Files
craft-libs/crates/update/src/install.rs
T
.CooCoo 6e03f65bf4 craft-update: a failed check for a running attach is reported on macOS (#803)
detach_unmounted leaves a disk image's attachments alone when the check
for a running `hdiutil attach` of the image gets no answer, and it
returned Ok in that case. A failed install then ended with no note that
the image may still be attached. It now returns the check's reason as
an error, so the install's error says "the disk image may still be
attached" with that reason, and craft-update logs it at warn level.
2026-11-11 11:22:26 -05:00

4380 lines
222 KiB
Rust

//! Installing, updating, removing and starting Craft Apps.
//!
//! Apps go where their platform keeps applications, installed the platform's own way:
//!
//! - macOS: the signed `.app` from the release DMG into `/Applications`.
//! - Windows: the release MSI (Windows Installer) into `C:\Program Files\<App>`.
//! - Linux: the distro's own package (`.deb` through apt on the Debian family, `.rpm` through
//! dnf/zypper/rpm on the Fedora, Red Hat and SUSE families, read from `os-release`) with the
//! system's polkit password prompt; otherwise a Flatpak, otherwise an AppImage in
//! `~/Applications` (the AppImage convention), with a menu entry and icon in
//! `~/.local/share` so it shows up like any other app. A Flatpak or AppImage launcher prefers
//! its own format.
//! - FreeBSD: the release's `/usr/local` tree, with a polkit prompt (or `~/.local` without one).
//! - Linux, inside the Flatpak launcher with no usable host Flatpak (none installed, or
//! `flatpak-spawn` not allowed): the release tarball, unpacked into the launcher's own folder
//! (`<data>/apps/<app>`) and run inside the same sandbox with `HOME` pointed at a `home/`
//! folder beside it, so the app's settings stay in the sandbox too.
//!
//! Every download is checked against its published SHA-256 before anything is unpacked, a
//! running app is never replaced, and a new copy is fully in place before the old one goes.
use std::path::{Path, PathBuf};
use crate::protocol::{CANCELLED, INSTALL_CANCELLED, InstallLocations, JobPhase};
use crate::release::{self, Asset, Release};
use crate::{App, InstallSource, Installed, PackageKind, version};
use crate::http::{Http, Stop, Transfer};
use crate::manifest::Record;
use crate::paths::Paths;
use crate::process;
/// What an install needs from the backend.
pub struct Job<'a> {
pub http: &'a Http,
pub paths: &'a Paths,
pub stop: &'a Stop,
pub phase: &'a mut dyn FnMut(JobPhase),
pub install_locations: &'a InstallLocations,
/// Write over whatever [`occupied`] found.
pub replace: bool,
}
/// Downloads and installs `asset` of `release`. Returns a record when the launcher owns the
/// result (portable ZIP, AppImage, macOS bundle, tarball); MSI installs are found in the registry.
/// `previous` is the launcher's record of the copy being replaced, if it installed it.
pub fn install(
app: &App,
release: &Release,
asset: &Asset,
kind: PackageKind,
existing: Option<&Installed>,
previous: Option<&Record>,
job: &mut Job<'_>,
) -> Result<Option<Record>, String> {
install_at(app, release, asset, kind, existing, previous, job, None)
}
type RecordCommit<'a> = Option<&'a mut dyn FnMut(&Record) -> Result<(), String>>;
/// Refuses to change an app that is running, or one whose state couldn't be checked.
fn not_running(app: &App, answer: Result<bool, String>) -> Result<(), String> {
match answer {
Ok(false) => Ok(()),
Ok(true) => Err(format!("{} is running. Quit it, then try again.", app.name)),
Err(reason) => Err(format!("couldn't check whether {} is running ({reason}), so nothing was changed", app.name)),
}
}
/// [`install`], with `commit` called once a portable ZIP's new files are in place and its version
/// checked, so the caller can save its record; an error from it rolls the previous copy back
/// (Windows only; ignored elsewhere). Refuses before downloading when the app is running, the
/// asset isn't the release's version (`bind_artifact_version`), a newer copy is installed, or the
/// link isn't the release's own (`check_download_link`).
#[allow(clippy::too_many_arguments)]
pub fn install_at(
app: &App,
release: &Release,
asset: &Asset,
kind: PackageKind,
existing: Option<&Installed>,
previous: Option<&Record>,
job: &mut Job<'_>,
commit: RecordCommit<'_>,
) -> Result<Option<Record>, String> {
#[cfg(windows)]
let mut commit = commit;
#[cfg(not(windows))]
let _ = commit;
#[allow(unused_variables)]
let destination = destination(app, kind, existing, previous, job.install_locations)?;
if let Some(inst) = existing
&& inst.source.launcher_can_update()
{
not_running(app, process::running(Path::new(&inst.location)))?;
}
bind_artifact_version(app, release, asset)?;
if !job.replace
&& let Some(inst) = existing
&& let (Some(rel_v), Some(inst_v)) = (release.version.as_ref(), inst.version.as_ref())
&& version::is_newer(inst_v, rel_v)
{
return Err(format!("Cannot update {} to {}: installed version {} is newer", app.name, release.version_label(), inst_v));
}
check_download_link(app, release, asset)?;
log::info!("{}: starting {} to {} ({})", app.name, if existing.is_some() { "update" } else { "install" }, release.version_label(), kind.label());
let file = download(release, asset, job)?;
log::info!("{}: download ready ({}), running installer...", app.name, file.display());
// Past this point the install can't be interrupted halfway.
(job.phase)(JobPhase::Installing);
#[allow(unused_variables)]
let version = release.version_label();
let result = match kind {
#[cfg(target_os = "macos")]
PackageKind::Dmg => {
let wait = macos::CheckTurn { stop: job.stop, phase: &mut *job.phase };
macos::install_dmg(app, &file, existing, destination.as_deref(), job.replace, job.paths, &macos::Expect::release(release), wait)
.map(|path| Some(record(&version, kind, path, Vec::new(), false)))
}
#[cfg(windows)]
PackageKind::Msi => windows::run_msi(&file, destination.as_deref(), job.stop, &mut *job.phase).map(|_| None),
#[cfg(windows)]
PackageKind::PortableZip => {
crate::portable::install(app, &file, &version, previous, job.paths, destination.as_deref(), job.replace, &mut |record| {
if release.version.is_some() && crate::detect::windows::executable_version(&record.path).as_ref() != release.version.as_ref() {
return Err("The portable executable does not match the selected version. The installation was rolled back.".into());
}
match commit.as_deref_mut() {
Some(commit) => commit(record),
None => Ok(()),
}
})
.map(Some)
}
#[cfg(target_os = "linux")]
PackageKind::Deb | PackageKind::Rpm | PackageKind::ArchPkg | PackageKind::Flatpak => {
let id = app.bundle_id();
linux::install_package(kind, &file, Some(&id), job.stop, &mut *job.phase).map(|_| None)
}
#[cfg(target_os = "linux")]
PackageKind::AppImage => {
linux::install_appimage(&file, &asset.name, destination.as_deref(), previous, job.replace).map(|(path, replaced)| {
let files = linux::integrate(app, &path, &replaced, previous);
Some(record(&version, kind, path, files, false))
})
}
#[cfg(target_os = "freebsd")]
PackageKind::TarGz => freebsd::install_tree(app, &file, job.paths).map(|(bin, files, root)| Some(record(&version, kind, bin, files, root))),
#[cfg(target_os = "linux")]
PackageKind::TarGz => linux::install_tree(app, &file, job.paths).map(|path| Some(record(&version, kind, path, Vec::new(), false))),
other => Err(format!("{} isn't supported on this system", other.label())),
};
match &result {
Ok(_) => log::info!("{}: successfully installed/updated to {}", app.name, release.version_label()),
Err(e) if e == CANCELLED => log::info!("{}: installation cancelled", app.name),
Err(e) => log::error!("{}: installation failed: {e}", app.name),
}
// A failed install keeps the download: the next try checks it against its published checksum
// again and skips the transfer.
if result.is_ok() {
let _ = std::fs::remove_file(&file);
}
// The system's own prompt was dismissed: the download itself wasn't cancelled.
result.map_err(|e| if e == CANCELLED { INSTALL_CANCELLED.to_string() } else { e })
}
/// Rejects an artifact whose own identity disagrees with the release that authorized it.
///
/// A feed or GitHub converter supplies the release version, the asset filename, and the download
/// URL independently. The downgrade guard and the persisted record both trust `release.version`,
/// so a controller could advertise a new version while pointing at an older official artifact —
/// bypassing the guard and recording a false installed version (finding: version not bound to the
/// release). We require the version encoded in the asset filename and the release tag embedded in
/// the download URL to agree with the release version before any bytes are fetched.
pub(crate) fn bind_artifact_version(app: &App, release: &Release, asset: &Asset) -> Result<(), String> {
let mismatch = || format!("{}'s download does not match the advertised version {}.", app.name, release.version_label());
let release_version = release.version.as_ref();
// The version in the asset filename (`<stem>-<version>-<os>-<arch>.<ext>`), when it carries one.
let stems: Vec<&str> = app.stems().collect();
if let Some(release_version) = release_version
&& let Some(parsed) = crate::platform::parse_asset_name(&asset.name, &stems)
&& version::parse(&parsed.version).as_ref() != Some(release_version)
{
return Err(mismatch());
}
// The release tag and filename embedded in a release download URL (`.../releases/download/<tag>/<name>`).
// A `+` in either is `%2B` there, as GitHub and `expected_download_url` write it; no other
// escape is decoded, so anything else still has to match as written.
if let Some((_, rest)) = asset.url.split_once("/releases/download/") {
let mut parts = rest.split('/');
let tag = parts.next().unwrap_or("").replace("%2B", "+");
let url_filename = parts.next().unwrap_or("").replace("%2B", "+");
if tag.is_empty()
|| release_version
.map_or(tag != release.tag, |release_version| tag != release.tag && version::parse(&tag).as_ref() != Some(release_version))
{
return Err(mismatch());
}
if !url_filename.is_empty() && url_filename != asset.name {
return Err(format!("{}'s download URL filename does not match selected asset {}.", app.name, asset.name));
}
}
Ok(())
}
/// The last check before any bytes are fetched (storytold/craft-launcher#764): the link must be
/// exactly the GitHub download for this release's tag and this asset's name in the release's
/// repository ([`Release::repo`], else the app's own), whichever converter (API, feed or
/// `SHA256SUMS.txt`) built the asset, and so must the link of the release's `SHA256SUMS.txt` that
/// the download is checked against. A `+` may be written plainly or as `%2B`
/// ([`release::same_download_url`]).
///
/// With this crate's `local-mirror` feature, the developer's loopback mirror is accepted too. The
/// consumer confines those links (the launcher's feed parser does) and must turn the feature on
/// itself (`craft-update/local-mirror`): its own feature of that name doesn't. This crate's tests
/// accept loopback as well, as their downloads come from a server on 127.0.0.1.
pub fn check_download_link(app: &App, release: &Release, asset: &Asset) -> Result<(), String> {
let repo = if release.repo.is_empty() { app.repo } else { release.repo.as_str() };
let own = |a: &Asset| {
((cfg!(feature = "local-mirror") || cfg!(test)) && crate::http::is_loopback(&a.url))
|| release::expected_download_url(repo, &release.tag, &a.name).is_some_and(|expected| release::same_download_url(&a.url, &expected))
};
if own(asset) && release.sums_asset().is_none_or(own) {
return Ok(());
}
Err(format!("{}'s download link isn't the one its release publishes ({}), so it wasn't downloaded.", app.name, asset.name))
}
/// What a new install would write over: something already at its destination that the launcher
/// didn't put there.
pub fn occupied(
app: &App,
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))] asset: &Asset,
kind: PackageKind,
existing: Option<&Installed>,
previous: Option<&Record>,
locations: &InstallLocations,
paths: &Paths,
) -> Result<Option<PathBuf>, String> {
if existing.is_some() || previous.is_some() {
return Ok(None);
}
let folder = destination(app, kind, None, None, locations)?;
let target = match kind {
PackageKind::Msi => folder,
PackageKind::PortableZip => Some(folder.unwrap_or_else(|| paths.apps()).join(app.id)),
PackageKind::Dmg => folder.map(|f| f.join(format!("{}.app", app.name))),
#[cfg(target_os = "linux")]
PackageKind::AppImage => folder.or_else(crate::detect::linux::appimage_dir).map(|f| f.join(&asset.name)),
_ => None,
};
Ok(target.filter(|t| std::fs::symlink_metadata(t).is_ok()))
}
/// The folder a new copy goes in: beside the launcher's copy it replaces, else the user's choice,
/// else the platform default (`None`). macOS updates find their bundle's folder themselves.
fn destination(
app: &App,
kind: PackageKind,
existing: Option<&Installed>,
previous: Option<&Record>,
locations: &InstallLocations,
) -> Result<Option<PathBuf>, String> {
if kind == PackageKind::PortableZip && previous.is_some_and(|record| record.kind == PackageKind::PortableZip) {
return Ok(None);
}
if let Some(inst) = existing {
let exe = Path::new(&inst.location);
return Ok(match kind {
PackageKind::AppImage => previous.filter(|r| r.kind == kind).and_then(|r| r.path.parent()).map(Path::to_path_buf),
PackageKind::Msi => exe.parent().filter(|_| exe.is_absolute()).map(Path::to_path_buf),
_ => None,
});
}
let Some(folder) = locations.folder(app.id) else { return Ok(None) };
if !matches!(kind, PackageKind::Dmg | PackageKind::AppImage | PackageKind::Msi | PackageKind::PortableZip) || process::in_sandbox() {
return Err("custom install folders aren't supported on this system".into());
}
let parent = selected_folder(folder, kind != PackageKind::Msi)?;
Ok(Some(if kind == PackageKind::Msi { parent.join(app.name) } else { parent }))
}
fn selected_folder(folder: &str, writable: bool) -> Result<PathBuf, String> {
let path = if folder == "~" {
crate::folders::home_dir().ok_or("no home folder")?
} else if let Some(relative) = folder.strip_prefix("~/") {
crate::folders::home_dir().ok_or("no home folder")?.join(relative)
} else {
PathBuf::from(folder)
};
let invalid = |reason: &str| format!("couldn't use install folder {}: {reason}", path.display());
if folder.chars().any(|c| c.is_control() || (cfg!(windows) && c == '"')) {
return Err(invalid("the path contains invalid characters"));
}
if !path.is_absolute() {
return Err(invalid("choose an absolute path"));
}
if !path.is_dir() {
return Err(invalid("the folder is unavailable. Create it or reconnect its drive first"));
}
if writable {
let probe = path.join(format!(
".artcraft-launcher-write-test-{}-{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos())
));
let output = std::fs::OpenOptions::new().write(true).create_new(true).open(&probe).map_err(|e| invalid(&e.to_string()))?;
drop(output);
std::fs::remove_file(&probe).map_err(|e| invalid(&e.to_string()))?;
}
Ok(path)
}
#[cfg(any(windows, test))]
fn msi_folder_argument(path: &Path) -> Result<String, String> {
let value = path.to_str().ok_or("unusual characters in a path")?;
if value.chars().any(|c| c == '"' || c.is_control()) {
return Err(format!("couldn't use install folder {value}: the path contains invalid characters"));
}
Ok(format!("INSTALLFOLDER=\"{value}\""))
}
/// Where `asset` is downloaded to (and its partial file kept while unfinished).
pub fn download_path(paths: &Paths, asset: &Asset) -> PathBuf {
paths.downloads().join(&asset.name)
}
#[allow(dead_code)]
fn record(version: &str, kind: PackageKind, path: PathBuf, files: Vec<PathBuf>, needs_root: bool) -> Record {
Record { version: version.to_string(), kind, path, installed_at: crate::now(), files, needs_root }
}
/// The package format to install `app` with: the format of the copy that's there (an update
/// stays a `.deb` update), else the user's choice, else the best this system supports.
///
/// Returns an empty `Vec` when `app` is already installed outside the launcher (such as an AUR
/// or repository Arch package) and must not be updated or replaced by the launcher.
pub fn choose_kinds(app: &App, existing: Option<&Installed>, record: Option<&Record>, preferred: &[PackageKind]) -> Vec<PackageKind> {
let available = available_kinds();
let can_pacman = available.contains(&PackageKind::ArchPkg) || can_pacman();
choose_kinds_with(app, existing, record, preferred, &available, can_pacman)
}
pub(crate) fn choose_kinds_with(
app: &App,
existing: Option<&Installed>,
record: Option<&Record>,
preferred: &[PackageKind],
available: &[PackageKind],
can_pacman: bool,
) -> Vec<PackageKind> {
if let Some(inst) = existing {
let kind = match inst.source {
InstallSource::Launcher => record.map(|r| r.kind),
InstallSource::MacApp => Some(PackageKind::Dmg),
InstallSource::Msi => Some(PackageKind::Msi),
InstallSource::Deb => Some(PackageKind::Deb),
InstallSource::Rpm => Some(PackageKind::Rpm),
// The release's own Arch package is updated like a .deb. AUR builds and repository
// packages are updated outside the launcher (see `managed_elsewhere`); the launcher
// opens and removes them but never replaces them.
InstallSource::Pacman if is_release_arch_package(app, inst) => Some(PackageKind::ArchPkg),
InstallSource::Pacman => return Vec::new(),
InstallSource::Flatpak => Some(PackageKind::Flatpak),
InstallSource::AppImage => Some(PackageKind::AppImage),
InstallSource::Binary => Some(PackageKind::TarGz),
};
if let Some(k) = kind.filter(|k| available.contains(k) || (*k == PackageKind::ArchPkg && can_pacman)) {
return vec![k];
}
}
let mut kinds: Vec<PackageKind> = preferred.iter().copied().filter(|k| available.contains(k)).collect();
for k in available {
if !kinds.contains(k) {
kinds.push(*k);
}
}
kinds
}
/// Whether this system has a working pacman installation and pacman database.
fn can_pacman() -> bool {
#[cfg(target_os = "linux")]
return find_in(&SYSTEM_BIN_DIRS, std::ffi::OsStr::new("pacman")).is_some() && crate::detect::linux::has_pacman_db();
#[cfg(not(target_os = "linux"))]
return false;
}
/// Whether a pacman install is the release's own Arch package rather than an AUR build. The
/// release names it by the bundle id (`ai.storyteller.<app>`), which no AUR package uses
/// (they are `<app>`, `<app>-bin` and `<app>-git`), so an AUR helper never mistakes it
/// for one of its own and rebuilds it from source.
pub fn is_release_arch_package(app: &App, installed: &Installed) -> bool {
installed.source == InstallSource::Pacman && installed.package.as_deref() == Some(&app.bundle_id())
}
/// Package formats this machine can install, best first.
pub fn available_kinds() -> Vec<PackageKind> {
#[cfg(target_os = "macos")]
return vec![PackageKind::Dmg];
#[cfg(windows)]
return vec![PackageKind::Msi, PackageKind::PortableZip];
#[cfg(target_os = "linux")]
return linux::available_kinds();
#[cfg(target_os = "freebsd")]
return vec![PackageKind::TarGz];
#[allow(unreachable_code)]
Vec::new()
}
/// Fetches `asset` into the download folder, checked against its published SHA-256: the feed's
/// or GitHub's per-file digest, else the release's `SHA256SUMS.txt`. A release with neither is
/// refused before anything is fetched: a file nothing vouches for is never installed (#90).
pub(crate) fn download(release: &Release, asset: &Asset, job: &mut Job<'_>) -> Result<PathBuf, String> {
if !release::valid_file_name(&asset.name) {
return Err("the release has an unexpected file name".into());
}
let sha = match &asset.sha256 {
Some(s) => Some(s.clone()),
None => published_checksum(release, asset, job.http)?,
};
let Some(sha) = sha else {
return Err(format!(
"the release publishes no checksum for {}, so it can't be verified and wasn't installed. Try again later, or report it on GitHub.",
asset.name
));
};
let dest = download_path(job.paths, asset);
let phase = &mut *job.phase;
job.http.download(&asset.url, &dest, asset.size, &sha, job.stop, &mut |t| match t {
Transfer::Bytes { done, total } => phase(JobPhase::Downloading { done, total }),
Transfer::Reconnecting { done, total } => phase(JobPhase::Reconnecting { done, total }),
})?;
(job.phase)(JobPhase::Verifying);
Ok(dest)
}
/// The file's line in the release's `SHA256SUMS.txt`. `Ok(None)` only when the release really
/// publishes no checksum for it; a list that exists but can't be fetched is an error, so a
/// network hiccup never downgrades the check.
fn published_checksum(release: &Release, asset: &Asset, http: &Http) -> Result<Option<String>, String> {
let Some(sums) = release.sums_asset() else { return Ok(None) };
let Some(text) = http.get_text(&sums.url).map_err(|e| format!("couldn't fetch the release's checksums ({e}). Try again"))? else {
return Ok(None);
};
Ok(release::parse_sha256sums(&text).into_iter().find(|(_, n)| *n == asset.name).map(|(h, _)| h))
}
/// When `installed` is owned by something the launcher doesn't drive, what to tell the user
/// instead of installing a second copy. Arch packages other than the release's own are removed
/// through pacman like any system package, but updated by whatever they came from: a system
/// update for a repository package (Omarchy's, Chaotic-AUR, ...), the user's AUR helper (which
/// builds as the user and can't run behind the password prompt) for anything else.
pub fn managed_elsewhere(app: &App, installed: &Installed, uninstall: bool) -> Option<String> {
managed_elsewhere_with(app, installed, uninstall, pacman_repo)
}
fn managed_elsewhere_with(app: &App, installed: &Installed, uninstall: bool, repo_fn: impl Fn(&str) -> Option<String>) -> Option<String> {
match installed.source {
InstallSource::Pacman if !uninstall && !is_release_arch_package(app, installed) => {
let pkg = installed.package.clone().unwrap_or_else(|| app.id.to_string());
Some(pacman_update_hint(app, &pkg, repo_fn(&pkg).as_deref()))
}
InstallSource::Binary => Some(format!("{} was installed outside the launcher. Update or remove it the way it was installed.", app.name)),
_ => None,
}
}
/// How to update an Arch package the launcher doesn't own: `repo` is the sync repository it
/// comes from, or `None` for a foreign (AUR) package.
fn pacman_update_hint(app: &App, pkg: &str, repo: Option<&str>) -> String {
let helper = ["paru", "yay"].into_iter().find(|h| which(h).is_some());
let cmd = match helper {
Some(h) => format!("{h} -S {pkg}"),
None => format!("your AUR helper (for example: yay -S {pkg})"),
};
if let Some(repo) = repo {
return format!("{} was installed from the {repo} repository or the AUR ({pkg}). Update it with {cmd} or sudo pacman -Syu", app.name);
}
format!("{} was installed from the AUR ({pkg}). Update it with {cmd}", app.name)
}
/// The sync repository (`extra`, `omarchy`, ...) that carries the installed package `pkg`, or
/// `None` when no configured repository does (an AUR or locally built package) or pacman can't
/// be asked.
fn pacman_repo(pkg: &str) -> Option<String> {
#[cfg(target_os = "linux")]
if !crate::detect::linux::has_pacman_db() {
return None;
}
let pacman = which("pacman")?;
let t = std::time::Duration::from_secs(5);
// For packages carried by a configured sync repository, ask pacman -Si which repo carries it.
// pacman translates its field names; the C locale keeps "Repository" parseable.
let mut si = process::command(&pacman);
si.args(["-Si", "--", pkg]).env("LC_ALL", "C");
let out = process::run_command(si, "pacman", t).ok()?;
parse_pacman_repo(&out)
}
/// The `Repository` field of `pacman -Si` output (the first one, when several repos carry it:
/// that is the one pacman installs from).
fn parse_pacman_repo(si: &str) -> Option<String> {
si.lines().find_map(|l| {
let (key, value) = l.split_once(':')?;
(key.trim() == "Repository").then(|| value.trim().to_string()).filter(|r| !r.is_empty())
})
}
/// Removes an installed copy. `phase` hears when the removal has to wait for another install;
/// `stop` cancels it while it waits.
pub fn uninstall(
app: &App,
installed: &Installed,
record: Option<&Record>,
paths: &Paths,
stop: &Stop,
phase: &mut dyn FnMut(JobPhase),
) -> Result<(), String> {
let _ = (record, stop, &phase, paths);
let path = PathBuf::from(&installed.location);
if let Some(msg) = managed_elsewhere(app, installed, true) {
return Err(msg);
}
if matches!(installed.source, InstallSource::MacApp | InstallSource::Msi | InstallSource::AppImage | InstallSource::Launcher) {
not_running(app, process::running(&path))?;
}
match installed.source {
#[cfg(windows)]
InstallSource::Launcher if record.is_some_and(|r| r.kind == PackageKind::PortableZip) => {
crate::portable::remove(app, record.ok_or("the launcher has no record of these files")?)
}
#[cfg(windows)]
InstallSource::Msi => windows::uninstall_msi(app, stop, phase),
#[cfg(target_os = "macos")]
InstallSource::MacApp | InstallSource::Launcher => macos::move_to_trash(&path),
#[cfg(target_os = "linux")]
InstallSource::Deb | InstallSource::Rpm | InstallSource::Pacman | InstallSource::Flatpak => {
linux::remove_package(app, installed, stop, phase)
}
#[cfg(target_os = "freebsd")]
InstallSource::Launcher => match record {
Some(r) => freebsd::remove_files(&r.files, r.needs_root),
None => Err("the launcher has no record of these files".into()),
},
#[cfg(target_os = "linux")]
InstallSource::AppImage => remove_appimage(app, &path, record),
// A tarball the launcher unpacked into its own folder: take the tree out and leave the
// app's settings (`home/`) alone, so a reinstall finds its projects where they were.
#[cfg(target_os = "linux")]
InstallSource::Launcher if record.is_some_and(|r| r.kind == PackageKind::TarGz) => linux::remove_tree(app, paths),
#[cfg(target_os = "linux")]
InstallSource::Launcher => remove_appimage(app, &path, record),
other => Err(format!("{} can't be removed from here", other.label())),
}
}
/// Deletes an AppImage, the menu entry and icon the launcher wrote for it, and the menu entry
/// the app may have written for itself on first run.
#[cfg(target_os = "linux")]
fn remove_appimage(app: &App, path: &Path, record: Option<&Record>) -> Result<(), String> {
if path.extension().is_none_or(|e| !e.eq_ignore_ascii_case("AppImage")) || !path.is_file() {
return Err(format!("{} isn't an AppImage, so it was left alone", path.display()));
}
// AppImages found elsewhere (Gear Lever's ~/AppImages, ~/.local/bin) are the user's own:
// remove only the launcher's folder or a copy the launcher recorded installing.
let ours =
path.parent().is_some_and(|dir| crate::detect::linux::appimage_dir().is_some_and(|own| own == dir)) || record.is_some_and(|r| r.path == path);
if !ours {
return Err(format!("{} wasn't installed by the launcher. Remove it the way it was added.", path.display()));
}
std::fs::remove_file(path).map_err(|e| format!("couldn't remove {} ({e})", path.display()))?;
linux::unintegrate(app, path, record);
Ok(())
}
/// Starts an installed app.
pub fn launch(app: &App, installed: &Installed, paths: &Paths) -> Result<(), String> {
let _ = paths;
let path = Path::new(&installed.location);
match installed.source {
InstallSource::Flatpak => {
let mut cmd = process::flatpak_command();
cmd.args(["run", &app.bundle_id()]);
process::spawn_detached(cmd)
}
InstallSource::MacApp => process::open(path.as_os_str()),
// A tarball the launcher unpacked into its own folder: run it here, against its own
// home, rather than on the host through `flatpak-spawn` (which is what `launch_binary`
// would do inside the sandbox). Its settings then land beside it instead of in the
// launcher's or the host's home folder.
#[cfg(target_os = "linux")]
InstallSource::Launcher if path.starts_with(paths.apps()) => {
let home = paths.app_home(app.id);
prepare_home(&home);
let mut cmd = process::command(path);
for (name, value) in home_env(&home) {
cmd.env(name, value);
}
if let Some(dir) = path.parent() {
cmd.current_dir(dir);
}
process::spawn_detached(cmd)
}
_ if cfg!(target_os = "macos") && path.extension().is_some_and(|e| e == "app") => process::open(path.as_os_str()),
_ => {
if !path.exists() {
return Err(format!("{} isn't where it was installed ({})", app.name, path.display()));
}
process::launch_binary(path, &[])
}
}
.map_err(|e| format!("Couldn't open {}: {e}", app.name))
}
/// The environment a Craft App unpacked by the launcher runs with: its own `HOME` and the XDG
/// base directories inside it. Apps read their settings from these, so everything they keep
/// (settings, projects, caches) stays in that one folder.
#[cfg(target_os = "linux")]
fn home_env(home: &Path) -> [(&'static str, PathBuf); 5] {
[
("HOME", home.to_path_buf()),
("XDG_CONFIG_HOME", home.join(".config")),
("XDG_DATA_HOME", home.join(".local/share")),
("XDG_CACHE_HOME", home.join(".cache")),
("XDG_STATE_HOME", home.join(".local/state")),
]
}
/// Creates the folders an app unpacked by the launcher expects to find, and links the host's
/// `~/.vst3` and `~/.clap` (SoundCraft's plugin folders) in when they exist, so an app running
/// inside the sandbox still sees the plugins installed on the host. Every failure here is
/// ignored: the app is worth starting even if its home is not quite ready.
#[cfg(target_os = "linux")]
fn prepare_home(home: &Path) {
for (_, dir) in home_env(home).iter().skip(1) {
let _ = std::fs::create_dir_all(dir);
}
let Some(real) = crate::folders::home_dir() else { return };
if real == home {
return;
}
for name in [".vst3", ".clap"] {
let link = home.join(name);
if link.symlink_metadata().is_ok() {
continue;
}
if real.join(name).is_dir() {
let _ = std::os::unix::fs::symlink(real.join(name), link);
}
}
}
/// A new folder of our own inside `dir`, removed when dropped: a predictable name there may be
/// the user's.
#[cfg(target_os = "linux")]
fn staging_dir(dir: &Path) -> Result<tempfile::TempDir, String> {
tempfile::Builder::new()
.prefix(".artcraft-launcher-")
.tempdir_in(dir)
.map_err(|e| format!("couldn't create a staging folder in {} ({e})", dir.display()))
}
/// Moves `staged` into place at `dest`, keeping the old copy until the new one is there.
#[allow(dead_code)]
fn swap_into_place(staged: &Path, dest: &Path) -> Result<(), String> {
swap_into_place_via(staged, dest, |from, to| std::fs::rename(from, to))
}
fn swap_into_place_via(staged: &Path, dest: &Path, rename: impl Fn(&Path, &Path) -> std::io::Result<()>) -> Result<(), String> {
// Reserve a directory of our own beside the destination. A predictable sibling such as
// `app.old` may be a user's backup, and must never be removed or overwritten.
let backup = if dest.exists() {
let parent = dest.parent().ok_or("the installed app has no parent folder")?;
let backup = tempfile::Builder::new()
.prefix(".artcraft-launcher-backup-")
.tempdir_in(parent)
.map_err(|e| format!("couldn't create a backup folder in {} ({e})", parent.display()))?;
rename(dest, &backup.path().join("old")).map_err(|e| format!("couldn't move the old copy aside ({e})"))?;
Some(backup)
} else {
None
};
if let Err(e) = rename(staged, dest) {
if let Some(backup) = backup
&& let Err(restore) = rename(&backup.path().join("old"), dest)
{
// A failed rollback must not let TempDir's cleanup delete the only old copy.
let saved = backup.keep().join("old");
return Err(format!(
"couldn't put the new copy in place ({e}); couldn't restore the old copy ({restore}); it was kept at {}",
saved.display()
));
}
return Err(format!("couldn't put the new copy in place ({e})"));
}
Ok(())
}
/// After unpacking an archive into `dir`: its single top-level folder, if it has one.
#[allow(dead_code)]
fn single_top_folder(dir: &Path) -> Result<PathBuf, String> {
let entries: Vec<_> = std::fs::read_dir(dir).map_err(|e| e.to_string())?.flatten().collect();
match entries.as_slice() {
[only] if only.file_type().is_ok_and(|ft| ft.is_dir() && !ft.is_symlink()) => Ok(only.path()),
_ => Ok(dir.to_path_buf()),
}
}
/// The app binary inside an unpacked archive: `bin/<id>`, `<id>` or `<id>.exe`.
#[allow(dead_code)]
fn find_binary(root: &Path, app: &App) -> Option<PathBuf> {
let exe = if cfg!(windows) { format!("{}.exe", app.id) } else { app.id.to_string() };
[root.join("bin").join(&exe), root.join(&exe)].into_iter().find(|p| p.is_file())
}
/// Finds a program on `PATH`.
#[allow(dead_code)]
fn which(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path).map(|d| d.join(name)).find(|p| p.is_file())
}
/// The root-owned directories that programs run as root come from. `pkexec` looks a bare program
/// name up on the caller's `PATH`, so it is always handed an absolute path from one of these.
#[cfg(target_os = "linux")]
const SYSTEM_BIN_DIRS: [&str; 4] = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"];
/// FreeBSD's packages, polkit's `pkexec` among them, install under `/usr/local`.
#[cfg(target_os = "freebsd")]
const SYSTEM_BIN_DIRS: [&str; 6] = ["/usr/bin", "/bin", "/usr/sbin", "/sbin", "/usr/local/bin", "/usr/local/sbin"];
/// `name` in the first of `dirs` that has it.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn find_in(dirs: &[impl AsRef<Path>], name: &std::ffi::OsStr) -> Option<PathBuf> {
dirs.iter().map(|d| d.as_ref().join(name)).find(|p| p.is_file())
}
/// `args` with its program as an absolute path: a bare name is looked up in `dirs`.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn with_absolute_program(dirs: &[impl AsRef<Path>], args: &[&std::ffi::OsStr]) -> Result<Vec<std::ffi::OsString>, String> {
let (program, rest) = args.split_first().ok_or("there is no command to run")?;
let path = Path::new(program);
let program = if path.is_absolute() {
path.to_path_buf()
} else if path.file_name() == Some(*program) {
find_in(dirs, program).ok_or_else(|| format!("{} isn't installed", path.display()))?
} else {
return Err(format!("{} isn't a program name", path.display()));
};
Ok(std::iter::once(program.into_os_string()).chain(rest.iter().map(|a| a.to_os_string())).collect())
}
/// polkit's `pkexec`, from the same directories as the programs it runs.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn pkexec() -> Option<PathBuf> {
find_in(&SYSTEM_BIN_DIRS, std::ffi::OsStr::new("pkexec"))
}
/// Runs a command as root through polkit (`pkexec`), which shows the desktop's password prompt.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn run_as_root(args: &[&std::ffi::OsStr]) -> Result<(), String> {
let args = with_absolute_program(&SYSTEM_BIN_DIRS, args)?;
let pkexec = pkexec().ok_or("installing system-wide needs polkit (pkexec), which isn't installed")?;
let status = process::command(pkexec).args(&args).status().map_err(|e| format!("couldn't ask for permission ({e})"))?;
root_outcome(status.code())
}
/// What `pkexec`'s exit code says about the command it ran.
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
fn root_outcome(code: Option<i32>) -> Result<(), String> {
match code {
Some(0) => Ok(()),
// pkexec: 126 = the password prompt was dismissed, 127 = not authorized.
Some(126 | 127) => Err("cancelled".into()),
Some(c) => Err(format!("the system installer stopped with error {c}")),
None => Err("the system installer was interrupted".into()),
}
}
/// How the error of an installer call shared by several apps ends (see `linux::shared_outcome`).
const SHARED_CALL: &str = "apps together. Try them one at a time.";
/// Whether an install failed in one installer call shared with other apps (Linux packages
/// installed together). Such a call can fail after it has installed some of its apps; any other
/// failure is the app's own.
pub fn failed_in_shared_call(error: &str) -> bool {
cfg!(target_os = "linux") && error.ends_with(SHARED_CALL)
}
/// The system's installer runs one job at a time; the others wait their turn here.
#[cfg(any(target_os = "linux", windows))]
static INSTALLER: crate::queue::Queue = crate::queue::Queue::new();
/// For a [`crate::queue::Queue`]: reports [`JobPhase::Waiting`] while a job waits, then `working` again.
#[cfg(any(target_os = "linux", target_os = "macos", windows))]
fn turn<'a>(phase: &'a mut dyn FnMut(JobPhase), working: JobPhase) -> impl FnMut(bool) + 'a {
move |waiting| phase(if waiting { JobPhase::Waiting } else { working })
}
/// For a [`crate::queue::Queue`]: whether the user cancelled the job while it waits.
#[cfg(any(target_os = "linux", target_os = "macos", windows))]
fn cancelled(stop: &Stop) -> impl Fn() -> bool + '_ {
|| stop.check().is_err_and(|e| e == crate::protocol::CANCELLED)
}
#[cfg(target_os = "linux")]
pub(crate) mod linux {
use std::ffi::{OsStr, OsString};
use std::os::unix::fs::PermissionsExt;
use std::time::Duration;
use crate::distro;
use crate::protocol::CANCELLED;
use crate::version::{self, Version};
use super::{
App, INSTALLER, InstallSource, Installed, JobPhase, PackageKind, Path, PathBuf, Paths, Record, SHARED_CALL, SYSTEM_BIN_DIRS, Stop, cancelled,
find_in, pkexec, process, run_as_root, swap_into_place, tree, turn,
};
/// apt waits this long for another package tool (a software centre, automatic updates) to
/// let go of its lock, instead of failing at once.
const APT_WAITS: &str = "DPkg::Lock::Timeout=60";
/// A package tool that root commands can run, from the same directories `run_as_root` uses.
fn system_tool(name: &str) -> Option<PathBuf> {
find_in(&SYSTEM_BIN_DIRS, OsStr::new(name))
}
/// Where commands that need root go: the installer's queue, then `pkexec`.
pub struct Root<'a> {
pub queue: &'a crate::queue::Queue,
pub run: &'a (dyn Fn(&[&OsStr]) -> Result<(), String> + Sync),
pub flatpak: &'a (dyn Fn() -> std::process::Command + Sync),
}
const SYSTEM: Root<'static> = Root { queue: &INSTALLER, run: &run_as_root, flatpak: &process::flatpak_command_c };
/// Runs `command target` as root. Jobs waiting for the same command go into one call
/// (`apt-get install a.deb b.deb`), so the password is asked for once.
fn as_root(
root: &Root<'_>,
command: &[&str],
target: &OsStr,
stop: &Stop,
phase: &mut dyn FnMut(JobPhase),
working: JobPhase,
) -> Result<(), String> {
let command = command.iter().map(OsString::from).collect();
root.queue.batched(command, target.to_os_string(), &cancelled(stop), &mut turn(phase, working), &|command, targets| {
let args: Vec<&OsStr> = command.iter().chain(targets).map(OsString::as_os_str).collect();
shared_outcome((root.run)(&args), targets.len())
})
}
/// What one call's result means for each of the `count` apps it handled: a failure can't be
/// pinned on one of them, and trying each again by itself would ask for the password each time.
pub fn shared_outcome(result: Result<(), String>, count: usize) -> Result<(), String> {
match result {
Err(e) if count > 1 && e != CANCELLED => Err(format!("{e} while handling {count} {SHARED_CALL}")),
other => other,
}
}
/// The command that installs (or upgrades) a downloaded system package.
pub fn install_command(kind: PackageKind) -> Option<&'static [&'static str]> {
match kind {
// apt resolves dependencies and records the package like any other.
PackageKind::Deb => Some(&["apt-get", "-o", APT_WAITS, "install", "-y", "--allow-downgrades"]),
PackageKind::Rpm if system_tool("dnf").is_some() => Some(&["dnf", "install", "-y"]),
PackageKind::Rpm if system_tool("zypper").is_some() => Some(&["zypper", "--non-interactive", "install", "--allow-unsigned-rpm"]),
PackageKind::Rpm => Some(&["rpm", "-U", "--replacepkgs"]),
PackageKind::ArchPkg => Some(&["pacman", "-U", "--noconfirm"]),
_ => None,
}
}
/// Fedora Atomic (Silverblue, Kinoite, Bazzite…): rpm-ostree manages the root filesystem and
/// `dnf install` of a local package is refused (Bazzite wraps `/usr/bin/dnf` to say so).
fn immutable_ostree() -> bool {
Path::new("/run/ostree-booted").exists()
}
/// What this system can install, ordered with the best default first.
///
/// The default package format aligns with how the user installed the launcher itself:
/// - Running as a Flatpak: prefers Flatpak (per-user, desktop integration). When the host
/// has no usable Flatpak (none installed, or `flatpak-spawn` refused) it prefers the
/// release tarball instead, which the launcher unpacks and runs inside the sandbox; an
/// AppImage cannot mount there and a distro package needs root, so both are dead ends.
/// - Running as an AppImage: prefers AppImage.
/// - Otherwise (system install): prefers the distro's own package format (`.deb` on the
/// Debian family, `.rpm` on the Fedora, Red Hat and SUSE families, `.pkg.tar.zst` on Arch,
/// read from `os-release` rather than from whichever tools are on `PATH`), then Flatpak if
/// available, then AppImage.
pub fn available_kinds() -> Vec<PackageKind> {
let flatpak = process::is_flatpak_available();
let is_flatpak = process::in_sandbox();
let is_appimage = std::env::var_os("APPIMAGE").is_some();
let root = pkexec().is_some();
let os = ["/etc/os-release", "/usr/lib/os-release"].iter().find_map(|p| std::fs::read_to_string(p).ok()).unwrap_or_default();
let native = distro::native_package(&distro::parse_os_release(&os));
// An ostree system can't take .deb or .rpm installs, whatever os-release says.
let root = root && !immutable_ostree();
let can_deb = root && native == Some(PackageKind::Deb) && system_tool("apt-get").is_some() && system_tool("dpkg").is_some();
let can_rpm = root && native == Some(PackageKind::Rpm) && system_tool("rpm").is_some();
let can_arch = root && native == Some(PackageKind::ArchPkg) && system_tool("pacman").is_some() && crate::detect::linux::has_pacman_db();
order_available_kinds(is_flatpak, is_appimage, flatpak, can_deb, can_rpm, can_arch)
}
pub(crate) fn order_available_kinds(
is_flatpak: bool,
is_appimage: bool,
flatpak_available: bool,
can_deb: bool,
can_rpm: bool,
can_arch: bool,
) -> Vec<PackageKind> {
let mut kinds = Vec::new();
if is_flatpak && flatpak_available {
kinds.push(PackageKind::Flatpak);
} else if is_flatpak {
kinds.push(PackageKind::TarGz);
} else if is_appimage {
kinds.push(PackageKind::AppImage);
}
if can_deb {
kinds.push(PackageKind::Deb);
}
if can_rpm {
kinds.push(PackageKind::Rpm);
}
if can_arch {
kinds.push(PackageKind::ArchPkg);
}
if flatpak_available && !kinds.contains(&PackageKind::Flatpak) {
kinds.push(PackageKind::Flatpak);
}
// The tarball runs inside the sandbox, so it is only ever offered there.
if is_flatpak && !kinds.contains(&PackageKind::TarGz) {
kinds.push(PackageKind::TarGz);
}
if !kinds.contains(&PackageKind::AppImage) {
kinds.push(PackageKind::AppImage);
}
kinds
}
/// Flags for `flatpak install` of one bundle. A per-user install stays noninteractive.
/// A system-wide install is also noninteractive: it runs under `pkexec`, which asks for the
/// password. Flatpak's own install-bundle action does not. Its polkit message needs `$(path)`,
/// and current polkit denies the action when that detail is missing, with no dialog.
pub(crate) fn flatpak_bundle_flags(system: bool) -> &'static [&'static str] {
if system {
&["install", "--system", "-y", "--noninteractive", "--reinstall", "--bundle"]
} else {
&["install", "--user", "-y", "--noninteractive", "--reinstall", "--bundle"]
}
}
/// Program prefix for a system-wide bundle reinstall. Inside the sandbox the host runs it,
/// because this process cannot see the host's `pkexec` or write `/var/lib/flatpak`.
pub(crate) fn system_bundle_prefix(in_sandbox: bool) -> &'static [&'static str] {
if in_sandbox { &["flatpak-spawn", "--host", "pkexec", "/usr/bin/flatpak"] } else { &["pkexec", "/usr/bin/flatpak"] }
}
fn system_bundle_command(file: &Path) -> Result<std::process::Command, String> {
if !process::in_sandbox() && pkexec().is_none() {
return Err("installing system-wide needs polkit (pkexec), which isn't installed".into());
}
let prefix = system_bundle_prefix(process::in_sandbox());
let Some((program, rest)) = prefix.split_first() else {
return Err("couldn't start Flatpak".into());
};
let mut cmd = process::command(*program);
cmd.args(rest);
cmd.args(flatpak_bundle_flags(true));
cmd.arg(file);
Ok(cmd)
}
pub(crate) fn flatpak_bundle_error(output: &std::process::Output) -> Result<(), String> {
if output.status.success() {
return Ok(());
}
// pkexec: 126 = the password prompt was dismissed, 127 = not authorized.
if matches!(output.status.code(), Some(126 | 127)) {
let detail = String::from_utf8_lossy(&output.stderr);
log::warn!("flatpak bundle install ended with {:?}: {}", output.status.code(), detail.trim());
return Err(CANCELLED.into());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let err = stderr.trim();
let err = if err.is_empty() { String::from_utf8_lossy(&output.stdout).trim().to_string() } else { err.to_string() };
if err.is_empty() {
Err("Flatpak couldn't install the bundle (it needs the Flathub remote for its runtime)".into())
} else {
let clean_err = err.strip_prefix("error: ").or_else(|| err.strip_prefix("Error: ")).unwrap_or(&err);
Err(format!("Flatpak couldn't install the bundle: {clean_err}"))
}
}
/// Puts a self-update's `.flatpak` bundle in place. `system` replaces the system-wide copy
/// through `pkexec`. Per-user stays a normal `flatpak install --user`, which needs no password.
pub(crate) fn update_flatpak_bundle(file: &Path, id: &str, system: bool, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
update_flatpak_bundle_via(&SYSTEM, file, id, system, stop, phase)
}
/// An app never uninstalls itself here: its download goes after this, so a failed install would
/// leave nothing. "Already installed" means an earlier update landed and never restarted.
pub(crate) fn update_flatpak_bundle_via(
root: &Root<'_>,
file: &Path,
id: &str,
system: bool,
stop: &Stop,
phase: &mut dyn FnMut(JobPhase),
) -> Result<(), String> {
match install_flatpak_bundle_via(root, file, None, system, stop, phase) {
Err(e) if already_installed(&e, id) => {
log::info!("{id} is already installed: only the restart is left");
Ok(())
}
result => result,
}
}
/// Installs a downloaded `.flatpak` bundle. `bundle` is the app id it holds, for a per-user install
/// that may uninstall that app first.
fn install_flatpak_bundle_via(
root: &Root<'_>,
file: &Path,
bundle: Option<&str>,
system: bool,
stop: &Stop,
phase: &mut dyn FnMut(JobPhase),
) -> Result<(), String> {
let install_one = || {
log::info!("executing flatpak install --reinstall --bundle for {}", file.display());
let mut cmd = if system {
system_bundle_command(file)?
} else {
// The C locale keeps the "already installed" error below readable.
let mut cmd = (root.flatpak)();
cmd.args(flatpak_bundle_flags(false));
cmd.arg(file);
cmd
};
let output = cmd.output().map_err(|e| format!("couldn't start Flatpak ({e})"))?;
if output.status.success() {
log::info!("flatpak install succeeded for {}", file.display());
}
flatpak_bundle_error(&output)
};
// Inside the queue: an uninstall racing another bundle install can drop its remote.
let install = || {
let result = install_one();
match bundle {
Some(id) if result.as_ref().is_err_and(|e| already_installed(e, id)) && reinstall_by_hand(root) => {
log::info!("{id} is already installed and this Flatpak can't reinstall a bundle: uninstalling it first");
// No `--delete-data`: the app's files stay for the next install.
if !(root.flatpak)().args(["uninstall", "--user", "-y", "--noninteractive", id]).status().is_ok_and(|s| s.success()) {
log::warn!("couldn't uninstall {id} to install its bundle again");
return result;
}
install_one()
.map_err(|e| format!("{e} (It was uninstalled first. Its data is kept, and Install tries again with the same download.)"))
}
_ => result,
}
};
root.queue.alone(&cancelled(stop), &mut turn(phase, JobPhase::Installing), install).unwrap_or(Err(CANCELLED.into()))
}
/// Whether a bundle install of `id` failed because the installation already has the bundle's
/// commit: Flatpak's "already installed", in the C locale. Any other failure leaves the app alone.
pub(crate) fn already_installed(err: &str, id: &str) -> bool {
err.contains(&format!("{id} already installed"))
}
/// Whether the user's copy has to be uninstalled before its bundle installs again: Flatpak
/// before 1.16.2 ignores `--reinstall` for a bundle.
fn reinstall_by_hand(root: &Root<'_>) -> bool {
let mut cmd = (root.flatpak)();
cmd.arg("--version");
process::run_command(cmd, "flatpak", Duration::from_secs(10)).is_ok_and(|v| ignores_bundle_reinstall(&v))
}
/// Whether `flatpak --version` names a Flatpak that ignores `--reinstall` for a bundle (flatpak
/// commit 4c5693b8 added it in 1.16.2).
pub(crate) fn ignores_bundle_reinstall(flatpak_version: &str) -> bool {
flatpak_version.split_whitespace().last().and_then(version::parse).is_some_and(|v| v < Version::new(1, 16, 2))
}
/// Installs (or upgrades) a downloaded package with the system's own tools. `bundle` is the app
/// id a Flatpak bundle holds.
pub fn install_package(kind: PackageKind, file: &Path, bundle: Option<&str>, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
if kind == PackageKind::Deb && system_tool("apt-get").is_none() {
return Err("this system has no apt-get to install a Debian package with".into());
}
if kind == PackageKind::ArchPkg && system_tool("pacman").is_none() {
return Err("this system has no pacman to install an Arch package with".into());
}
install_package_via(&SYSTEM, kind, file, bundle, stop, phase)
}
pub fn install_package_via(
root: &Root<'_>,
kind: PackageKind,
file: &Path,
bundle: Option<&str>,
stop: &Stop,
phase: &mut dyn FnMut(JobPhase),
) -> Result<(), String> {
let f = file.as_os_str();
if let Some(command) = install_command(kind) {
return as_root(root, command, f, stop, phase, JobPhase::Installing);
}
match kind {
PackageKind::Flatpak => install_flatpak_bundle_via(root, file, bundle, false, stop, phase),
other => Err(format!("{} isn't a system package", other.label())),
}
}
pub fn remove_package(app: &App, installed: &Installed, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
let mut remove = |command: &[&str], package: &str| as_root(&SYSTEM, command, OsStr::new(package), stop, phase, JobPhase::Removing);
match installed.source {
InstallSource::Deb if system_tool("apt-get").is_some() => remove(&["apt-get", "-o", APT_WAITS, "remove", "-y"], app.id),
InstallSource::Deb => remove(&["dpkg", "-r"], app.id),
InstallSource::Rpm => {
if system_tool("dnf").is_some() {
remove(&["dnf", "remove", "-y"], app.id)
} else {
remove(&["rpm", "-e"], app.id)
}
}
// The package detected (photocraft, photocraft-bin or photocraft-git).
InstallSource::Pacman => remove(&["pacman", "-R", "--noconfirm"], installed.package.as_deref().unwrap_or(app.id)),
InstallSource::Flatpak => {
let id = app.bundle_id();
let user = process::flatpak_command().args(["uninstall", "--user", "-y", "--noninteractive", &id]).status();
if user.is_ok_and(|s| s.success()) {
return Ok(());
}
if process::in_sandbox() {
let system = process::flatpak_command().args(["uninstall", "--system", "-y", "--noninteractive", &id]).status();
if system.is_ok_and(|s| s.success()) {
return Ok(());
}
return Err(format!("couldn't uninstall {id}"));
}
remove(&["flatpak", "uninstall", "--system", "-y", "--noninteractive"], &id)
}
other => Err(format!("{} isn't a system package", other.label())),
}
}
pub fn install_appimage(
file: &Path,
name: &str,
destination: Option<&Path>,
previous: Option<&Record>,
replace: bool,
) -> Result<(PathBuf, Vec<PathBuf>), String> {
let dir = match destination {
Some(dir) => dir.to_path_buf(),
None => {
let dir = crate::detect::linux::appimage_dir().ok_or("no home folder")?;
std::fs::create_dir_all(&dir).map_err(|e| format!("couldn't create {} ({e})", dir.display()))?;
dir
}
};
let dest = dir.join(name);
let ours = previous.is_some_and(|r| r.kind == PackageKind::AppImage && r.path == dest);
if std::fs::symlink_metadata(&dest).is_ok_and(|m| m.is_dir() || !(replace || ours && m.is_file())) {
return Err(format!("{} already exists and wasn't installed by ArtCraft Launcher", dest.display()));
}
let staging = super::staging_dir(&dir)?;
let partial = staging.path().join(name);
std::fs::copy(file, &partial).map_err(|e| format!("couldn't copy the AppImage ({e})"))?;
// The AppImage is the program the user just installed and must be executable; 0o755 is
// not group/world-writable.
std::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o755)).map_err(|e| e.to_string())?; // NOSONAR
std::fs::rename(&partial, &dest).map_err(|e| format!("couldn't put the AppImage in place ({e})"))?;
let mut removed = Vec::new();
if let Some(old) = previous.filter(|r| r.kind == PackageKind::AppImage && r.path != dest)
&& std::fs::remove_file(&old.path).is_ok()
{
removed.push(old.path.clone());
}
Ok((dest, removed))
}
/// Unpacks a release tarball (`bin/<app>`, `share/…`) into `<data>/apps/<app>`, a folder
/// only the launcher owns, and returns the app's binary. This is the fallback for the
/// Flatpak launcher when the host has no usable Flatpak: the app then runs inside the
/// sandbox instead of on the host. `bin` and `share` are swapped in one at a time, so an
/// update leaves the app's own `home/` beside them untouched.
pub fn install_tree(app: &App, file: &Path, paths: &Paths) -> Result<PathBuf, String> {
let staged = paths.cache.join(format!("{}.new", app.id));
let result = unpack_tree(app, file, &staged, paths);
let _ = std::fs::remove_dir_all(&staged);
result
}
fn unpack_tree(app: &App, file: &Path, staged: &Path, paths: &Paths) -> Result<PathBuf, String> {
let tree = tree::unpack(app, file, staged)?;
// Verify tree root is strictly within staged and is not a symlink (CWE-59).
let rel_tree = tree.strip_prefix(staged).map_err(|_| "staging layout escapes staging".to_string())?;
let mut check_root = staged.to_path_buf();
for part in rel_tree.components() {
check_root.push(part);
let meta = std::fs::symlink_metadata(&check_root).map_err(|e| format!("couldn't check {} ({e})", check_root.display()))?;
if meta.file_type().is_symlink() {
return Err("unpacked tree root is a symlink".into());
}
}
// Validate the staged binary and set its mode *before* either swap: a rejected update
// (e.g. a symlinked app binary) must never disturb the installed tree it replaces.
let staged_bin = tree.join("bin").join(app.id);
make_runnable_within(&tree, &staged_bin)?;
let root = paths.apps().join(app.id);
std::fs::create_dir_all(&root).map_err(|e| format!("couldn't create {} ({e})", root.display()))?;
for dir in ["bin", "share"] {
if tree.join(dir).is_dir() {
swap_into_place(&tree.join(dir), &root.join(dir))?;
}
}
Ok(root.join("bin").join(app.id))
}
/// Makes `bin` executable (0755) without ever following an archive-controlled symlink out of
/// the installation tree.
///
/// `bin` comes from an untrusted tarball. If it, or one of its ancestors inside `root`, is a
/// symlink, a plain `std::fs::set_permissions(bin, …)` would follow the link and change the
/// mode of an existing file *outside* the tree (CWE-59). We reject any symlinked component
/// before changing permissions, so the chmod can only ever reach a regular file that lives at
/// the verified path inside the launcher's own apps folder.
fn make_runnable_within(root: &Path, bin: &Path) -> Result<(), String> {
let root_meta = std::fs::symlink_metadata(root).map_err(|e| format!("couldn't check {} ({e})", root.display()))?;
if root_meta.file_type().is_symlink() {
return Err("the install tree root is a symlink".into());
}
// No component between `root` and `bin` may be a symlink.
let rel = bin.strip_prefix(root).map_err(|_| "the app binary isn't inside the install tree".to_string())?;
let mut here = root.to_path_buf();
for part in rel.components() {
here.push(part);
let meta = std::fs::symlink_metadata(&here).map_err(|e| format!("couldn't check {} ({e})", here.display()))?;
if meta.file_type().is_symlink() {
return Err("the download's app binary escapes the install tree through a symlink".into());
}
}
// The final component is a verified non-symlink regular file inside the launcher's own
// apps folder, so changing its mode cannot reach anything outside the tree.
if !here.metadata().map_err(|e| e.to_string())?.file_type().is_file() {
return Err("the download's app binary isn't a regular file".into());
}
// Verified non-symlink regular file inside the launcher's apps folder; the app binary must
// be executable and 0o755 is not group/world-writable.
std::fs::set_permissions(bin, std::fs::Permissions::from_mode(0o755)).map_err(|e| format!("couldn't make {} runnable ({e})", bin.display())) // NOSONAR
}
/// Removes a tree the launcher unpacked in [`install_tree`], keeping the app's `home/`: the
/// settings and projects in it are the user's, not the installer's. Only the two folders the
/// launcher put there are removed, and only inside the launcher's own apps folder.
pub fn remove_tree(app: &App, paths: &Paths) -> Result<(), String> {
let root = paths.apps().join(app.id);
let mut removed = false;
for dir in ["bin", "share"] {
let path = root.join(dir);
if path.starts_with(paths.apps()) && path.is_dir() {
std::fs::remove_dir_all(&path).map_err(|e| format!("couldn't remove {} ({e})", path.display()))?;
removed = true;
}
}
if removed { Ok(()) } else { Err(format!("{} isn't installed in this folder", app.name)) }
}
/// Where menu entries and icons go: `$XDG_DATA_HOME`, else `~/.local/share`. Inside the
/// launcher's own Flatpak that variable points into the sandbox, so the host's is used.
pub fn host_data_dir() -> Option<PathBuf> {
crate::folders::host_data_local_dir()
}
/// The app's 128 px icon, as bundled in `assets/app-icons`.
fn icon_png(app: &App) -> Option<&'static [u8]> {
app.icon_png
}
fn desktop_path(data: &Path, app: &App) -> PathBuf {
data.join("applications").join(format!("{}.desktop", app.bundle_id()))
}
fn icon_path(data: &Path, app: &App) -> PathBuf {
data.join("icons/hicolor/128x128/apps").join(format!("{}.png", app.bundle_id()))
}
/// Adds `appimage` to the desktop's menu. Best effort: a failure here never fails the
/// install. Returns the files written, for the record.
pub fn integrate(app: &App, appimage: &Path, replaced: &[PathBuf], previous: Option<&Record>) -> Vec<PathBuf> {
match host_data_dir() {
Some(data) => integrate_into(&data, app, appimage, replaced, previous),
None => Vec::new(),
}
}
/// [`integrate`] into the data folder `data`. Only writes over files the launcher wrote
/// before: its own marked menu entry, an entry pointing at a copy this install replaced, and
/// an icon listed in `previous`.
pub(crate) fn integrate_into(data: &Path, app: &App, appimage: &Path, replaced: &[PathBuf], previous: Option<&Record>) -> Vec<PathBuf> {
let ours_before = |p: &Path| previous.is_some_and(|r| r.files.iter().any(|f| f == p));
let mut written = Vec::new();
let icon = icon_path(data, app);
let has_icon = match icon_png(app) {
Some(png) if !icon.exists() || ours_before(&icon) => match crate::paths::write_atomic(&icon, png) {
Ok(()) => {
written.push(icon.clone());
true
}
Err(e) => {
log::warn!("{}: couldn't install the menu icon: {e}", app.name);
false
}
},
// Someone else's icon under the app's id (the app's own AppImage puts one there).
_ => icon.is_file(),
};
let Some(text) = crate::desktop_entry::render(app, appimage, has_icon) else {
log::warn!("{}: {} can't be written into a menu entry; skipping it", app.name, appimage.display());
return written;
};
let desktop = desktop_path(data, app);
let replace = match std::fs::read_to_string(&desktop) {
Ok(old) => {
crate::desktop_entry::is_ours(&old)
|| crate::desktop_entry::exec_program(&old)
.is_some_and(|p| Path::new(&p) == appimage || replaced.iter().any(|r| r == Path::new(&p)))
}
Err(_) => !desktop.exists(),
};
if !replace {
log::info!("{}: leaving the existing menu entry {} alone", app.name, desktop.display());
return written;
}
match crate::paths::write_atomic(&desktop, text.as_bytes()) {
Ok(()) => written.push(desktop),
Err(e) => log::warn!("{}: couldn't add the menu entry: {e}", app.name),
}
written
}
/// Takes away what [`integrate`] added for the removed AppImage `appimage`.
pub fn unintegrate(app: &App, appimage: &Path, record: Option<&Record>) {
if let Some(data) = host_data_dir() {
unintegrate_from(&data, app, appimage, record);
}
}
/// [`unintegrate`] from the data folder `data`: the menu entry when it runs the removed copy,
/// or when the launcher wrote it and what it runs is gone; the icon only when the
/// launcher's record lists it.
pub(crate) fn unintegrate_from(data: &Path, app: &App, appimage: &Path, record: Option<&Record>) {
let desktop = desktop_path(data, app);
if let Ok(text) = std::fs::read_to_string(&desktop) {
let program = crate::desktop_entry::exec_program(&text).map(PathBuf::from);
let runs_removed = program.as_deref() == Some(appimage);
let ours_and_dangling = crate::desktop_entry::is_ours(&text) && program.is_none_or(|p| !p.exists());
if runs_removed || ours_and_dangling {
let _ = std::fs::remove_file(&desktop);
}
}
let icon = icon_path(data, app);
if record.is_some_and(|r| r.files.contains(&icon)) {
let _ = std::fs::remove_file(&icon);
}
}
}
#[cfg(target_os = "macos")]
pub mod macos {
use std::time::Duration;
use crate::process::HelperFailure;
use crate::protocol::CANCELLED;
use crate::queue::Queue;
use crate::version::Version;
use super::{
App, InstallSource, Installed, JobPhase, Path, PathBuf, Paths, Release, Stop, cancelled, not_running, process, swap_into_place, turn,
};
/// `/Applications` when we can write there, else `~/Applications`.
fn applications_dir(existing: Option<&Installed>, destination: Option<&Path>) -> Result<PathBuf, String> {
if let Some(parent) =
existing.filter(|i| i.source == InstallSource::MacApp).and_then(|i| Path::new(&i.location).parent().map(Path::to_path_buf))
{
return Ok(parent);
}
if let Some(dir) = destination {
return Ok(dir.to_path_buf());
}
let system = PathBuf::from("/Applications");
let probe = system.join(".artcraft-launcher-write-test");
if std::fs::write(&probe, b"").is_ok() {
let _ = std::fs::remove_file(&probe);
return Ok(system);
}
let user = crate::folders::home_dir().ok_or("no home folder")?.join("Applications");
std::fs::create_dir_all(&user).map_err(|e| format!("couldn't create {} ({e})", user.display()))?;
Ok(user)
}
/// The Apple Developer team that signs every Craft App and ArtCraft Launcher (Learning
/// Machines LLC): the macOS side of the Windows publisher check.
pub const TEAM_ID: &str = "DJ6XS33FX8";
/// The start of [`install_dmg`]'s error when hdiutil can't attach the disk image.
pub(crate) const IMAGE_NOT_OPENED: &str = "couldn't open the disk image";
/// What a new copy must prove before it replaces anything. The checksum shows the download is
/// the file the release lists; these show the app inside is the one the release promises.
pub struct Expect<'a> {
/// The team whose Developer ID signature the bundle must carry. `None` only in tests,
/// which build unsigned bundles.
pub team: Option<&'a str>,
/// The release's version, which the bundle must report. `None` when the tag isn't one.
pub version: Option<&'a Version>,
/// Refuse the swap when the app it replaces is running. `false` for an app replacing
/// itself, which is running on purpose.
pub quit_first: bool,
}
impl<'a> Expect<'a> {
pub fn release(release: &'a Release) -> Self {
Self { team: Some(TEAM_ID), version: release.version.as_ref(), quit_first: true }
}
/// [`Self::release`] for an app replacing itself.
pub fn self_update(release: &'a Release) -> Self {
Self { quit_first: false, ..Self::release(release) }
}
}
/// Held while this process asks diskimages-helper for something (an attach here; the tests'
/// `hdiutil create` too), since it turns concurrent requests away.
pub(crate) static DISK_IMAGES: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Deep signature checks wait their turn here. `codesign --deep` reads every file of the
/// bundle, so checks that overlap compete for the disk and all slow down. The time limit
/// starts when a check's turn comes.
static SIGNATURE_CHECKS: Queue = Queue::new();
/// How a job waits for its turn at the signature check: `phase` hears of the wait, and `stop`
/// can cancel it.
pub struct CheckTurn<'a> {
pub stop: &'a Stop,
pub phase: &'a mut dyn FnMut(JobPhase),
}
/// Apple's tools, by full path: a launcher started from Finder or at login gets a minimal
/// `PATH`, and nothing earlier on it should stand in for these.
const HDIUTIL: &str = "/usr/bin/hdiutil";
const DITTO: &str = "/usr/bin/ditto";
const CODESIGN: &str = "/usr/bin/codesign";
/// Stops a `codesign` that hangs.
const CODESIGN_TIMEOUT: Duration = Duration::from_secs(600);
#[allow(clippy::too_many_arguments)]
pub fn install_dmg(
app: &App,
dmg: &Path,
existing: Option<&Installed>,
destination: Option<&Path>,
replace: bool,
paths: &Paths,
expect: &Expect<'_>,
wait: CheckTurn<'_>,
) -> Result<PathBuf, String> {
// A new, empty mount point for every install. A fixed one can still hold an image from an
// attach that was interrupted or timed out, and hdiutil won't mount another over it.
let attempt = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
let mount = paths.cache.join(format!("mount-{}-{}-{attempt}", app.id, std::process::id()));
let _in_use = InUse::new(&mount);
std::fs::create_dir_all(&mount).map_err(|e| format!("couldn't create a folder to open the disk image in ({e})"))?;
let (Some(dmg_s), Some(mount_s)) = (dmg.to_str(), mount.to_str()) else {
let _ = std::fs::remove_dir(&mount);
return Err("unusual characters in the cache folder's path".into());
};
// diskimages-helper refuses an attach while it is busy with another image ("Resource
// temporarily unavailable"): attach one image at a time (several apps may be updating at
// once), and wait a little and try again when something outside the launcher holds it.
// The download's SHA-256 is already checked, so hdiutil's own checksum pass (-noverify)
// would only add minutes on a large image.
let attached = {
let _one_at_a_time = DISK_IMAGES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
// An image an earlier install left attached makes hdiutil refuse the same file.
clear_mounts(app, &paths.cache, process_ended(), detach);
let attach = || {
process::run(
HDIUTIL,
&["attach", "-nobrowse", "-readonly", "-noautoopen", "-noverify", "-mountpoint", mount_s, dmg_s],
Duration::from_secs(300),
)
};
// diskimages-helper can stay busy for a while on a shared runner; a minute of retries is still quick.
let mut attached = attach();
for wait in [1, 2, 4, 8, 16, 32] {
if attached.is_ok() {
break;
}
let _ = detach(&mount);
let _ = detach_unmounted(dmg);
std::thread::sleep(Duration::from_secs(wait));
attached = attach();
}
attached
};
if let Err(reason) = attached {
let mut reason = format!("{IMAGE_NOT_OPENED}: {reason}");
if let Err(why) = detach_unmounted(dmg) {
log::warn!("couldn't detach the disk image {} ({why})", dmg.display());
reason = format!("{reason}; the disk image may still be attached ({why})");
}
// An attach that timed out can still finish mounting the image.
return close_image(Err(reason), &mount, detach);
}
let result = copy_app(app, &mount, existing, destination, replace, expect, wait);
close_image(result, &mount, detach)
}
/// Detaches the image at `mount` through `detach` and removes the mount point. An image that
/// stays attached is logged, and the error of a failed install says so after its reason. A
/// later install detaches it.
pub(crate) fn close_image(
result: Result<PathBuf, String>,
mount: &Path,
detach: impl Fn(&Path) -> Result<(), String>,
) -> Result<PathBuf, String> {
if let Err(why) = detach(mount) {
log::warn!("couldn't detach the disk image at {} ({why})", mount.display());
return result.map_err(|reason| format!("{reason}; the disk image is still attached at {} ({why})", mount.display()));
}
if let Err(e) = std::fs::remove_dir(mount) {
log::warn!("couldn't remove the empty folder {} ({e})", mount.display());
}
result
}
fn copy_app(
app: &App,
mount: &Path,
existing: Option<&Installed>,
destination: Option<&Path>,
replace: bool,
expect: &Expect<'_>,
wait: CheckTurn<'_>,
) -> Result<PathBuf, String> {
let bundle = format!("{}.app", app.name);
let src = mount.join(&bundle);
crate::detect::macos::read_bundle(app, &src).ok_or("the disk image doesn't contain the app")?;
let dir = applications_dir(existing, destination)?;
let dest = existing.filter(|i| i.source == InstallSource::MacApp).map_or_else(|| dir.join(&bundle), |i| PathBuf::from(&i.location));
if !replace
&& std::fs::symlink_metadata(&dest).is_ok()
&& (std::fs::symlink_metadata(&dest).is_ok_and(|m| m.file_type().is_symlink()) || crate::detect::macos::read_bundle(app, &dest).is_none())
{
return Err(format!("{} already exists and wasn't installed by ArtCraft Launcher", dest.display()));
}
clear_staging(app, &dir, process_ended(), std::time::SystemTime::now());
let staging = staging_dir(&dir)?;
let result = place(app, &src, staging.path(), &dest, expect, wait);
remove_staging(result, staging)
}
/// Copies the app at `src` into `staging`, checks the copy and replaces `dest` with it.
fn place(app: &App, src: &Path, staging: &Path, dest: &Path, expect: &Expect<'_>, wait: CheckTurn<'_>) -> Result<PathBuf, String> {
let staged = staging.join(format!("{}.app", app.name));
let (Some(src_s), Some(staged_s)) = (src.to_str(), staged.to_str()) else { return Err("unusual characters in a path".into()) };
// ditto keeps the code signature and symlinks intact. The disk image's volume adds Finder
// metadata (com.apple.FinderInfo) to the files; leave it and the extended attributes
// behind, or `codesign --verify --strict` rejects the bundle as carrying "detritus". A
// bundle's signature lives in its files, not in extended attributes.
process::run(DITTO, &["--norsrc", "--noextattr", "--noacl", src_s, staged_s], Duration::from_secs(600))
.map_err(|reason| format!("couldn't copy the app: {reason}"))?;
place_checked(app, &staged, dest, expect, process::run_detailed, process::running, wait)
}
/// Checks the copy at `staged` and replaces `dest` with it. `codesign` runs through `run`, and
/// `running` says whether `dest` is running. Tests pass stand-ins.
pub(crate) fn place_checked(
app: &App,
staged: &Path,
dest: &Path,
expect: &Expect<'_>,
run: impl FnOnce(&str, &[&str], Duration) -> Result<String, HelperFailure>,
running: impl FnOnce(&Path) -> Result<bool, String>,
wait: CheckTurn<'_>,
) -> Result<PathBuf, String> {
let found = crate::detect::macos::read_bundle(app, staged).ok_or("the copied app is incomplete")?;
check(staged, &found, expect, run, wait)?;
// The check can wait behind other apps' checks, and the user may have opened the app.
if expect.quit_first {
not_running(app, running(dest))?;
}
swap_into_place(staged, dest)?;
Ok(dest.to_path_buf())
}
/// Removes `staging` with whatever is still in it: the new copy when the install failed. A
/// folder that can't be removed is logged, and the error of a failed install says so after
/// its reason.
pub(crate) fn remove_staging(result: Result<PathBuf, String>, staging: tempfile::TempDir) -> Result<PathBuf, String> {
let path = staging.path().to_path_buf();
let Err(e) = staging.close() else { return result };
log::warn!("couldn't remove the staging folder {} ({e})", path.display());
result.map_err(|reason| format!("{reason}; couldn't remove the partial copy at {} ({e})", path.display()))
}
/// Checks a copied bundle against what its release promises, before it replaces anything.
/// `codesign` runs through `run`.
pub(crate) fn check(
bundle: &Path,
found: &Installed,
expect: &Expect<'_>,
run: impl FnOnce(&str, &[&str], Duration) -> Result<String, HelperFailure>,
wait: CheckTurn<'_>,
) -> Result<(), String> {
if let Some(team) = expect.team {
match signature(bundle, team, run, wait) {
Signature::Valid => {}
Signature::Canceled => return Err(CANCELLED.into()),
Signature::Wrong => return Err("the app isn't signed by its developer, so it wasn't installed".into()),
Signature::Failed(reason) => return Err(format!("the app failed its signature check ({reason}), so it wasn't installed")),
Signature::Unchecked(reason) => return Err(format!("the app's signature couldn't be checked ({reason}), so it wasn't installed")),
}
}
if let Some(want) = expect.version
&& !found.version.as_ref().is_some_and(|v| same_release(v, want))
{
return Err(format!("the app inside isn't version {want}, so it wasn't installed"));
}
Ok(())
}
/// Whether `bundle` has a valid Developer ID signature from `team`, nested code included. The
/// requirement pins the signer, so a valid ad-hoc or other developer's signature fails it.
/// Unlike a Gatekeeper assessment it needs no network. `false` also means the check couldn't
/// finish, so an app that couldn't be checked never passes.
pub fn signed_by(bundle: &Path, team: &str) -> bool {
let stop = Stop::default();
matches!(signature(bundle, team, process::run_detailed, CheckTurn { stop: &stop, phase: &mut |_| {} }), Signature::Valid)
}
/// What `codesign` found out about a bundle.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum Signature {
Valid,
/// The signature isn't the developer's.
Wrong,
/// `codesign` failed the verification, with its reason: no signature at all, a damaged
/// signature, or a bundle it couldn't read.
Failed(String),
/// `codesign` gave no answer, with the reason why.
Unchecked(String),
/// The user canceled the job while it waited for its turn. `codesign` did not run.
Canceled,
}
/// Runs `codesign` on `bundle` through `run`, one bundle at a time.
pub(crate) fn signature(
bundle: &Path,
team: &str,
run: impl FnOnce(&str, &[&str], Duration) -> Result<String, HelperFailure>,
wait: CheckTurn<'_>,
) -> Signature {
let Some(path) = bundle.to_str() else { return Signature::Unchecked("unusual characters in the app's path".into()) };
if team.is_empty() || !team.bytes().all(|b| b.is_ascii_alphanumeric()) {
return Signature::Wrong;
}
let requirement = format!("=anchor apple generic and certificate leaf[subject.OU] = \"{team}\"");
let verify = || match run(CODESIGN, &["--verify", "--deep", "-R", &requirement, path], CODESIGN_TIMEOUT) {
Ok(_) => Signature::Valid,
// codesign(1): 3 is a signature that fails the requirement, and 1 any other failed
// verification.
Err(HelperFailure::Exited { code: 3, .. }) => Signature::Wrong,
Err(HelperFailure::Exited { code: 1, reason }) => Signature::Failed(reason),
Err(failure) => Signature::Unchecked(failure.into_reason()),
};
SIGNATURE_CHECKS.alone(&cancelled(wait.stop), &mut turn(wait.phase, JobPhase::Installing), verify).unwrap_or(Signature::Canceled)
}
/// Whether a bundle's version is the release's. A bundle that reports only the numbers
/// (`CFBundleShortVersionString`) matches a pre-release with the same numbers.
fn same_release(found: &Version, release: &Version) -> bool {
(found.major, found.minor, found.patch) == (release.major, release.minor, release.patch) && (found.pre.is_empty() || found.pre == release.pre)
}
/// Detaches the image mounted at `mount`, if one is. `Ok` when none is mounted there.
fn detach(mount: &Path) -> Result<(), String> {
if !is_mounted(mount) {
return Ok(());
}
let m = mount.to_str().ok_or("unusual characters in the mount point's path")?;
process::run(HDIUTIL, &["detach", m, "-quiet"], Duration::from_secs(60))
.or_else(|_| process::run(HDIUTIL, &["detach", m, "-force", "-quiet"], Duration::from_secs(60)))
.map(drop)
}
/// Detaches the image file `dmg` where it is attached with no volume mounted, which a failed
/// attach can leave. An attachment with a mounted volume is left alone: another install may
/// be using it. So is every attachment while a program started with `dmg` in its arguments
/// (another process's `hdiutil attach`) is running, or while the check for one gets no answer;
/// in that case the error gives the check's reason.
fn detach_unmounted(dmg: &Path) -> Result<(), String> {
detach_unmounted_with(dmg, process::running, process::run)
}
/// [`detach_unmounted`], with `running` checking for a program started with `dmg` in its
/// arguments and `run` running `hdiutil`.
pub(crate) fn detach_unmounted_with(
dmg: &Path,
running: impl FnOnce(&Path) -> Result<bool, String>,
run: impl Fn(&str, &[&str], Duration) -> Result<String, String>,
) -> Result<(), String> {
match running(dmg) {
Ok(false) => {}
Ok(true) => return Ok(()),
Err(why) => return Err(format!("couldn't check for a running attach of the image ({why})")),
}
let info = run(HDIUTIL, &["info", "-plist"], Duration::from_secs(30))?;
let image = [Some(dmg.to_path_buf()), std::fs::canonicalize(dmg).ok()];
for device in unmounted_attachments(&info, &image.into_iter().flatten().collect::<Vec<_>>()) {
run(HDIUTIL, &["detach", &device, "-force", "-quiet"], Duration::from_secs(60))?;
}
Ok(())
}
/// The devices of the attachments, in `hdiutil info -plist`'s output `info`, of an image file
/// at one of the paths in `image` that have no volume mounted.
pub(crate) fn unmounted_attachments(info: &str, image: &[PathBuf]) -> Vec<String> {
let Ok(info) = plist::Value::from_reader_xml(info.as_bytes()) else { return Vec::new() };
let images = info.as_dictionary().and_then(|d| d.get("images")).and_then(plist::Value::as_array);
let mut devices = Vec::new();
for attached in images.into_iter().flatten().filter_map(plist::Value::as_dictionary) {
let path = attached.get("image-path").and_then(plist::Value::as_string);
if !path.is_some_and(|p| image.iter().any(|i| i.as_path() == Path::new(p))) {
continue;
}
let entities: Vec<_> = attached
.get("system-entities")
.and_then(plist::Value::as_array)
.into_iter()
.flatten()
.filter_map(plist::Value::as_dictionary)
.collect();
if entities.iter().any(|e| e.contains_key("mount-point")) {
continue;
}
// The first entity is the whole disk; detaching it detaches the rest.
if let Some(device) = entities.first().and_then(|e| e.get("dev-entry")).and_then(plist::Value::as_string) {
devices.push(device.to_string());
}
}
devices
}
/// Whether a volume is mounted at `dir`: it is on a different device from its parent folder.
fn is_mounted(dir: &Path) -> bool {
use std::os::unix::fs::MetadataExt;
let (Ok(here), Some(Ok(above))) = (std::fs::symlink_metadata(dir), dir.parent().map(std::fs::metadata)) else { return false };
here.is_dir() && here.dev() != above.dev()
}
/// The start of the name of the folder an install copies the new app into, beside the
/// installed app. The installing process's id, `-` and six random letters and digits follow it.
const STAGING: &str = ".artcraft-launcher-staging-";
/// The empty file an install writes in its staging folder before copying anything into it.
const STAGING_MARKER: &str = ".artcraft-launcher-staging";
/// The mount points of this process's installs in progress.
static MOUNTS_IN_USE: std::sync::Mutex<Vec<PathBuf>> = std::sync::Mutex::new(Vec::new());
/// Lists a mount point in [`MOUNTS_IN_USE`] until dropped.
pub(crate) struct InUse(PathBuf);
impl InUse {
pub(crate) fn new(mount: &Path) -> Self {
MOUNTS_IN_USE.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(mount.to_path_buf());
Self(mount.to_path_buf())
}
}
impl Drop for InUse {
fn drop(&mut self) {
let mut in_use = MOUNTS_IN_USE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(at) = in_use.iter().position(|m| *m == self.0) {
in_use.swap_remove(at);
}
}
}
/// The process id in `name` when `name` is `prefix`, then digits, then `-` and a rest that
/// `rest` accepts.
fn owner(name: &str, prefix: &str, rest: impl Fn(&str) -> bool) -> Option<u32> {
let (pid, after) = name.strip_prefix(prefix)?.split_once('-')?;
if pid.is_empty() || !pid.bytes().all(|b| b.is_ascii_digit()) || after.is_empty() || !rest(after) {
return None;
}
pid.parse().ok()
}
/// The ids of the running processes, from one `ps` run. `None` when `ps` fails.
fn running_processes() -> Option<std::collections::HashSet<u32>> {
let out = process::run("/bin/ps", &["-A", "-o", "pid="], Duration::from_secs(10)).ok()?;
out.lines().map(str::trim).filter(|l| !l.is_empty()).map(|l| l.parse().ok()).collect()
}
/// Says whether the process with a given id has ended, from one `ps` run made at the first
/// call. No process counts as ended when `ps` fails.
fn process_ended() -> impl Fn(u32) -> bool {
let running = std::cell::OnceCell::new();
move |pid| running.get_or_init(running_processes).as_ref().is_some_and(|r| !r.contains(&pid))
}
/// Detaches the images that installs of `app` left attached in `cache` when they ended
/// partway, and removes their mount points. A mount point belongs to an install that ended
/// when `ended` says its process ended, or when it is this process's and no install here uses
/// it. `detach` detaches an image. Failures are logged and the install goes on.
pub(crate) fn clear_mounts(app: &App, cache: &Path, ended: impl Fn(u32) -> bool, detach: impl Fn(&Path) -> Result<(), String>) {
let Ok(entries) = std::fs::read_dir(cache) else { return };
let prefix = format!("mount-{}-", app.id);
// Read before `ended` runs `ps`, which then lists every process that made one of them.
let names: Vec<_> = entries.flatten().map(|e| e.file_name()).collect();
// Listed after the folder is read: an install registers its mount point before creating it.
let in_use = MOUNTS_IN_USE.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone();
for name in names {
let Some(pid) = name.to_str().and_then(|n| owner(n, &prefix, |rest| rest.bytes().all(|b| b.is_ascii_digit()))) else { continue };
let mount = cache.join(&name);
let gone = if pid == std::process::id() { !in_use.contains(&mount) } else { ended(pid) };
if !gone {
continue;
}
match detach(&mount).and_then(|()| std::fs::remove_dir(&mount).map_err(|e| e.to_string())) {
Ok(()) => log::info!("removed {}, left by an install that ended partway", mount.display()),
Err(e) => log::warn!("couldn't clear {}, left by an install that ended partway ({e})", mount.display()),
}
}
}
/// The start of the name of the staging folders of launcher versions that didn't record the
/// installing process. Six random letters and digits follow it.
const OLD_STAGING: &str = ".artcraft-launcher-";
/// How long a staging folder of [`OLD_STAGING`]'s kind stays unchanged before it counts as
/// left by an install that ended. An install's copy times out after 10 minutes.
const OLD_STAGING_AGE: Duration = Duration::from_secs(24 * 60 * 60);
/// Removes the staging folders in `dir` of installs that ended partway, with the partial copies
/// in them:
///
/// - a folder named as [`STAGING`] describes, holding [`STAGING_MARKER`], whose process
/// `ended` says has ended, whichever app that install was for. Without the marker, the
/// folder is removed only when it is empty.
/// - a folder named as [`OLD_STAGING`] describes that holds only `<app>.app` and has not
/// changed for [`OLD_STAGING_AGE`] before `now`.
///
/// Folders of this process's installs and of installs still running, [`OLD_STAGING`] folders
/// of other apps and backup folders (one may hold the only copy of an app) are left alone.
/// Failures are logged and the install goes on.
pub(crate) fn clear_staging(app: &App, dir: &Path, ended: impl Fn(u32) -> bool, now: std::time::SystemTime) {
let Ok(entries) = std::fs::read_dir(dir) else { return };
// Read before `ended` runs `ps`, which then lists every process that made one of them.
let folders: Vec<_> = entries.flatten().filter(|e| e.file_type().is_ok_and(|t| t.is_dir())).collect();
let random = |rest: &str| rest.len() == 6 && rest.bytes().all(|b| b.is_ascii_alphanumeric());
for folder in folders {
let (name, path) = (folder.file_name(), folder.path());
let Some(name) = name.to_str() else { continue };
let removed = match owner(name, STAGING, random) {
Some(pid) if pid == std::process::id() || !ended(pid) => continue,
Some(_) if std::fs::symlink_metadata(path.join(STAGING_MARKER)).is_ok_and(|m| m.is_file()) => std::fs::remove_dir_all(&path),
// Stopped before it wrote the marker. A folder with something in it may be the user's.
Some(_) => match std::fs::remove_dir(&path) {
Ok(()) => Ok(()),
Err(_) => continue,
},
None if name.strip_prefix(OLD_STAGING).is_some_and(random) && left_by_old_install(app, &path, now) => std::fs::remove_dir_all(&path),
None => continue,
};
match removed {
Ok(()) => log::info!("removed {}, left by an install that ended partway", path.display()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => log::warn!("couldn't remove {}, left by an install that ended partway ({e})", path.display()),
}
}
}
/// Whether `folder` holds only `<app>.app`, a folder, and has not changed for
/// [`OLD_STAGING_AGE`] before `now`.
fn left_by_old_install(app: &App, folder: &Path, now: std::time::SystemTime) -> bool {
let Ok(entries) = std::fs::read_dir(folder) else { return false };
let bundle = format!("{}.app", app.name);
let names: Vec<_> = entries.take(2).map(|e| e.map(|e| e.file_name())).collect();
let [Ok(only)] = names.as_slice() else { return false };
only.to_str() == Some(bundle.as_str())
&& std::fs::symlink_metadata(folder.join(&bundle)).is_ok_and(|m| m.is_dir())
&& std::fs::symlink_metadata(folder)
.and_then(|m| m.modified())
.is_ok_and(|at| now.duration_since(at).is_ok_and(|age| age >= OLD_STAGING_AGE))
}
/// A new staging folder for this process in `dir`, holding [`STAGING_MARKER`]. See [`STAGING`].
fn staging_dir(dir: &Path) -> Result<tempfile::TempDir, String> {
let failed = |e: std::io::Error| format!("couldn't create a staging folder in {} ({e})", dir.display());
let staging = tempfile::Builder::new().prefix(&format!("{STAGING}{}-", std::process::id())).rand_bytes(6).tempdir_in(dir).map_err(failed)?;
std::fs::write(staging.path().join(STAGING_MARKER), b"").map_err(failed)?;
Ok(staging)
}
/// Moves an app to the user's Trash (so it can be put back).
pub fn move_to_trash(path: &Path) -> Result<(), String> {
let trash = crate::folders::home_dir().ok_or("no home folder")?.join(".Trash");
let name = path.file_name().ok_or("not an app")?;
let mut dest = trash.join(name);
if dest.exists() {
dest = trash.join(format!("{} {}", name.to_string_lossy(), crate::now()));
}
std::fs::rename(path, &dest).map_err(|e| format!("couldn't move {} to Trash ({e}). Remove it in Finder instead.", path.display()))
}
}
/// FreeBSD: the release tarball holds a `/usr/local`-style tree (`bin/<app>`,
/// `share/applications`, icons). Older releases had the files flat in one folder; those are laid
/// out the same way first.
#[cfg(unix)]
#[allow(dead_code)]
pub(crate) mod tree {
use std::io::Read;
use super::{App, Path, PathBuf, single_top_folder};
// Bound entry count and cumulative unpacked bytes to prevent decompression bombs (CWE-409).
const MAX_ENTRIES: usize = 50_000;
const MAX_UNPACKED_BYTES: u64 = 2 * 1024 * 1024 * 1024;
/// The most decompressed tar stream either pass reads: the unpacked bytes plus room for
/// headers, padding and metadata records.
const MAX_TAR_BYTES: u64 = MAX_UNPACKED_BYTES + (1 << 30);
/// The most a PAX or GNU long-name record may hold. The tar crate buffers such a record
/// whole while the iterator advances, before the loop below sees any entry size.
const MAX_METADATA_BYTES: u64 = 1 << 20;
/// Walks the headers of the tarball `file` without unpacking anything, and refuses a
/// metadata record (PAX `x`/`g`, GNU `L`/`K`) past [`MAX_METADATA_BYTES`]. A damaged
/// archive passes: unpacking reports it.
fn check_metadata(file: &Path) -> Result<(), String> {
let gz = std::fs::File::open(file).map_err(|e| e.to_string())?;
let mut tar = flate2::read::GzDecoder::new(gz).take(MAX_TAR_BYTES);
let mut header = [0u8; 512];
while tar.read_exact(&mut header).is_ok() && header.iter().any(|b| *b != 0) {
let size = header_size(&header[124..136]).ok_or("invalid tar header")?;
if matches!(header[156], b'x' | b'g' | b'L' | b'K') && size > MAX_METADATA_BYTES {
return Err("archive has an oversized metadata record".into());
}
let padded = size.div_ceil(512).saturating_mul(512);
if std::io::copy(&mut (&mut tar).take(padded), &mut std::io::sink()).map_err(|e| e.to_string())? < padded {
break;
}
}
Ok(())
}
/// A tar header's size field: octal digits, or big-endian base-256 after a set top bit.
fn header_size(field: &[u8]) -> Option<u64> {
if field.first()? & 0x80 != 0 {
return field
.iter()
.enumerate()
.try_fold(0u64, |n, (i, b)| n.checked_mul(256)?.checked_add(u64::from(if i == 0 { b & 0x7f } else { *b })));
}
let digits = std::str::from_utf8(field).ok()?.trim_matches(|c: char| c == '\0' || c == ' ');
if digits.is_empty() { Some(0) } else { u64::from_str_radix(digits, 8).ok() }
}
/// Unpacks `file` and returns a staging folder laid out like `/usr/local`.
pub fn unpack(app: &App, file: &Path, staging: &Path) -> Result<PathBuf, String> {
check_metadata(file)?;
let _ = std::fs::remove_dir_all(staging);
std::fs::create_dir_all(staging).map_err(|e| format!("couldn't create {} ({e})", staging.display()))?;
let gz = std::fs::File::open(file).map_err(|e| e.to_string())?;
// Never past what `check_metadata` read.
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(gz).take(MAX_TAR_BYTES));
archive.set_preserve_permissions(true);
let mut entry_count = 0;
let mut total_unpacked = 0u64;
let mut directories = Vec::new();
let entries = archive.entries().map_err(|e| format!("couldn't read archive entries ({e})"))?;
for entry in entries {
let mut entry = entry.map_err(|e| format!("invalid tar entry ({e})"))?;
entry_count += 1;
if entry_count > MAX_ENTRIES {
return Err("archive has too many entries".into());
}
let size = entry.size();
total_unpacked = total_unpacked.saturating_add(size);
if total_unpacked > MAX_UNPACKED_BYTES {
return Err("archive exceeds maximum unpacked size".into());
}
if entry.header().entry_type().is_dir() {
directories.push(entry);
} else {
entry.unpack_in(staging).map_err(|e| format!("couldn't unpack archive entry ({e})"))?;
}
}
// Delay directory entries until child entries are written so restrictive modes do not break child extraction.
directories.sort_by(|a, b| b.path_bytes().cmp(&a.path_bytes()));
for mut dir in directories {
dir.unpack_in(staging).map_err(|e| format!("couldn't unpack archive directory ({e})"))?;
}
// tar reads a limit-truncated stream as a clean end of archive, so an exhausted limit is refused here.
if archive.into_inner().limit() == 0 {
return Err("archive exceeds maximum size".into());
}
let top = single_top_folder(staging)?;
if top.join("bin").join(app.id).is_file() {
return Ok(top);
}
// Flat layout: put each file where /usr/local keeps it.
// Ensure tree is a fresh directory and not an archive-created symlink or directory (CWE-59).
let tree = staging.join("tree");
if let Ok(m) = std::fs::symlink_metadata(&tree) {
if m.file_type().is_symlink() || !m.is_dir() {
let _ = std::fs::remove_file(&tree);
} else {
let _ = std::fs::remove_dir_all(&tree);
}
}
std::fs::create_dir_all(&tree).map_err(|e| e.to_string())?;
let id = app.bundle_id();
for (from, to) in [
(top.join(app.id), tree.join("bin").join(app.id)),
(top.join(format!("{}-cli", app.id)), tree.join("bin").join(format!("{}-cli", app.id))),
(top.join(format!("{id}.desktop")), tree.join("share/applications").join(format!("{id}.desktop"))),
(top.join(format!("{id}.png")), tree.join("share/pixmaps").join(format!("{id}.png"))),
] {
if std::fs::symlink_metadata(&from).is_ok_and(|m| m.file_type().is_file()) {
if let Some(dir) = to.parent() {
let rel = dir.strip_prefix(&tree).map_err(|_| "destination escapes staging tree".to_string())?;
let mut check = tree.clone();
for part in rel.components() {
check.push(part);
if let Ok(m) = std::fs::symlink_metadata(&check)
&& m.file_type().is_symlink()
{
return Err("destination parent directory is a symlink".into());
}
}
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
std::fs::rename(&from, &to).map_err(|e| e.to_string())?;
}
}
if !tree.join("bin").join(app.id).is_file() {
return Err("the download doesn't contain the app".into());
}
Ok(tree)
}
/// Every file under `root`, relative to it. Does not descend through symlinks (CWE-59, CWE-400).
pub fn files(root: &Path) -> Result<Vec<PathBuf>, String> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
let mut visited = 0;
const MAX_FILES: usize = 50_000;
while let Some(dir) = stack.pop() {
let entries = std::fs::read_dir(&dir).map_err(|e| format!("couldn't read directory {}: {e}", dir.display()))?;
for entry in entries {
let entry = entry.map_err(|e| format!("couldn't read directory entry: {e}"))?;
visited += 1;
if visited > MAX_FILES {
return Err("directory tree exceeds maximum file limit of 50,000".into());
}
let ft = entry.file_type().map_err(|e| format!("couldn't read file type: {e}"))?;
let p = entry.path();
if ft.is_dir() && !ft.is_symlink() {
stack.push(p);
} else {
let rel = p.strip_prefix(root).map_err(|e| e.to_string())?;
out.push(rel.to_path_buf());
}
}
}
out.sort();
Ok(out)
}
}
#[cfg(target_os = "freebsd")]
mod freebsd {
use std::ffi::OsStr;
use super::{App, Path, PathBuf, Paths, pkexec, process, run_as_root, tree};
/// Copies the release's tree into `/usr/local` (asking for permission through polkit), or
/// into `~/.local` when polkit isn't installed. Returns the binary, every file installed,
/// and whether removing them needs root.
pub fn install_tree(app: &App, file: &Path, paths: &Paths) -> Result<(PathBuf, Vec<PathBuf>, bool), String> {
let staging = paths.cache.join(format!("{}.new", app.id));
let result = install_from(app, file, &staging);
let _ = std::fs::remove_dir_all(&staging);
result
}
fn install_from(app: &App, file: &Path, staging: &Path) -> Result<(PathBuf, Vec<PathBuf>, bool), String> {
let tree = tree::unpack(app, file, staging)?;
let rel = tree::files(&tree)?;
let mut source = tree.into_os_string();
source.push("/.");
if pkexec().is_some() {
let prefix = PathBuf::from("/usr/local");
run_as_root(&[OsStr::new("/bin/cp"), OsStr::new("-R"), &source, prefix.as_os_str()])?;
return Ok((prefix.join("bin").join(app.id), rel.iter().map(|r| prefix.join(r)).collect(), true));
}
let prefix = crate::folders::home_dir().ok_or("no home folder")?.join(".local");
std::fs::create_dir_all(&prefix).map_err(|e| e.to_string())?;
let status = process::command("/bin/cp").arg("-R").arg(&source).arg(&prefix).status().map_err(|e| e.to_string())?;
if !status.success() {
return Err("couldn't copy the app into ~/.local".into());
}
// ~/.local/bin is often not on PATH for the desktop: point the menu entry at the binary.
let bin = prefix.join("bin").join(app.id);
let desktop = prefix.join("share/applications").join(format!("{}.desktop", app.bundle_id()));
if let Ok(text) = std::fs::read_to_string(&desktop) {
let exec = format!("Exec=\"{}\"", bin.display());
let fixed: String = text
.lines()
.map(|l| {
if let Some(rest) = l.strip_prefix("Exec=").and_then(|r| r.strip_prefix(app.id)) {
format!("{exec}{rest}\n")
} else {
format!("{l}\n")
}
})
.collect();
let _ = std::fs::write(&desktop, fixed);
}
Ok((bin, rel.iter().map(|r| prefix.join(r)).collect(), false))
}
/// Removes the files a tree install put down.
pub fn remove_files(files: &[PathBuf], needs_root: bool) -> Result<(), String> {
if files.is_empty() {
return Err("the launcher has no record of these files".into());
}
let prefix = if needs_root { PathBuf::from("/usr/local") } else { crate::folders::home_dir().ok_or("no home folder")?.join(".local") };
let mut safe_files = Vec::new();
for f in files {
// Refuse paths that climb or escape install prefix (CWE-59).
if !f.starts_with(&prefix) || f.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
return Err(format!("refusing to remove file outside install prefix: {}", f.display()));
}
let mut has_symlink_ancestor = false;
if let Ok(rel) = f.strip_prefix(&prefix) {
let mut check = prefix.clone();
for part in rel.components() {
if let Ok(meta) = std::fs::symlink_metadata(&check)
&& meta.file_type().is_symlink()
{
has_symlink_ancestor = true;
break;
}
check.push(part);
}
}
if !has_symlink_ancestor {
safe_files.push(f);
}
}
if safe_files.is_empty() {
return Ok(());
}
if needs_root {
let mut args: Vec<&OsStr> = vec![OsStr::new("/bin/rm"), OsStr::new("-f"), OsStr::new("--")];
args.extend(safe_files.iter().map(|f| f.as_os_str()));
return run_as_root(&args);
}
for f in safe_files {
let _ = std::fs::remove_file(f);
}
Ok(())
}
}
/// Builds the Windows Installer command separately so its package argument can be checked
/// without starting an installer.
#[cfg_attr(not(windows), allow(dead_code))]
fn msi_command(program: &Path, msi: &Path) -> Result<std::process::Command, String> {
let msi = msi_path(msi)?;
let mut command = process::command(program);
// A verbose log beside the package: Windows Installer's own errors (1603 above all) say
// nothing without it.
command.arg("/i").arg(&msi).args(["/passive", "/norestart", "/l*v"]).arg(msi_log(&msi));
Ok(command)
}
/// The self-update command: [`msi_command`] plus the properties that keep the installed copy's
/// Windows Installer context (`msi_copy::update_arguments`).
#[cfg_attr(not(windows), allow(dead_code))]
fn reinstall_command(program: &Path, msi: &Path, context: &[&str]) -> Result<std::process::Command, String> {
let mut command = msi_command(program, msi)?;
command.args(context);
Ok(command)
}
/// Where `msi_command` asks Windows Installer to write its log.
#[cfg_attr(not(windows), allow(dead_code))]
pub(crate) fn msi_log(msi: &Path) -> PathBuf {
msi.with_extension("log")
}
/// Windows Installer's storage APIs can fail on paths through junctions or symlinks (#338).
/// Keep ordinary drive/UNC syntax when possible: Windows canonicalization alone introduces
/// a `\\?\` prefix that older APIs also cannot handle.
#[cfg_attr(not(windows), allow(dead_code))]
pub(crate) fn msi_path(msi: &Path) -> Result<PathBuf, String> {
#[cfg(windows)]
let resolved = dunce::canonicalize(msi);
#[cfg(not(windows))]
let resolved = std::fs::canonicalize(msi);
resolved.map_err(|e| format!("couldn't resolve the Windows Installer package {} ({e})", msi.display()))
}
#[cfg(windows)]
pub mod windows {
use crate::protocol::CANCELLED;
use super::{
App, INSTALLER, JobPhase, Path, PathBuf, Stop, cancelled, msi_command, msi_folder_argument, msi_log, process, reinstall_command, turn,
};
fn msiexec() -> PathBuf {
std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from).join(r"System32\msiexec.exe")
}
fn explain(code: Option<i32>) -> Result<(), String> {
match code {
Some(0 | 3010 | 1641) => Ok(()),
Some(1602) => Err("cancelled".into()),
Some(1618) => Err("another installation is in progress. Wait for it to finish, then try again.".into()),
Some(1925 | 1303) => Err("Windows didn't allow the install (administrator permission is needed)".into()),
Some(c) => Err(format!("the Windows Installer stopped with error {c}")),
None => Err("the Windows Installer was interrupted".into()),
}
}
/// Runs an MSI with a progress window and no restart. Windows asks for permission itself.
pub fn run_msi(msi: &Path, destination: Option<&Path>, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
in_turn(stop, phase, JobPhase::Installing, || {
use std::os::windows::process::CommandExt;
let mut command = msi_command(&msiexec(), msi)?;
if let Some(dir) = destination {
command.raw_arg(msi_folder_argument(dir)?);
}
let status = command.status().map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;
explain(status.code()).map_err(|e| match status.code() {
Some(1602) | None => e,
_ => format!("{e}. Its log is at {}", msi_log(msi).display()),
})
})
}
/// Runs an app's own new MSI for a self-update, with the same progress window and no restart
/// as [`run_msi`], and waits for it. `context` keeps the installed copy's Windows Installer
/// context; `folder`, when given, is where it goes. A failure names the installer's log,
/// which is the only place 1603 is explained.
pub fn reinstall(msi: &Path, context: &[&str], folder: Option<&Path>) -> Result<(), String> {
use std::os::windows::process::CommandExt;
let mut command = reinstall_command(&msiexec(), msi, context)?;
if let Some(dir) = folder {
command.raw_arg(msi_folder_argument(dir)?);
}
let status = command.status().map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;
explain(status.code()).map_err(|e| match status.code() {
Some(1602) | None => e,
_ => format!("{e}. Its log is at {}", msi_log(msi).display()),
})
}
/// Runs `work` once no other Windows Installer job of ours is running (a second one would
/// stop with error 1618). Cancelled while it waits, it never runs.
fn in_turn(stop: &Stop, phase: &mut dyn FnMut(JobPhase), working: JobPhase, work: impl FnOnce() -> Result<(), String>) -> Result<(), String> {
INSTALLER.alone(&cancelled(stop), &mut turn(phase, working), work).unwrap_or_else(|| Err(CANCELLED.into()))
}
/// Removes one installed product by its code, with a progress window and no restart, and waits.
/// A per-user product asks for no permission. `log` receives the installer's verbose log.
pub fn remove_product(product_code: &str, log: &Path) -> Result<(), String> {
if !crate::msi_context::valid_product_code(product_code) {
return Err(format!("{product_code} isn't a Windows Installer product code"));
}
let status = process::command(msiexec())
.args(["/x", product_code, "/passive", "/norestart", "/l*v"])
.arg(log)
.status()
.map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;
explain(status.code()).map_err(|e| format!("{e}. Its log is at {}", log.display()))
}
pub fn uninstall_msi(app: &App, stop: &Stop, phase: &mut dyn FnMut(JobPhase)) -> Result<(), String> {
let installs = crate::detect::windows::msi_installs(app);
if installs.is_empty() {
return Err(format!("{} isn't registered with the Windows Installer", app.name));
}
in_turn(stop, phase, JobPhase::Removing, || {
for m in installs {
let status = process::command(msiexec())
.args(["/x", &m.product_code, "/passive", "/norestart"])
.status()
.map_err(|e| format!("couldn't start the Windows Installer ({e})"))?;
explain(status.code())?;
}
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn a_system_flatpak_bundle_is_reinstalled_with_pkexec_on_that_installation() {
assert_eq!(super::linux::flatpak_bundle_flags(false), ["install", "--user", "-y", "--noninteractive", "--reinstall", "--bundle"]);
let system = super::linux::flatpak_bundle_flags(true);
assert_eq!(system, ["install", "--system", "-y", "--noninteractive", "--reinstall", "--bundle"]);
assert!(!system.contains(&"--user"));
assert_eq!(super::linux::system_bundle_prefix(true), ["flatpak-spawn", "--host", "pkexec", "/usr/bin/flatpak"]);
assert_eq!(super::linux::system_bundle_prefix(false), ["pkexec", "/usr/bin/flatpak"]);
}
#[cfg(target_os = "linux")]
#[test]
fn a_dismissed_system_bundle_password_cancels_and_a_flatpak_error_is_kept() {
use std::os::unix::process::ExitStatusExt;
use std::process::Output;
let dismissed = Output { status: std::process::ExitStatus::from_raw(126 << 8), stdout: Vec::new(), stderr: Vec::new() };
assert_eq!(super::linux::flatpak_bundle_error(&dismissed).unwrap_err(), "cancelled");
let failed =
Output { status: std::process::ExitStatus::from_raw(1 << 8), stdout: Vec::new(), stderr: b"error: something went wrong\n".to_vec() };
assert_eq!(super::linux::flatpak_bundle_error(&failed).unwrap_err(), "Flatpak couldn't install the bundle: something went wrong");
let ok = Output { status: std::process::ExitStatus::from_raw(0), stdout: Vec::new(), stderr: Vec::new() };
assert!(super::linux::flatpak_bundle_error(&ok).is_ok());
}
#[cfg(unix)]
#[test]
fn msi_install_uses_the_package_target_instead_of_a_symlinked_cache() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path().join("persistent cache");
std::fs::create_dir(&cache).unwrap();
let file = cache.join("PhotoCraft.msi");
std::fs::write(&file, b"test package").unwrap();
let link = dir.path().join("current cache");
std::os::unix::fs::symlink(&cache, &link).unwrap();
let command = msi_command(Path::new("msiexec.exe"), &link.join("PhotoCraft.msi")).unwrap();
let args: Vec<_> = command.get_args().collect();
assert_eq!(
args,
[
std::ffi::OsStr::new("/i"),
file.canonicalize().unwrap().as_os_str(),
std::ffi::OsStr::new("/passive"),
std::ffi::OsStr::new("/norestart"),
std::ffi::OsStr::new("/l*v"),
file.canonicalize().unwrap().with_extension("log").as_os_str()
]
);
}
#[test]
fn a_self_update_keeps_the_installed_copys_context_after_the_package_options() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("GridCraft.msi");
std::fs::write(&file, b"test package").unwrap();
let resolved = msi_path(&file).unwrap();
for context in [&["ALLUSERS=1"][..], &["ALLUSERS=2", "MSIINSTALLPERUSER=1"], &[]] {
let command = reinstall_command(Path::new("msiexec.exe"), &file, context).unwrap();
let args: Vec<_> = command.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
let mut expected: Vec<String> = ["/i", &resolved.to_string_lossy(), "/passive", "/norestart", "/l*v"].map(String::from).to_vec();
expected.push(resolved.with_extension("log").to_string_lossy().into_owned());
expected.extend(context.iter().map(|p| p.to_string()));
assert_eq!(args, expected);
}
}
#[test]
fn an_unavailable_msi_is_refused_before_starting_the_installer() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing.msi");
let error = msi_command(Path::new("msiexec.exe"), &missing).unwrap_err();
assert!(error.contains("couldn't resolve the Windows Installer package"), "{error}");
}
#[cfg(windows)]
#[test]
fn a_short_local_msi_path_keeps_ordinary_windows_syntax() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("package.msi");
std::fs::write(&file, b"test package").unwrap();
let resolved = msi_path(&file).unwrap();
assert!(!resolved.as_os_str().to_string_lossy().starts_with(r"\\?\"), "{resolved:?}");
assert_eq!(std::fs::read(resolved).unwrap(), b"test package");
}
#[test]
fn selected_folders_must_exist_and_leave_no_probe_files() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(selected_folder(dir.path().to_str().unwrap(), true).unwrap(), dir.path());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
#[cfg(unix)]
{
let quoted = dir.path().join("Craft \"Apps\"");
std::fs::create_dir(&quoted).unwrap();
assert_eq!(selected_folder(quoted.to_str().unwrap(), true).unwrap(), quoted);
}
let missing = dir.path().join("unavailable/Apps");
assert!(selected_folder(missing.to_str().unwrap(), true).is_err());
assert!(!missing.exists());
assert!(selected_folder("relative/folder", true).is_err());
assert!(selected_folder("bad\nfolder", true).is_err());
let app = App::by_id("photocraft").unwrap();
let locations = InstallLocations { root: Some(dir.path().display().to_string()), ..Default::default() };
assert_eq!(destination(app, PackageKind::Msi, None, None, &locations).unwrap(), Some(dir.path().join("PhotoCraft")));
assert_eq!(destination(app, PackageKind::PortableZip, None, None, &locations).unwrap(), Some(dir.path().to_path_buf()));
let old = record("0.5.0", PackageKind::PortableZip, dir.path().join("PhotoCraft/photocraft.exe"), vec![], false);
let unavailable = InstallLocations { root: Some(dir.path().join("missing").display().to_string()), ..Default::default() };
assert_eq!(destination(app, PackageKind::PortableZip, None, Some(&old), &unavailable).unwrap(), None);
let msi = Installed {
version: None,
source: InstallSource::Msi,
location: dir.path().join("MSI/PhotoCraft/photocraft.exe").display().to_string(),
package: None,
};
assert_eq!(destination(app, PackageKind::Msi, Some(&msi), Some(&old), &unavailable).unwrap(), Some(dir.path().join("MSI/PhotoCraft")));
assert!(destination(app, PackageKind::TarGz, None, None, &locations).unwrap_err().contains("aren't supported"));
}
#[test]
fn updates_stay_beside_the_launchers_copy_and_new_installs_use_the_chosen_folder() {
let dir = tempfile::tempdir().unwrap();
let app = App::by_id("photocraft").unwrap();
let path = dir.path().join("old.AppImage");
let rec = record("0.5.0", PackageKind::AppImage, path.clone(), vec![], false);
let launcher = Installed { version: None, source: InstallSource::Launcher, location: path.display().to_string(), package: None };
let users = Installed { version: None, source: InstallSource::AppImage, location: "/home/me/AppImages/p.AppImage".into(), package: None };
let chosen = dir.path().join("New apps");
std::fs::create_dir(&chosen).unwrap();
let mut locations = InstallLocations { root: Some(chosen.display().to_string()), ..Default::default() };
assert_eq!(destination(app, PackageKind::AppImage, Some(&launcher), Some(&rec), &locations).unwrap(), Some(dir.path().into()));
assert_eq!(destination(app, PackageKind::AppImage, Some(&users), None, &locations).unwrap(), None);
if !process::in_sandbox() {
assert_eq!(destination(app, PackageKind::AppImage, None, None, &locations).unwrap(), Some(chosen.clone()));
locations.per_app.insert(app.id.into(), dir.path().display().to_string());
assert_eq!(destination(app, PackageKind::Dmg, None, None, &locations).unwrap(), Some(dir.path().into()));
}
}
#[test]
fn msi_folder_assignment_preserves_spaces_unicode_and_root_separators() {
for (value, argument) in [
(r"D:\", r#"INSTALLFOLDER="D:\""#),
(r"D:\Craft Apps\PhotoCraft", r#"INSTALLFOLDER="D:\Craft Apps\PhotoCraft""#),
(r"\\server\apps\绘画", r#"INSTALLFOLDER="\\server\apps\绘画""#),
(r"D:\Craft Apps\", r#"INSTALLFOLDER="D:\Craft Apps\""#),
] {
assert_eq!(msi_folder_argument(Path::new(value)).unwrap(), argument);
}
assert!(msi_folder_argument(Path::new("D:\\bad\" TRANSFORMS=evil")).is_err());
assert!(msi_folder_argument(Path::new("D:\\bad\nfolder")).is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn appimage_updates_replace_only_the_recorded_file_in_the_same_folder() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("Craft Apps");
std::fs::create_dir(&folder).unwrap();
let download = dir.path().join("download");
std::fs::write(&download, "first").unwrap();
let unrelated = folder.join("photocraft-unrelated.AppImage");
std::fs::write(&unrelated, "user copy").unwrap();
let (old, removed) = linux::install_appimage(&download, "photocraft-0.5.0.AppImage", Some(&folder), None, false).unwrap();
assert!(removed.is_empty());
assert_eq!(std::fs::read(&old).unwrap(), b"first");
let rec = record("0.5.0", PackageKind::AppImage, old.clone(), vec![], false);
std::fs::write(&download, "updated").unwrap();
let (new, removed) = linux::install_appimage(&download, "photocraft-0.6.0.AppImage", Some(&folder), Some(&rec), false).unwrap();
assert_eq!(new, folder.join("photocraft-0.6.0.AppImage"));
assert_eq!(std::fs::read(&new).unwrap(), b"updated");
assert_eq!(removed.as_slice(), std::slice::from_ref(&old));
assert!(!old.exists());
assert_eq!(std::fs::read(&unrelated).unwrap(), b"user copy");
assert!(linux::install_appimage(&download, "photocraft-unrelated.AppImage", Some(&folder), Some(&rec), false).is_err());
assert_eq!(std::fs::read(&unrelated).unwrap(), b"user copy");
let new_rec = record("0.6.0", PackageKind::AppImage, new.clone(), vec![], false);
assert!(linux::install_appimage(&download, "photocraft-0.6.0.AppImage", Some(&folder), Some(&new_rec), false).is_ok());
assert_eq!(std::fs::read_dir(&folder).unwrap().count(), 2);
let link = folder.join("linked.AppImage");
std::os::unix::fs::symlink(&unrelated, &link).unwrap();
let link_rec = record("0.6.0", PackageKind::AppImage, link, vec![], false);
assert!(linux::install_appimage(&download, "linked.AppImage", Some(&folder), Some(&link_rec), false).is_err());
assert_eq!(std::fs::read(&unrelated).unwrap(), b"user copy");
}
#[cfg(target_os = "linux")]
#[test]
fn an_appimage_writes_over_a_users_file_only_when_asked_to() {
let dir = tempfile::tempdir().unwrap();
let download = dir.path().join("download");
std::fs::write(&download, "new").unwrap();
let folder = dir.path().join("Apps");
std::fs::create_dir(&folder).unwrap();
let theirs = folder.join("photocraft.AppImage");
let personal = dir.path().join("personal.txt");
std::fs::write(&theirs, "user copy").unwrap();
std::fs::write(&personal, "user file").unwrap();
std::os::unix::fs::symlink(&personal, folder.join(".photocraft.AppImage.partial")).unwrap();
assert!(linux::install_appimage(&download, "photocraft.AppImage", Some(&folder), None, false).is_err());
assert_eq!(std::fs::read(&theirs).unwrap(), b"user copy");
linux::install_appimage(&download, "photocraft.AppImage", Some(&folder), None, true).unwrap();
assert_eq!(std::fs::read(&theirs).unwrap(), b"new");
assert_eq!(std::fs::read(&personal).unwrap(), b"user file");
assert_eq!(std::fs::read_dir(&folder).unwrap().count(), 2, "staging was left behind");
}
#[test]
fn a_new_install_reports_what_it_would_write_over() {
let dir = tempfile::tempdir().unwrap();
let app = App::by_id("photocraft").unwrap();
let paths = Paths::under(dir.path());
let locations = InstallLocations { root: Some(dir.path().display().to_string()), ..Default::default() };
let asset = Asset { name: "photocraft.zip".into(), ..Default::default() };
let occupied = |previous| occupied(app, &asset, PackageKind::PortableZip, None, previous, &locations, &paths).unwrap();
assert_eq!(occupied(None), None);
std::fs::create_dir(dir.path().join("photocraft")).unwrap();
assert_eq!(occupied(None), Some(dir.path().join("photocraft")));
let old = record("0.5.0", PackageKind::PortableZip, dir.path().join("photocraft/photocraft.exe"), vec![], false);
assert_eq!(occupied(Some(&old)), None);
}
#[test]
fn swap_keeps_the_old_copy_until_the_new_one_is_in_place() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("app");
let staged = dir.path().join("app.new");
std::fs::create_dir(&dest).unwrap();
std::fs::write(dest.join("v"), "old").unwrap();
std::fs::create_dir(&staged).unwrap();
std::fs::write(staged.join("v"), "new").unwrap();
swap_into_place(&staged, &dest).unwrap();
assert_eq!(std::fs::read_to_string(dest.join("v")).unwrap(), "new");
assert!(!staged.exists() && !dir.path().join("app.old").exists());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
// A missing staged copy leaves the installed one alone.
assert!(swap_into_place(&staged, &dest).is_err());
assert_eq!(std::fs::read_to_string(dest.join("v")).unwrap(), "new");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
}
#[test]
fn swap_preserves_an_unrelated_backup_file() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("app");
let staged = dir.path().join("app.new");
let unrelated = dest.with_extension("old");
std::fs::write(&dest, b"installed app").unwrap();
std::fs::write(&staged, b"updated app").unwrap();
std::fs::write(&unrelated, b"user backup").unwrap();
swap_into_place(&staged, &dest).unwrap();
assert_eq!(std::fs::read(&unrelated).unwrap(), b"user backup");
assert_eq!(std::fs::read(&dest).unwrap(), b"updated app");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2);
}
#[test]
fn swap_preserves_an_unrelated_backup_directory() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("app");
let staged = dir.path().join("app.new");
let unrelated = dest.with_extension("old");
std::fs::create_dir(&dest).unwrap();
std::fs::write(dest.join("v"), b"installed app").unwrap();
std::fs::create_dir(&staged).unwrap();
std::fs::write(staged.join("v"), b"updated app").unwrap();
std::fs::create_dir(&unrelated).unwrap();
std::fs::write(unrelated.join("keep.txt"), b"user backup").unwrap();
swap_into_place(&staged, &dest).unwrap();
assert_eq!(std::fs::read(unrelated.join("keep.txt")).unwrap(), b"user backup");
assert_eq!(std::fs::read(dest.join("v")).unwrap(), b"updated app");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 2);
}
#[cfg(unix)]
#[test]
fn swap_preserves_a_dangling_backup_symlink() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("app");
let staged = dir.path().join("app.new");
let unrelated = dest.with_extension("old");
let target = dir.path().join("missing");
std::fs::write(&dest, b"installed app").unwrap();
std::fs::write(&staged, b"updated app").unwrap();
std::os::unix::fs::symlink(&target, &unrelated).unwrap();
swap_into_place(&staged, &dest).unwrap();
assert_eq!(std::fs::read_link(&unrelated).unwrap(), target);
assert_eq!(std::fs::read(&dest).unwrap(), b"updated app");
}
#[test]
fn a_failed_rollback_keeps_the_old_app_and_reports_its_backup_path() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("app");
let staged = dir.path().join("app.new");
std::fs::write(&dest, b"installed app").unwrap();
std::fs::write(&staged, b"updated app").unwrap();
let calls = std::cell::Cell::new(0);
let err = swap_into_place_via(&staged, &dest, |from, to| {
calls.set(calls.get() + 1);
match calls.get() {
1 => std::fs::rename(from, to),
2 => Err(std::io::Error::other("staged move failed")),
_ => Err(std::io::Error::other("restore failed")),
}
})
.unwrap_err();
assert_eq!(calls.get(), 3);
assert!(err.contains("staged move failed") && err.contains("restore failed"));
let backup = std::fs::read_dir(dir.path()).unwrap().map(|e| e.unwrap().path()).find(|p| p.is_dir()).unwrap();
let saved = backup.join("old");
assert_eq!(std::fs::read(&saved).unwrap(), b"installed app");
assert!(err.contains(&saved.display().to_string()));
assert_eq!(std::fs::read(&staged).unwrap(), b"updated app");
}
#[test]
fn a_failed_install_keeps_the_download_for_the_next_try() {
use crate::http::tests::{Reply, body, serve};
let base = App::by_id("wordcraft").unwrap();
let wordcraft = App { icon_png: Some(b"\x89PNG\r\n\x1a\nfake-png-bytes"), ..*base };
let app = &wordcraft;
let (body, sha) = body();
let (url, requests) = serve(body.clone(), vec![Reply::Full, Reply::Full]);
let asset = Asset { name: "wordcraft-0.3.0-test.pkg".into(), size: body.len() as u64, url, sha256: Some(sha) };
let release = Release { assets: vec![asset.clone()], ..Release::default() };
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let http = Http::new(crate::TEST_AGENT).unwrap();
// A format no build of the launcher installs here stands in for any failing install step.
let kind = if cfg!(windows) { PackageKind::Dmg } else { PackageKind::Msi };
let mut phases = Vec::new();
for _ in 0..2 {
let mut phase = |p: JobPhase| phases.push(p);
let mut job = Job {
http: &http,
paths: &paths,
stop: &Stop::default(),
phase: &mut phase,
install_locations: &InstallLocations::default(),
replace: false,
};
let err = install(app, &release, &asset, kind, None, None, &mut job).unwrap_err();
assert!(err.contains("isn't supported"), "{err}");
assert_eq!(std::fs::read(download_path(&paths, &asset)).unwrap(), body);
}
assert_eq!(requests.lock().unwrap().len(), 1, "the second try reuses the kept file");
assert_eq!(phases.iter().filter(|p| **p == JobPhase::Installing).count(), 2);
}
#[test]
fn an_unavailable_custom_folder_is_rejected_before_downloading() {
use crate::http::tests::{Reply, body, serve};
let base = App::by_id("wordcraft").unwrap();
let wordcraft = App { icon_png: Some(b"\x89PNG\r\n\x1a\nfake-png-bytes"), ..*base };
let app = &wordcraft;
let (body, sha) = body();
let (url, requests) = serve(body.clone(), vec![Reply::Full]);
let asset = Asset { name: "wordcraft-0.3.0-linux-x86_64.AppImage".into(), size: body.len() as u64, url, sha256: Some(sha) };
let release = Release { assets: vec![asset.clone()], ..Release::default() };
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("unavailable/Apps");
let locations = InstallLocations { root: Some(missing.display().to_string()), ..Default::default() };
let paths = Paths::under(dir.path());
let http = Http::new(crate::TEST_AGENT).unwrap();
let mut phases = vec![];
let mut phase = |p| phases.push(p);
let mut job = Job { http: &http, paths: &paths, stop: &Stop::default(), phase: &mut phase, install_locations: &locations, replace: false };
let error = install(app, &release, &asset, PackageKind::AppImage, None, None, &mut job).unwrap_err();
assert!(error.contains("folder is unavailable") || (process::in_sandbox() && error.contains("aren't supported")), "{error}");
assert!(requests.lock().unwrap().is_empty());
assert!(phases.is_empty());
assert!(!missing.exists());
}
#[test]
fn a_release_without_a_checksum_is_refused_before_anything_is_fetched() {
use crate::http::tests::{Reply, body, serve};
let base = App::by_id("wordcraft").unwrap();
let wordcraft = App { icon_png: Some(b"\x89PNG\r\n\x1a\nfake-png-bytes"), ..*base };
let app = &wordcraft;
let (body, _) = body();
let (url, requests) = serve(body.clone(), vec![Reply::Full]);
// Neither a per-file digest nor a SHA256SUMS.txt in the release.
let asset = Asset { name: "wordcraft-0.3.1-test.pkg".into(), size: body.len() as u64, url, sha256: None };
let release = Release { assets: vec![asset.clone()], ..Release::default() };
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let http = Http::new(crate::TEST_AGENT).unwrap();
let kind = if cfg!(windows) { PackageKind::Dmg } else { PackageKind::Msi };
let mut phases = Vec::new();
let mut phase = |p: JobPhase| phases.push(p);
let mut job = Job {
http: &http,
paths: &paths,
stop: &Stop::default(),
phase: &mut phase,
install_locations: &InstallLocations::default(),
replace: false,
};
let err = install(app, &release, &asset, kind, None, None, &mut job).unwrap_err();
assert!(err.contains("publishes no checksum"), "{err}");
assert!(!download_path(&paths, &asset).exists());
assert!(requests.lock().unwrap().is_empty(), "nothing was fetched");
assert!(!phases.contains(&JobPhase::Installing));
}
#[cfg(unix)]
fn tarball(path: &Path, entries: &[&str]) {
let f = std::fs::File::create(path).unwrap();
let mut b = tar::Builder::new(flate2::write::GzEncoder::new(f, flate2::Compression::fast()));
for name in entries {
let mut h = tar::Header::new_gnu();
h.set_size(2);
h.set_mode(0o755);
h.set_cksum();
b.append_data(&mut h, name, &b"hi"[..]).unwrap();
}
b.into_inner().unwrap().finish().unwrap();
}
#[cfg(unix)]
#[test]
fn lays_out_both_freebsd_tarball_styles_like_usr_local() {
let base = App::by_id("wordcraft").unwrap();
let wordcraft = App { icon_png: Some(b"\x89PNG\r\n\x1a\nfake-png-bytes"), ..*base };
let app = &wordcraft;
let dir = tempfile::tempdir().unwrap();
let tree_style = dir.path().join("tree.tar.gz");
tarball(
&tree_style,
&["wordcraft-0.3.0-freebsd-x86_64/bin/wordcraft", "wordcraft-0.3.0-freebsd-x86_64/share/applications/ai.storyteller.wordcraft.desktop"],
);
let flat_style = dir.path().join("flat.tar.gz");
tarball(
&flat_style,
&[
"wordcraft-0.3.0-freebsd-x86_64/wordcraft",
"wordcraft-0.3.0-freebsd-x86_64/wordcraft-cli",
"wordcraft-0.3.0-freebsd-x86_64/README.md",
"wordcraft-0.3.0-freebsd-x86_64/ai.storyteller.wordcraft.desktop",
"wordcraft-0.3.0-freebsd-x86_64/ai.storyteller.wordcraft.png",
],
);
for file in [tree_style, flat_style] {
let root = tree::unpack(app, &file, &dir.path().join("staging")).unwrap();
let files = tree::files(&root).unwrap();
assert!(files.contains(&PathBuf::from("bin/wordcraft")), "{files:?}");
assert!(files.contains(&PathBuf::from("share/applications/ai.storyteller.wordcraft.desktop")), "{files:?}");
assert!(!files.iter().any(|f| f.ends_with("README.md")), "{files:?}");
}
let bad = dir.path().join("bad.tar.gz");
tarball(&bad, &["x/README.md"]);
assert!(tree::unpack(app, &bad, &dir.path().join("staging")).is_err());
}
#[cfg(unix)]
#[test]
fn an_oversized_pax_record_is_refused_before_tar_buffers_it() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
// A PAX header claiming 1 MiB + 1 byte of records, compressed to almost nothing.
let mut header = tar::Header::new_ustar();
header.set_entry_type(tar::EntryType::XHeader);
header.set_size((1 << 20) + 1);
header.set_path("pax").unwrap();
header.set_cksum();
let file = dir.path().join("pax.tar.gz");
let mut gz = flate2::write::GzEncoder::new(std::fs::File::create(&file).unwrap(), flate2::Compression::best());
gz.write_all(header.as_bytes()).unwrap();
gz.write_all(&vec![b'a'; (1 << 20) + 512]).unwrap();
gz.finish().unwrap();
let err = tree::unpack(App::by_id("wordcraft").unwrap(), &file, &dir.path().join("staging")).unwrap_err();
assert!(err.contains("oversized metadata"), "{err}");
}
#[cfg(unix)]
#[test]
fn tree_files_does_not_traverse_directory_symlinks() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("root");
let external = dir.path().join("external");
std::fs::create_dir_all(&root).unwrap();
std::fs::create_dir_all(&external).unwrap();
std::fs::write(root.join("regular.txt"), b"hi").unwrap();
std::fs::write(external.join("secret.txt"), b"secret").unwrap();
std::os::unix::fs::symlink(&external, root.join("link_to_external")).unwrap();
let list = tree::files(&root).unwrap();
assert_eq!(list, vec![PathBuf::from("link_to_external"), PathBuf::from("regular.txt")]);
}
#[cfg(target_os = "freebsd")]
#[test]
fn remove_files_refuses_escaping_paths() {
assert!(freebsd::remove_files(&[PathBuf::from("/etc/shadow")], false).is_err());
if let Some(home) = crate::folders::home_dir() {
let prefix = home.join(".local");
assert!(freebsd::remove_files(&[prefix.join("../escaped")], false).is_err());
}
}
#[test]
fn single_top_folder_rejects_symlink() {
let dir = tempfile::tempdir().unwrap();
let real_dir = dir.path().join("real");
std::fs::create_dir_all(&real_dir).unwrap();
let staging = dir.path().join("staging");
std::fs::create_dir_all(&staging).unwrap();
#[cfg(unix)]
{
std::os::unix::fs::symlink(&real_dir, staging.join("symlink_dir")).unwrap();
let res = single_top_folder(&staging).unwrap();
assert_eq!(res, staging, "a directory symlink must not be chosen as top folder");
}
}
#[cfg(target_os = "linux")]
#[test]
fn a_tarball_install_lives_in_the_launchers_folder_and_keeps_the_apps_home() {
let base = App::by_id("wordcraft").unwrap();
let wordcraft = App { icon_png: Some(b"\x89PNG\r\n\x1a\nfake-png-bytes"), ..*base };
let app = &wordcraft;
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let file = dir.path().join("wordcraft-0.3.0-linux-x86_64.tar.gz");
tarball(
&file,
&[
"wordcraft-0.3.0-linux-x86_64/bin/wordcraft",
"wordcraft-0.3.0-linux-x86_64/bin/wordcraft-cli",
"wordcraft-0.3.0-linux-x86_64/share/applications/ai.storyteller.wordcraft.desktop",
],
);
let bin = linux::install_tree(app, &file, &paths).unwrap();
let root = paths.apps().join("wordcraft");
assert_eq!(bin, root.join("bin").join("wordcraft"));
assert!(bin.is_file(), "the app's binary wasn't unpacked");
assert!(root.join("share/applications/ai.storyteller.wordcraft.desktop").is_file(), "share/ wasn't unpacked");
assert!(!paths.cache.join("wordcraft.new").exists(), "the staging folder was left behind");
// The app's own home sits beside the tree, and an update keeps it.
let home = paths.app_home(app.id);
std::fs::create_dir_all(home.join(".config/wordcraft")).unwrap();
std::fs::write(home.join(".config/wordcraft/settings.toml"), "keep me").unwrap();
linux::install_tree(app, &file, &paths).unwrap();
assert_eq!(std::fs::read_to_string(home.join(".config/wordcraft/settings.toml")).unwrap(), "keep me");
// Removing the install takes the tree and nothing else.
linux::remove_tree(app, &paths).unwrap();
assert!(!bin.exists() && !root.join("share").exists());
assert!(home.join(".config/wordcraft/settings.toml").is_file(), "the app's settings were removed with it");
assert!(linux::remove_tree(app, &paths).is_err(), "a second removal says there is nothing there");
}
#[cfg(target_os = "linux")]
#[test]
fn a_tarball_binary_that_is_a_symlink_out_of_the_tree_is_refused() {
use std::io::Write;
use std::os::unix::fs::PermissionsExt;
let base = App::by_id("wordcraft").unwrap();
let wordcraft = App { icon_png: Some(b"\x89PNG\r\n\x1a\nfake-png-bytes"), ..*base };
let app = &wordcraft;
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
// A working install the rejected update must leave untouched.
let good = dir.path().join("wordcraft-0.3.0-linux-x86_64.tar.gz");
tarball(&good, &["wordcraft-0.3.0-linux-x86_64/bin/wordcraft"]);
let bin = linux::install_tree(app, &good, &paths).unwrap();
assert_eq!(std::fs::read_to_string(&bin).unwrap(), "hi");
// A file outside the install tree that the archive tries to chmod through a symlink.
let outside = dir.path().join("victim");
std::fs::write(&outside, b"secret").unwrap();
std::fs::set_permissions(&outside, std::fs::Permissions::from_mode(0o600)).unwrap();
// A tarball whose bin/wordcraft is a symlink to that external file.
let file = dir.path().join("evil.tar.gz");
let f = std::fs::File::create(&file).unwrap();
let mut b = tar::Builder::new(flate2::write::GzEncoder::new(f, flate2::Compression::fast()));
let mut link = tar::Header::new_gnu();
link.set_entry_type(tar::EntryType::Symlink);
link.set_size(0);
link.set_mode(0o777);
link.set_cksum();
b.append_link(&mut link, "wordcraft-0.3.0-linux-x86_64/bin/wordcraft", &outside).unwrap();
// A second real file so the top folder is detected.
let mut reg = tar::Header::new_gnu();
reg.set_size(2);
reg.set_mode(0o644);
reg.set_cksum();
b.append_data(&mut reg, "wordcraft-0.3.0-linux-x86_64/share/applications/ai.storyteller.wordcraft.desktop", &b"hi"[..]).unwrap();
b.into_inner().unwrap().finish().unwrap();
drop(std::io::stdout().flush());
let result = linux::install_tree(app, &file, &paths);
assert!(result.is_err(), "a symlink binary must be rejected: {result:?}");
assert!(result.unwrap_err().contains("symlink"));
// The external file keeps its original mode: the chmod never reached it.
let mode = std::fs::metadata(&outside).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "the external file's permissions must be untouched");
// The rejected update left the previously installed, working binary fully intact.
assert!(bin.is_file(), "the rejected update must not remove the working binary");
assert_eq!(std::fs::read_to_string(&bin).unwrap(), "hi");
}
#[cfg(target_os = "linux")]
#[test]
fn an_app_sees_its_own_home_and_the_xdg_folders_inside_it() {
let home = PathBuf::from("/data/apps/photocraft/home");
let env = home_env(&home);
assert_eq!(env[0], ("HOME", home.clone()));
for (name, dir) in &env[1..] {
assert!(dir.starts_with(&home), "{name}: {}", dir.display());
}
for wanted in ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME"] {
assert!(env.iter().any(|(name, _)| *name == wanted), "{wanted} isn't set");
}
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
#[test]
fn root_commands_run_programs_by_absolute_path_from_the_given_dirs() {
use std::ffi::OsStr;
let root = tempfile::tempdir().unwrap();
let (usr_bin, sbin) = (root.path().join("usr/bin"), root.path().join("sbin"));
std::fs::create_dir_all(&usr_bin).unwrap();
std::fs::create_dir_all(&sbin).unwrap();
std::fs::write(usr_bin.join("rpm"), b"").unwrap();
std::fs::write(sbin.join("rpm"), b"").unwrap();
std::fs::write(sbin.join("zypper"), b"").unwrap();
let dirs = [&usr_bin, &sbin];
let run = |args: &[&str]| {
let args: Vec<&OsStr> = args.iter().map(OsStr::new).collect();
with_absolute_program(&dirs, &args).map(|a| a.into_iter().map(PathBuf::from).collect::<Vec<_>>())
};
// The first directory that has the program wins, and the arguments follow unchanged.
assert_eq!(run(&["rpm", "-e", "wordcraft"]), Ok(vec![usr_bin.join("rpm"), "-e".into(), "wordcraft".into()]));
assert_eq!(run(&["zypper"]), Ok(vec![sbin.join("zypper")]));
// A program that isn't in those directories isn't looked for anywhere else.
assert_eq!(run(&["dnf", "remove"]), Err("dnf isn't installed".into()));
// An absolute path is kept; a relative path with folders in it is refused.
assert_eq!(run(&["/bin/cp", "-R"]), Ok(vec!["/bin/cp".into(), "-R".into()]));
assert!(run(&["../usr/bin/rpm"]).is_err());
assert!(run(&["bin/rpm"]).is_err());
assert!(run(&[]).is_err());
}
#[cfg(target_os = "linux")]
#[test]
fn several_debs_fit_one_apt_call_and_share_its_result() {
// The files are appended to this, so it must end where apt expects packages.
assert_eq!(
linux::install_command(PackageKind::Deb),
Some(&["apt-get", "-o", "DPkg::Lock::Timeout=60", "install", "-y", "--allow-downgrades"][..])
);
assert_eq!(linux::install_command(PackageKind::Flatpak), None);
let failed = || Err("the system installer stopped with error 100".to_string());
assert_eq!(linux::shared_outcome(Ok(()), 3), Ok(()));
assert_eq!(linux::shared_outcome(failed(), 1), failed());
assert_eq!(
linux::shared_outcome(failed(), 3),
Err("the system installer stopped with error 100 while handling 3 apps together. Try them one at a time.".into())
);
// A dismissed password prompt stays a cancellation for every app, not an error.
assert_eq!(linux::shared_outcome(Err("cancelled".into()), 3), Err("cancelled".into()));
}
/// Installs two `.deb` files that reach the installer together (both wait behind a running
/// install), with a stand-in for `pkexec` that exits with `code`. Returns every argument
/// list that reached it, and each job's result.
#[cfg(target_os = "linux")]
fn two_debs_through_pkexec(code: i32) -> (Vec<Vec<String>>, Vec<Result<(), String>>) {
use std::sync::{Mutex, mpsc};
let queue = crate::queue::Queue::new();
let ran = Mutex::new(Vec::new());
let run = |args: &[&std::ffi::OsStr]| {
ran.lock().unwrap().push(args.iter().map(|a| a.to_string_lossy().into_owned()).collect::<Vec<_>>());
root_outcome(Some(code))
};
let root = linux::Root { queue: &queue, run: &run, flatpak: &process::flatpak_command_c };
let (started, has_started) = mpsc::channel();
let (release, released) = mpsc::channel::<()>();
let (waiting, waits) = mpsc::channel();
let results = std::thread::scope(|scope| {
let (queue, root) = (&queue, &root);
scope.spawn(move || {
queue.alone(&|| false, &mut |_| {}, || {
started.send(()).unwrap();
released.recv().unwrap();
});
});
has_started.recv().unwrap();
let jobs = ["/downloads/a.deb", "/downloads/b.deb"].map(|file| {
let waiting = waiting.clone();
scope.spawn(move || {
let mut phase = |p: JobPhase| {
if p == JobPhase::Waiting {
waiting.send(()).unwrap();
}
};
linux::install_package_via(root, PackageKind::Deb, Path::new(file), None, &Stop::default(), &mut phase)
})
});
waits.recv().unwrap();
waits.recv().unwrap();
release.send(()).unwrap();
jobs.map(|j| j.join().unwrap()).to_vec()
});
(ran.into_inner().unwrap(), results)
}
#[cfg(target_os = "linux")]
#[test]
fn two_debs_reach_root_as_one_apt_call() {
let (ran, results) = two_debs_through_pkexec(0);
assert_eq!(ran.len(), 1, "{ran:?}");
let (command, files) = ran[0].split_at(6);
assert_eq!(command, ["apt-get", "-o", "DPkg::Lock::Timeout=60", "install", "-y", "--allow-downgrades"]);
let mut files = files.to_vec();
files.sort();
assert_eq!(files, ["/downloads/a.deb", "/downloads/b.deb"]);
assert_eq!(results, [Ok(()), Ok(())]);
// The password prompt was dismissed: one call, and both apps are cancelled, not failed.
let (ran, results) = two_debs_through_pkexec(126);
assert_eq!(ran.len(), 1, "{ran:?}");
assert_eq!(results, vec![Err("cancelled".to_string()); 2]);
// apt failed: both apps say so, and how to find the one at fault.
let (ran, results) = two_debs_through_pkexec(100);
assert_eq!(ran.len(), 1, "{ran:?}");
let error = "the system installer stopped with error 100 while handling 2 apps together. Try them one at a time.";
assert_eq!(results, vec![Err(error.to_string()); 2]);
}
#[cfg(target_os = "linux")]
#[test]
fn flatpak_bundle_install_failure_reports_clean_error() {
if !crate::process::is_flatpak_available() {
return;
}
let queue = crate::queue::Queue::new();
let root = linux::Root { queue: &queue, run: &|_| Ok(()), flatpak: &process::flatpak_command_c };
let result =
linux::install_package_via(&root, PackageKind::Flatpak, Path::new("/nonexistent/file.flatpak"), None, &Stop::default(), &mut |_| ());
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.starts_with("Flatpak couldn't install the bundle: "), "unexpected error: {err}");
}
#[cfg(target_os = "linux")]
const FLATPAK_ID: &str = "ai.storyteller.soundcraft";
#[cfg(target_os = "linux")]
const ALREADY_THERE: &str = "ai.storyteller.soundcraft already installed";
#[cfg(target_os = "linux")]
const INSTALL_CALL: &str = "install --user -y --noninteractive --reinstall --bundle /x.flatpak";
/// Runs `install` against a stand-in `flatpak` that logs its calls and fails its first `failures`
/// installs with `why`. Returns the result and the calls.
#[cfg(target_os = "linux")]
fn with_fake_flatpak(
flatpak_version: &str,
failures: u32,
why: &str,
uninstall_exit: u32,
install: impl FnOnce(&linux::Root<'_>) -> Result<(), String>,
) -> (Result<(), String>, Vec<String>) {
let dir = tempfile::tempdir().unwrap();
let (script, calls) = (dir.path().join("flatpak"), dir.path().join("calls"));
let log = calls.display();
std::fs::write(
&script,
format!(
r#"echo "$*" >> "{log}"
case "$1" in
--version) echo "Flatpak {flatpak_version}" ;;
uninstall) exit {uninstall_exit} ;;
install)
[ "$(grep -c '^install' "{log}")" -gt {failures} ] && exit 0
echo "error: Failed to install bundle: {why}" >&2
exit 1 ;;
esac
"#
),
)
.unwrap();
let flatpak = || {
let mut cmd = std::process::Command::new("/bin/sh");
cmd.arg(&script);
cmd
};
let queue = crate::queue::Queue::new();
let root = linux::Root { queue: &queue, run: &|_| Ok(()), flatpak: &flatpak };
let result = install(&root);
(result, std::fs::read_to_string(calls).unwrap().lines().map(str::to_string).collect())
}
#[cfg(target_os = "linux")]
fn install_bundle(root: &linux::Root<'_>, bundle: Option<&str>) -> Result<(), String> {
linux::install_package_via(root, PackageKind::Flatpak, Path::new("/x.flatpak"), bundle, &Stop::default(), &mut |_| {})
}
#[cfg(target_os = "linux")]
fn update_bundle(root: &linux::Root<'_>, system: bool) -> Result<(), String> {
linux::update_flatpak_bundle_via(root, Path::new("/x.flatpak"), FLATPAK_ID, system, &Stop::default(), &mut |_| {})
}
#[cfg(target_os = "linux")]
#[test]
fn a_bundle_goes_in_again_by_hand_only_when_flatpak_needs_it() {
let uninstall = format!("uninstall --user -y --noninteractive {FLATPAK_ID}");
let refused = format!("Flatpak couldn't install the bundle: Failed to install bundle: {ALREADY_THERE}");
let id = Some(FLATPAK_ID);
// An old Flatpak: the data stays, because nothing else goes on the uninstall's command line.
let (result, calls) = with_fake_flatpak("1.14.6", 1, ALREADY_THERE, 0, |root| install_bundle(root, id));
assert_eq!(result, Ok(()));
assert_eq!(calls, [INSTALL_CALL, "--version", &uninstall, INSTALL_CALL]);
// A first install that works, or a call with no bundle id, asks nothing more.
assert_eq!(with_fake_flatpak("1.14.6", 0, ALREADY_THERE, 0, |root| install_bundle(root, id)), (Ok(()), vec![INSTALL_CALL.to_string()]));
let (result, calls) = with_fake_flatpak("1.14.6", 1, ALREADY_THERE, 0, |root| install_bundle(root, None));
assert_eq!((result, calls), (Err(refused.clone()), vec![INSTALL_CALL.to_string()]));
// A Flatpak that can reinstall a bundle meant the refusal, so the installed copy stays.
let (result, calls) = with_fake_flatpak("1.16.2", 1, ALREADY_THERE, 0, |root| install_bundle(root, id));
assert_eq!(result, Err(refused.clone()));
assert_eq!(calls, [INSTALL_CALL, "--version"]);
// Any other failure leaves it alone too.
let (result, calls) = with_fake_flatpak("1.14.6", 1, "Invalid checksum", 0, |root| install_bundle(root, id));
assert_eq!(result, Err("Flatpak couldn't install the bundle: Failed to install bundle: Invalid checksum".to_string()));
assert_eq!(calls, [INSTALL_CALL]);
// A failed uninstall reports the first error, and the app is still there.
let (result, calls) = with_fake_flatpak("1.14.6", 1, ALREADY_THERE, 1, |root| install_bundle(root, id));
assert_eq!(result, Err(refused.clone()));
assert_eq!(calls, [INSTALL_CALL, "--version", &uninstall]);
// A failure after the uninstall says what happened and what to do next.
let (result, calls) = with_fake_flatpak("1.14.6", 2, ALREADY_THERE, 0, |root| install_bundle(root, id));
let error = result.unwrap_err();
assert!(error.starts_with(&refused) && error.contains("uninstalled first") && error.contains("Install tries again"), "{error}");
assert_eq!(calls, [INSTALL_CALL, "--version", &uninstall, INSTALL_CALL]);
}
#[cfg(target_os = "linux")]
#[test]
fn a_self_update_never_uninstalls_the_app_it_runs_from() {
// The update landed earlier and never restarted, so only the restart is left.
let (result, calls) = with_fake_flatpak("1.14.6", 1, ALREADY_THERE, 0, |root| update_bundle(root, false));
assert_eq!((result, calls), (Ok(()), vec![INSTALL_CALL.to_string()]));
let (result, calls) = with_fake_flatpak("1.14.6", 0, ALREADY_THERE, 0, |root| update_bundle(root, false));
assert_eq!((result, calls), (Ok(()), vec![INSTALL_CALL.to_string()]));
// Anything else is still a failure: a broken bundle, or one holding another app.
for why in ["Invalid checksum", "ai.storyteller.photocraft already installed"] {
let (result, calls) = with_fake_flatpak("1.14.6", 1, why, 0, |root| update_bundle(root, false));
assert_eq!(result, Err(format!("Flatpak couldn't install the bundle: Failed to install bundle: {why}")));
assert_eq!(calls, [INSTALL_CALL]);
}
}
#[cfg(target_os = "linux")]
#[test]
fn only_flatpaks_already_installed_error_counts_as_installed() {
let id = FLATPAK_ID;
let failed = |why: &str| format!("Flatpak couldn't install the bundle: Failed to install bundle {id}: {why}");
assert!(linux::already_installed(&failed(ALREADY_THERE), id));
// A bundle Flatpak can't use, or one for another app, keeps the installed copy.
assert!(!linux::already_installed(&failed("Can't change remote during bundle install"), id));
assert!(!linux::already_installed(&failed("Invalid checksum of length 0 expected 32"), id));
assert!(!linux::already_installed(&failed("ai.storyteller.photocraft already installed"), id));
}
#[cfg(target_os = "linux")]
#[test]
fn only_a_flatpak_before_1_16_2_needs_a_bundle_reinstalled_by_hand() {
assert!(linux::ignores_bundle_reinstall("Flatpak 1.14.6\n"));
assert!(linux::ignores_bundle_reinstall("Flatpak 1.16.1"));
assert!(!linux::ignores_bundle_reinstall("Flatpak 1.16.2"));
assert!(!linux::ignores_bundle_reinstall("Flatpak 1.18.4"));
// An unreadable version leaves the failed install as it is.
assert!(!linux::ignores_bundle_reinstall(""));
}
#[cfg(target_os = "linux")]
#[test]
fn adds_and_removes_only_its_own_menu_entry_and_icon() {
let base = App::by_id("wordcraft").unwrap();
let wordcraft = App { icon_png: Some(b"\x89PNG\r\n\x1a\nfake-png-bytes"), ..*base };
let app = &wordcraft;
let root = tempfile::tempdir().unwrap();
let data = root.path().join("share");
let desktop = data.join("applications/ai.storyteller.wordcraft.desktop");
let icon = data.join("icons/hicolor/128x128/apps/ai.storyteller.wordcraft.png");
let old = root.path().join("My Apps/wordcraft-0.2.0-linux-x86_64.AppImage");
let new = root.path().join("My Apps/wordcraft-0.3.0-linux-x86_64.AppImage");
std::fs::create_dir_all(new.parent().unwrap()).unwrap();
std::fs::write(&new, b"").unwrap();
// Fresh install: entry and icon written and recorded.
let files = linux::integrate_into(&data, app, &new, &[], None);
assert_eq!(files, [icon.clone(), desktop.clone()]);
let text = std::fs::read_to_string(&desktop).unwrap();
assert!(text.contains("Exec=\"") && text.contains("My Apps/wordcraft-0.3.0-linux-x86_64.AppImage\" %F\n"), "{text}");
assert!(text.contains("Icon=ai.storyteller.wordcraft\n"));
assert!(std::fs::read(&icon).unwrap().starts_with(b"\x89PNG"));
let rec = record("0.3.0", PackageKind::AppImage, new.clone(), files, false);
// An update rewrites both.
assert_eq!(linux::integrate_into(&data, app, &new, &[], Some(&rec)).len(), 2);
// Someone else's entry and icon are left alone (and not recorded).
std::fs::write(&desktop, "[Desktop Entry]\nExec=/usr/bin/wordcraft\n").unwrap();
std::fs::write(&icon, b"theirs").unwrap();
assert!(linux::integrate_into(&data, app, &new, &[], None).is_empty());
assert_eq!(std::fs::read(&icon).unwrap(), b"theirs");
// ...unless the entry runs a copy this install just replaced (the app wrote it itself).
std::fs::write(&desktop, format!("[Desktop Entry]\nExec=\"{}\" %F\n", old.display())).unwrap();
assert_eq!(linux::integrate_into(&data, app, &new, std::slice::from_ref(&old), None), std::slice::from_ref(&desktop));
assert!(desktop_entry_is_ours(&desktop));
// Uninstall: an icon the record doesn't list stays; the entry for the removed copy goes.
std::fs::remove_file(&new).unwrap();
linux::unintegrate_from(&data, app, &new, None);
assert!(!desktop.exists());
assert_eq!(std::fs::read(&icon).unwrap(), b"theirs");
// With the record, the launcher's icon goes too.
std::fs::remove_file(&icon).unwrap();
std::fs::write(&new, b"").unwrap();
let files = linux::integrate_into(&data, app, &new, &[], None);
let rec = record("0.3.0", PackageKind::AppImage, new.clone(), files, false);
std::fs::remove_file(&new).unwrap();
linux::unintegrate_from(&data, app, &new, Some(&rec));
assert!(!desktop.exists() && !icon.exists());
// Someone else's entry survives an uninstall.
std::fs::write(&desktop, "[Desktop Entry]\nExec=/usr/bin/wordcraft\n").unwrap();
linux::unintegrate_from(&data, app, &new, Some(&rec));
assert!(desktop.exists());
}
#[cfg(target_os = "linux")]
fn desktop_entry_is_ours(path: &Path) -> bool {
crate::desktop_entry::is_ours(&std::fs::read_to_string(path).unwrap())
}
#[cfg(target_os = "linux")]
#[test]
fn immutable_ostree_offers_no_deb_or_rpm() {
if !Path::new("/run/ostree-booted").exists() {
return;
}
let kinds = available_kinds();
assert!(!kinds.contains(&PackageKind::Rpm), "{kinds:?}");
assert!(!kinds.contains(&PackageKind::Deb), "{kinds:?}");
assert!(kinds.contains(&PackageKind::AppImage), "{kinds:?}");
}
#[test]
fn updates_keep_the_installed_format() {
let inst = |source| Installed { version: None, source, location: String::new(), package: None };
let app = crate::App::by_id("photocraft").unwrap();
let available = available_kinds();
if let Some(first) = available.first() {
assert_eq!(choose_kinds(app, None, None, &[]).first(), Some(first));
}
#[cfg(target_os = "macos")]
assert_eq!(choose_kinds(app, Some(&inst(InstallSource::MacApp)), None, &[]), [PackageKind::Dmg]);
#[cfg(windows)]
assert_eq!(choose_kinds(app, Some(&inst(InstallSource::Msi)), None, &[]), [PackageKind::Msi]);
#[cfg(target_os = "linux")]
assert_eq!(choose_kinds(app, Some(&inst(InstallSource::AppImage)), None, &[]), [PackageKind::AppImage]);
let _ = inst;
}
#[test]
fn only_the_release_arch_package_is_updated_by_the_launcher() {
let pacman = |name: &str| Installed { version: None, source: InstallSource::Pacman, location: String::new(), package: Some(name.into()) };
let app = crate::App::by_id("wordcraft").unwrap();
assert!(is_release_arch_package(app, &pacman("ai.storyteller.wordcraft")));
for aur in ["wordcraft", "wordcraft-bin", "wordcraft-git"] {
assert!(!is_release_arch_package(app, &pacman(aur)), "{aur}");
}
assert_eq!(managed_elsewhere_with(app, &pacman("ai.storyteller.wordcraft"), false, |_| None), None);
assert_eq!(managed_elsewhere_with(app, &pacman("wordcraft-bin"), true, |_| None), None);
}
#[test]
fn arch_packages_from_a_repository_are_updated_with_the_system() {
let app = crate::App::by_id("wordcraft").unwrap();
let hint = pacman_update_hint(app, "wordcraft-bin", Some("omarchy"));
assert!(hint.starts_with("WordCraft was installed from the omarchy repository or the AUR (wordcraft-bin)."));
assert!(hint.ends_with("or sudo pacman -Syu"));
let aur_hint = pacman_update_hint(app, "wordcraft-bin", None);
assert!(aur_hint.starts_with("WordCraft was installed from the AUR (wordcraft-bin)."));
let si = "Repository : omarchy\nName : wordcraft-bin\nVersion : 0.6.1-1\n";
assert_eq!(parse_pacman_repo(si).as_deref(), Some("omarchy"));
assert_eq!(parse_pacman_repo("Name : wordcraft-bin\n"), None);
assert_eq!(parse_pacman_repo("Repository :\n"), None);
}
#[test]
fn aur_packages_are_not_updated_as_arch_pkg() {
let pacman = |name: &str| Installed { version: None, source: InstallSource::Pacman, location: String::new(), package: Some(name.into()) };
let app = crate::App::by_id("filmcraft").unwrap();
let installed = pacman("filmcraft-bin");
assert!(managed_elsewhere_with(app, &installed, false, |_| None).is_some());
assert_eq!(choose_kinds_with(app, Some(&installed), None, &[], &[], false), Vec::<PackageKind>::new());
assert_eq!(choose_kinds_with(app, Some(&installed), None, &[PackageKind::ArchPkg], &[PackageKind::ArchPkg], true), Vec::<PackageKind>::new());
}
#[test]
fn release_arch_packages_are_updated_as_arch_pkg() {
let pacman = |name: &str| Installed { version: None, source: InstallSource::Pacman, location: String::new(), package: Some(name.into()) };
let app = crate::App::by_id("filmcraft").unwrap();
let installed = pacman("ai.storyteller.filmcraft");
assert_eq!(managed_elsewhere_with(app, &installed, false, |_| None), None);
assert_eq!(choose_kinds_with(app, Some(&installed), None, &[], &[], true), vec![PackageKind::ArchPkg]);
assert_eq!(choose_kinds_with(app, Some(&installed), None, &[], &[PackageKind::ArchPkg], false), vec![PackageKind::ArchPkg]);
}
#[test]
fn unavailable_arch_package_not_selected_for_new_install() {
let app = crate::App::by_id("filmcraft").unwrap();
assert_eq!(choose_kinds_with(app, None, None, &[PackageKind::ArchPkg], &[], true), Vec::<PackageKind>::new());
assert_eq!(choose_kinds_with(app, None, None, &[PackageKind::ArchPkg], &[], false), Vec::<PackageKind>::new());
assert_eq!(choose_kinds_with(app, None, None, &[PackageKind::ArchPkg], &[PackageKind::ArchPkg], false), vec![PackageKind::ArchPkg]);
}
#[test]
fn install_refuses_to_downgrade_when_target_release_is_older() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let http = Http::new(crate::TEST_AGENT).unwrap();
let phase = &mut |_| ();
let locations = InstallLocations::default();
let app = App::by_id("photocraft").unwrap();
let release = Release { version: version::parse("0.4.0"), tag: "v0.4.0".into(), ..Release::default() };
let asset = Asset { name: "photocraft-0.4.0-linux-x86_64.AppImage".into(), ..Asset::default() };
let installed = Installed {
version: version::parse("0.5.0"),
source: InstallSource::Launcher,
location: dir.path().join("PhotoCraft.AppImage").display().to_string(),
package: None,
};
let stop = Stop::default();
let mut job = Job { http: &http, paths: &paths, stop: &stop, phase, install_locations: &locations, replace: false };
let result = install_at(app, &release, &asset, PackageKind::AppImage, Some(&installed), None, &mut job, None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("installed version 0.5.0 is newer"));
}
/// An asset whose file name, link tag or link file name isn't the release's is refused before
/// the download, and so is one in another app's releases or with a foreign `SHA256SUMS.txt`.
#[test]
fn install_rejects_an_artifact_whose_version_is_not_the_advertised_one() {
// The release advertises 99.0.0, but the selected artifact is an older official build
// (filename and URL tag say 0.4.0). The guard must refuse before any download.
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let http = Http::new(crate::TEST_AGENT).unwrap();
let phase = &mut |_| ();
let locations = InstallLocations::default();
let app = App::by_id("photocraft").unwrap();
let release = Release { version: version::parse("99.0.0"), tag: "v99.0.0".into(), ..Release::default() };
let asset = Asset {
name: "photocraft-0.4.0-linux-x86_64.AppImage".into(),
url: "https://github.com/storytold/photocraft/releases/download/v0.4.0/photocraft-0.4.0-linux-x86_64.AppImage".into(),
..Asset::default()
};
let stop = Stop::default();
let mut job = Job { http: &http, paths: &paths, stop: &stop, phase, install_locations: &locations, replace: false };
let result = install_at(app, &release, &asset, PackageKind::AppImage, None, None, &mut job, None);
assert!(result.is_err(), "mismatched artifact version must be rejected");
assert!(result.unwrap_err().contains("does not match the advertised version"));
// A matching artifact passes the guard (it fails later for unrelated reasons, not the bind).
let good = Asset {
name: "photocraft-99.0.0-linux-x86_64.AppImage".into(),
url: "https://github.com/storytold/photocraft/releases/download/v99.0.0/photocraft-99.0.0-linux-x86_64.AppImage".into(),
..Asset::default()
};
assert!(bind_artifact_version(app, &release, &good).is_ok());
// URL filename mismatch is rejected (CWE-494).
let bad_url_name = Asset {
name: "photocraft-99.0.0-linux-x86_64.AppImage".into(),
url: "https://github.com/storytold/photocraft/releases/download/v99.0.0/other.AppImage".into(),
..Asset::default()
};
assert!(bind_artifact_version(app, &release, &bad_url_name).is_err());
// URL tag mismatch is rejected.
let bad_tag = Asset {
name: "photocraft-99.0.0-linux-x86_64.AppImage".into(),
url: "https://github.com/storytold/photocraft/releases/download/unparsed_tag/photocraft-99.0.0-linux-x86_64.AppImage".into(),
..Asset::default()
};
assert!(bind_artifact_version(app, &release, &bad_tag).is_err());
// storytold/craft-launcher#764: another app's release with the right tag and name passes
// the bind (it reads only the path after `/releases/download/`), but the check before the
// download refuses it.
let other_repo = Asset {
name: "photocraft-99.0.0-linux-x86_64.AppImage".into(),
url: "https://github.com/storytold/vectorcraft/releases/download/v99.0.0/photocraft-99.0.0-linux-x86_64.AppImage".into(),
..Asset::default()
};
assert!(bind_artifact_version(app, &release, &other_repo).is_ok());
let err = install_at(app, &release, &other_repo, PackageKind::AppImage, None, None, &mut job, None).unwrap_err();
assert!(err.contains("isn't the one its release publishes"), "{err}");
assert!(check_download_link(app, &release, &good).is_ok());
// The checksums the download is verified against must come from the release too.
let mut foreign_sums = release.clone();
foreign_sums.assets.push(Asset {
name: "SHA256SUMS.txt".into(),
url: "https://github.com/storytold/vectorcraft/releases/download/v99.0.0/SHA256SUMS.txt".into(),
..Asset::default()
});
assert!(check_download_link(app, &foreign_sums, &good).is_err());
// A release the feed reads from a renamed repository records it, and its links are
// checked against that one; the same links are refused for a release that doesn't.
let renamed =
release::release_from_sums("storytold/photocraft-next", "v0.5.0", &[("a".repeat(64), "photocraft-0.5.0-linux-x86_64.AppImage".into())]);
assert!(check_download_link(app, &renamed, &renamed.assets[0]).is_ok());
let unrecorded = Release { repo: String::new(), ..renamed.clone() };
assert!(check_download_link(app, &unrecorded, &unrecorded.assets[0]).is_err());
// A `+` written plainly, as an older launcher's feed and saved releases have it, passes.
let plus = release::release_from_sums(app.repo, "v1.0.0+b.1", &[("a".repeat(64), "photocraft-1.0.0+b.1-linux-x86_64.AppImage".into())]);
let mut plain = plus.clone();
for a in &mut plain.assets {
a.url = a.url.replace("%2B", "+");
}
assert!(check_download_link(app, &plain, &plain.assets[0]).is_ok());
}
/// A release whose tag has a `+` (semver build metadata) has `%2B` in its links. Both checks
/// before the download accept them, so `install_at` gets as far as the download, which the
/// cancelled job ends before anything is fetched. A link with another escape is still refused.
#[test]
fn install_accepts_a_plus_release_up_to_the_download() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let http = Http::new(crate::TEST_AGENT).unwrap();
let phase = &mut |_| ();
let locations = InstallLocations::default();
let app = App::by_id("photocraft").unwrap();
let name = "photocraft-1.0.0+build.1-linux-x86_64.AppImage";
let release = release::release_from_sums(app.repo, "v1.0.0+build.1", &[("a".repeat(64), name.to_string())]);
let asset = release.assets[0].clone();
assert!(asset.url.ends_with("/download/v1.0.0%2Bbuild.1/photocraft-1.0.0%2Bbuild.1-linux-x86_64.AppImage"), "{}", asset.url);
assert_eq!(bind_artifact_version(app, &release, &asset), Ok(()));
let stop = Stop::default();
stop.cancel();
let mut job = Job { http: &http, paths: &paths, stop: &stop, phase, install_locations: &locations, replace: false };
let result = install_at(app, &release, &asset, PackageKind::AppImage, None, None, &mut job, None);
assert_eq!(result.unwrap_err(), crate::protocol::CANCELLED);
let other_escape = Asset { url: asset.url.replace("%2B", "%2b"), ..asset };
assert!(bind_artifact_version(app, &release, &other_escape).is_err());
}
/// craft-launcher#764: `install_at` refuses, before downloading, an asset whose link is the
/// right path on another host, and a release whose `SHA256SUMS.txt` (what the download is
/// checked against) is linked elsewhere. The stop is cancelled, so if the check stopped working
/// the install would end at the download with `CANCELLED` instead of fetching anything.
#[test]
fn install_refuses_links_off_the_releases_own_before_downloading() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let http = Http::new(crate::TEST_AGENT).unwrap();
let locations = InstallLocations::default();
let app = App::by_id("photocraft").unwrap();
let name = "photocraft-0.5.0-linux-x86_64.AppImage";
let own = release::release_from_sums(app.repo, "v0.5.0", &[("a".repeat(64), name.to_string())]);
let refuse = |release: &Release, asset: &Asset| {
let stop = Stop::default();
stop.cancel();
let phase = &mut |_| ();
let mut job = Job { http: &http, paths: &paths, stop: &stop, phase, install_locations: &locations, replace: false };
let result = install_at(app, release, asset, PackageKind::AppImage, None, None, &mut job, None);
assert!(result.as_ref().is_err_and(|e| e.contains("isn't the one its release publishes")), "{}: {result:?}", asset.url);
};
for url in [
format!("https://evil.example/storytold/photocraft/releases/download/v0.5.0/{name}"),
format!("http://github.com/storytold/photocraft/releases/download/v0.5.0/{name}"),
format!("https://github.com.evil.example/storytold/photocraft/releases/download/v0.5.0/{name}"),
] {
refuse(&own, &Asset { url, ..own.assets[0].clone() });
}
let mut sums_elsewhere = own.clone();
let sums = sums_elsewhere.assets.iter_mut().find(|a| a.name == "SHA256SUMS.txt").unwrap();
sums.url = "https://evil.example/storytold/photocraft/releases/download/v0.5.0/SHA256SUMS.txt".into();
refuse(&sums_elsewhere, &sums_elsewhere.assets[0]);
}
/// A download that fails its checksum (twice, as a resumed file gets one fresh try) replaces
/// nothing: the installed copy keeps its bytes, the install never starts, the record isn't
/// committed, and the bad file isn't kept for the next try.
#[test]
fn a_download_that_fails_its_checksum_replaces_nothing() {
use crate::http::tests::{Reply, body, serve};
let app = App::by_id("photocraft").unwrap();
let (body, sha) = body();
let (url, requests) = serve(body.clone(), vec![Reply::Corrupt, Reply::Corrupt]);
let name = "photocraft-0.5.0-linux-x86_64.AppImage";
let asset = Asset { name: name.into(), size: body.len() as u64, url, sha256: Some(sha) };
let release = Release { version: version::parse("0.5.0"), tag: "v0.5.0".into(), assets: vec![asset.clone()], ..Release::default() };
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let http = Http::new(crate::TEST_AGENT).unwrap();
let old = dir.path().join("Apps").join("PhotoCraft.AppImage");
std::fs::create_dir_all(old.parent().unwrap()).unwrap();
std::fs::write(&old, b"the installed 0.4.0").unwrap();
let installed =
Installed { version: version::parse("0.4.0"), source: InstallSource::Launcher, location: old.display().to_string(), package: None };
let previous = record("0.4.0", PackageKind::AppImage, old.clone(), vec![], false);
let mut phases = Vec::new();
let mut phase = |p: JobPhase| phases.push(p);
let locations = InstallLocations::default();
let stop = Stop::default();
let mut job = Job { http: &http, paths: &paths, stop: &stop, phase: &mut phase, install_locations: &locations, replace: false };
let mut committed = false;
let mut commit = |_: &Record| {
committed = true;
Ok(())
};
let result = install_at(app, &release, &asset, PackageKind::AppImage, Some(&installed), Some(&previous), &mut job, Some(&mut commit));
assert!(result.as_ref().is_err_and(|e| e.contains("checksum")), "{result:?}");
assert_eq!(std::fs::read(&old).unwrap(), b"the installed 0.4.0");
assert!(!phases.contains(&JobPhase::Installing), "{phases:?}");
assert!(!committed);
assert!(!download_path(&paths, &asset).exists());
assert_eq!(requests.lock().unwrap().len(), 2);
}
/// A running installed copy isn't replaced; the stop is cancelled in case the check stops
/// working. Windows finds a process by its file name, so the running test binary stands in for
/// the copy. Elsewhere `pgrep -f` matches the command line, so a probe runs with the copy's path.
#[test]
fn install_refuses_while_the_installed_copy_is_running() {
let dir = tempfile::tempdir().unwrap();
let paths = Paths::under(dir.path());
let http = Http::new(crate::TEST_AGENT).unwrap();
let phase = &mut |_| ();
let locations = InstallLocations::default();
let app = App::by_id("photocraft").unwrap();
let name = "photocraft-0.5.0-linux-x86_64.AppImage";
let release = release::release_from_sums(app.repo, "v0.5.0", &[("a".repeat(64), name.to_string())]);
#[cfg(windows)]
let location = std::env::current_exe().unwrap().display().to_string();
#[cfg(unix)]
let location = dir.path().join("PhotoCraft.AppImage").display().to_string();
#[cfg(unix)]
let _probe = crate::process::tests::RunningProbe::start(&location);
assert!(process::is_running(Path::new(&location)), "{location} should be seen as running");
let installed = Installed { version: version::parse("0.4.0"), source: InstallSource::Launcher, location, package: None };
let stop = Stop::default();
stop.cancel();
let mut job = Job { http: &http, paths: &paths, stop: &stop, phase, install_locations: &locations, replace: false };
let result = install_at(app, &release, &release.assets[0], PackageKind::AppImage, Some(&installed), None, &mut job, None);
assert!(result.as_ref().is_err_and(|e| e.contains("is running")), "{result:?}");
}
#[cfg(target_os = "linux")]
#[test]
fn package_kinds_order_matches_launcher_format() {
use super::linux::order_available_kinds;
// Flatpak launcher defaults to Flatpak if available
let kinds = order_available_kinds(true, false, true, true, false, false);
assert_eq!(kinds.first(), Some(&PackageKind::Flatpak));
assert!(kinds.contains(&PackageKind::Deb));
assert!(kinds.contains(&PackageKind::AppImage));
// AppImage launcher defaults to AppImage
let kinds = order_available_kinds(false, true, true, true, false, false);
assert_eq!(kinds.first(), Some(&PackageKind::AppImage));
assert!(kinds.contains(&PackageKind::Deb));
assert!(kinds.contains(&PackageKind::Flatpak));
// Base Linux launcher defaults to native package format (deb, rpm, or arch) if available
let kinds = order_available_kinds(false, false, true, true, false, false);
assert_eq!(kinds.first(), Some(&PackageKind::Deb));
let kinds = order_available_kinds(false, false, true, false, true, false);
assert_eq!(kinds.first(), Some(&PackageKind::Rpm));
let kinds = order_available_kinds(false, false, true, false, false, true);
assert_eq!(kinds.first(), Some(&PackageKind::ArchPkg));
// Base Linux launcher without root/distro packaging falls back to Flatpak then AppImage
let kinds = order_available_kinds(false, false, true, false, false, false);
assert_eq!(kinds.first(), Some(&PackageKind::Flatpak));
assert_eq!(kinds.get(1), Some(&PackageKind::AppImage));
// The tarball runs inside the sandbox, so it is only offered there: with a usable host
// Flatpak it stays an alternative, without one it is the default (an AppImage cannot
// mount in a sandbox, and a distro package needs root).
let kinds = order_available_kinds(true, false, true, false, false, false);
assert_eq!(kinds.first(), Some(&PackageKind::Flatpak));
assert_eq!(kinds.get(1), Some(&PackageKind::TarGz));
let kinds = order_available_kinds(true, false, false, false, false, false);
assert_eq!(kinds, [PackageKind::TarGz, PackageKind::AppImage]);
for (appimage, flatpak) in [(true, true), (false, false), (false, true)] {
assert!(!order_available_kinds(false, appimage, flatpak, true, true, false).contains(&PackageKind::TarGz));
}
}
#[cfg(target_os = "macos")]
mod macos_checks {
use crate::process::HelperFailure;
use crate::{SUITE, version};
use super::macos::{CheckTurn, Expect, Signature, TEAM_ID, signed_by};
use super::*;
fn photocraft() -> &'static App {
SUITE.iter().find(|a| a.id == "photocraft").unwrap()
}
/// [`macos::install_dmg`] for a job nobody cancels.
fn install_dmg(
app: &App,
dmg: &Path,
existing: Option<&Installed>,
destination: Option<&Path>,
replace: bool,
paths: &Paths,
expect: &Expect<'_>,
) -> Result<PathBuf, String> {
let stop = Stop::default();
macos::install_dmg(app, dmg, existing, destination, replace, paths, expect, CheckTurn { stop: &stop, phase: &mut |_| {} })
}
/// [`macos::signature`] for a job nobody cancels.
fn signature(bundle: &Path, team: &str, run: impl FnOnce(&str, &[&str], std::time::Duration) -> Result<String, HelperFailure>) -> Signature {
let stop = Stop::default();
macos::signature(bundle, team, run, CheckTurn { stop: &stop, phase: &mut |_| {} })
}
/// [`macos::check`] for a job nobody cancels.
fn check(
bundle: &Path,
found: &Installed,
expect: &Expect<'_>,
run: impl FnOnce(&str, &[&str], std::time::Duration) -> Result<String, HelperFailure>,
) -> Result<(), String> {
let stop = Stop::default();
macos::check(bundle, found, expect, run, CheckTurn { stop: &stop, phase: &mut |_| {} })
}
/// [`macos::place_checked`] for a job nobody cancels.
fn place_checked(
app: &App,
staged: &Path,
dest: &Path,
expect: &Expect<'_>,
run: impl FnOnce(&str, &[&str], std::time::Duration) -> Result<String, HelperFailure>,
running: impl FnOnce(&Path) -> Result<bool, String>,
) -> Result<PathBuf, String> {
let stop = Stop::default();
macos::place_checked(app, staged, dest, expect, run, running, CheckTurn { stop: &stop, phase: &mut |_| {} })
}
/// A minimal `PhotoCraft.app` at `dir`, reporting `version`.
fn bundle(dir: &Path, version: &str) -> PathBuf {
let app = dir.join("PhotoCraft.app");
let contents = app.join("Contents");
std::fs::create_dir_all(contents.join("MacOS")).unwrap();
std::fs::write(
contents.join("Info.plist"),
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"><dict>\
<key>CFBundleIdentifier</key><string>ai.storyteller.photocraft</string>\
<key>CFBundleExecutable</key><string>PhotoCraft</string>\
<key>CFBundleShortVersionString</key><string>{version}</string></dict></plist>"
),
)
.unwrap();
std::fs::write(contents.join("MacOS/PhotoCraft"), version).unwrap();
app
}
/// A release-style disk image holding a `PhotoCraft.app` that reports `version`. The
/// disk-image helper turns requests away while it is busy (`attach` retries for the same
/// reason), so `hdiutil create` is tried a few times, and what it said is kept for the
/// failure message.
fn dmg(dir: &Path, version: &str) -> PathBuf {
let src = dir.join(format!("src-{version}"));
bundle(&src, version);
let dmg = dir.join(format!("photocraft-{version}-macos-universal.dmg"));
let _one_at_a_time = super::macos::DISK_IMAGES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut said = String::new();
for attempt in 0..5u64 {
if attempt > 0 {
std::thread::sleep(std::time::Duration::from_millis(400 * attempt));
let _ = std::fs::remove_file(&dmg);
}
let output = std::process::Command::new("hdiutil")
.args(["create", "-quiet", "-format", "UDZO", "-volname", "PhotoCraft", "-srcfolder"])
.arg(&src)
.arg(&dmg)
.output()
.unwrap();
if output.status.success() {
return dmg;
}
said = String::from_utf8_lossy(&output.stderr).trim().to_string();
}
panic!("hdiutil create failed after 5 tries: {said}");
}
#[test]
fn custom_bundles_are_detected_and_updates_stay_in_their_actual_location() {
let dir = tempfile::tempdir().unwrap();
let apps = dir.path().join("Craft Apps");
std::fs::create_dir(&apps).unwrap();
let paths = Paths::under(dir.path());
let image = dmg(dir.path(), "0.6.0");
let expect = Expect { team: None, version: None, quit_first: false };
let placed = install_dmg(photocraft(), &image, None, Some(&apps), false, &paths, &expect).unwrap();
assert_eq!(placed, apps.join("PhotoCraft.app"));
let mut manifest = crate::manifest::Manifest::default();
manifest.apps.insert("photocraft".into(), record("0.6.0", PackageKind::Dmg, placed.clone(), vec![], false));
let existing = crate::detect::detect(photocraft(), &manifest, &crate::detect::Snapshot::default()).unwrap();
assert_eq!(existing.source, InstallSource::MacApp);
assert_eq!(existing.location, placed.display().to_string());
assert_eq!(existing.version.unwrap().to_string(), "0.6.0");
let existing = crate::detect::detect(photocraft(), &manifest, &crate::detect::Snapshot::default()).unwrap();
let new_default = dir.path().join("Other Apps");
std::fs::create_dir(&new_default).unwrap();
let update = dmg(dir.path(), "0.7.0");
assert_eq!(install_dmg(photocraft(), &update, Some(&existing), Some(&new_default), false, &paths, &expect).unwrap(), placed);
assert!(!new_default.join("PhotoCraft.app").exists());
let detected = crate::detect::detect(photocraft(), &manifest, &crate::detect::Snapshot::default()).unwrap();
assert_eq!(detected.version.unwrap().to_string(), "0.7.0");
std::fs::write(placed.join("Contents/Info.plist"), "invalid").unwrap();
assert_ne!(
crate::detect::detect(photocraft(), &manifest, &crate::detect::Snapshot::default()).map(|i| i.source),
Some(InstallSource::Launcher)
);
}
#[test]
fn an_unrelated_bundle_at_the_selected_destination_is_preserved() {
let dir = tempfile::tempdir().unwrap();
let placed = bundle(dir.path(), "0.5.0");
let plist = placed.join("Contents/Info.plist");
let unrelated = std::fs::read_to_string(&plist).unwrap().replace("ai.storyteller.photocraft", "com.example.other");
std::fs::write(&plist, &unrelated).unwrap();
let image = dmg(dir.path(), "0.6.0");
let expect = Expect { team: None, version: None, quit_first: false };
let result = install_dmg(photocraft(), &image, None, Some(dir.path()), false, &Paths::under(dir.path()), &expect);
assert!(result.unwrap_err().contains("already exists"));
assert_eq!(std::fs::read_to_string(plist).unwrap(), unrelated);
assert_eq!(std::fs::read(placed.join("Contents/MacOS/PhotoCraft")).unwrap(), b"0.5.0");
}
#[test]
fn only_the_teams_signature_passes() {
let dir = tempfile::tempdir().unwrap();
let app = bundle(dir.path(), "0.6.0");
assert!(!signed_by(&app, TEAM_ID), "an unsigned bundle passed");
// A valid signature, but not from the team.
let ok = std::process::Command::new("codesign").args(["--force", "--sign", "-"]).arg(&app).status().unwrap().success();
assert!(ok, "codesign failed");
assert!(!signed_by(&app, TEAM_ID), "an ad-hoc signature passed");
// A team id can't smuggle anything into the requirement.
assert!(!signed_by(&app, "X\" or anchor apple generic or \"Y"));
assert!(!signed_by(&app, ""));
}
/// `codesign`'s own exit codes for the two kinds of refusal, which [`signature`] reads as
/// answers and not as a check that couldn't finish.
#[test]
fn real_refusals_are_answers_from_codesign() {
let dir = tempfile::tempdir().unwrap();
let app = bundle(dir.path(), "0.6.0");
let unsigned = signature(&app, TEAM_ID, crate::process::run_detailed);
assert!(matches!(&unsigned, Signature::Failed(reason) if reason.contains("not signed")), "an unsigned bundle: {unsigned:?}");
let ok = std::process::Command::new("codesign").args(["--force", "--sign", "-"]).arg(&app).status().unwrap().success();
assert!(ok, "codesign failed");
assert_eq!(signature(&app, TEAM_ID, crate::process::run_detailed), Signature::Wrong, "an ad-hoc signature");
}
/// `codesign` exits 1 for a bundle it can't read too, which says nothing about who signed it.
#[test]
fn a_bundle_codesign_cannot_read_is_not_called_unsigned() {
let dir = tempfile::tempdir().unwrap();
let gone = dir.path().join("Gone.app");
let expect = Expect { team: Some(TEAM_ID), version: None, quit_first: false };
let refusal = check(&gone, &found_at(&gone), &expect, crate::process::run_detailed).unwrap_err();
assert!(refusal.contains("No such file"), "{refusal}");
assert!(!refusal.contains("isn't signed"), "{refusal}");
}
fn found_at(bundle: &Path) -> Installed {
Installed { version: None, source: InstallSource::MacApp, location: bundle.display().to_string(), package: None }
}
/// What [`check`] says when `codesign` fails with `failure`.
fn refusal_for(failure: HelperFailure) -> Result<(), String> {
let bundle = Path::new("/private/owned/PhotoCraft.app");
let expect = Expect { team: Some(TEAM_ID), version: None, quit_first: false };
check(bundle, &found_at(bundle), &expect, |_, _, _| Err(failure))
}
#[test]
fn a_check_that_timed_out_is_not_reported_as_a_bad_signature() {
let result = refusal_for(HelperFailure::Unfinished("codesign didn't finish within 600 seconds".into()));
assert_eq!(
result,
Err("the app's signature couldn't be checked (codesign didn't finish within 600 seconds), so it wasn't installed".into())
);
}
/// Exit 2 is a bad command line, which says nothing about the bundle.
#[test]
fn a_check_with_a_bad_command_line_is_not_reported_as_a_bad_signature() {
let result = refusal_for(HelperFailure::Exited { code: 2, reason: "bad flag".into() });
assert_eq!(result, Err("the app's signature couldn't be checked (bad flag), so it wasn't installed".into()));
}
#[test]
fn a_signature_that_fails_the_requirement_is_refused_as_not_the_developers() {
let result = refusal_for(HelperFailure::Exited { code: 3, reason: "no".into() });
assert_eq!(result, Err("the app isn't signed by its developer, so it wasn't installed".into()));
}
#[test]
fn a_failed_verification_is_refused_with_codesigns_reason() {
let result = refusal_for(HelperFailure::Exited { code: 1, reason: "a sealed resource is missing".into() });
assert_eq!(result, Err("the app failed its signature check (a sealed resource is missing), so it wasn't installed".into()));
}
#[test]
fn the_signature_check_gets_a_slow_macs_time() {
let mut limit = None;
let found = signature(Path::new("/private/owned/PhotoCraft.app"), TEAM_ID, |program, args, timeout| {
assert_eq!(program, "/usr/bin/codesign");
assert_eq!(args[..3], ["--verify", "--deep", "-R"]);
limit = Some(timeout);
Ok(String::new())
});
assert_eq!(found, Signature::Valid);
assert!(limit.is_some_and(|limit| limit >= std::time::Duration::from_secs(600)), "{limit:?}");
}
/// Runs `behind` while a signature check of another app holds the turn, and frees the turn
/// when `behind` has returned, or when `release` is called.
fn behind_a_running_check<R>(behind: impl FnOnce(&dyn Fn()) -> R) -> R {
let (started, has_started) = std::sync::mpsc::channel();
let (released, wait_for_release) = std::sync::mpsc::channel::<()>();
std::thread::scope(|scope| {
scope.spawn(move || {
signature(Path::new("/private/owned/Other.app"), TEAM_ID, |_, _, _| {
started.send(()).unwrap();
let _ = wait_for_release.recv();
Ok(String::new())
})
});
has_started.recv().unwrap();
let release = move || {
let _ = released.send(());
};
let result = behind(&release);
release();
result
})
}
#[test]
fn a_second_signature_check_waits_its_turn_and_says_so() {
behind_a_running_check(|release| {
let (phases, phase) = std::sync::mpsc::channel();
let ran = std::sync::atomic::AtomicBool::new(false);
std::thread::scope(|scope| {
let second = scope.spawn(|| {
let stop = Stop::default();
let mut report = move |p| phases.send(p).unwrap();
let run = |_: &str, _: &[&str], _: std::time::Duration| {
ran.store(true, std::sync::atomic::Ordering::SeqCst);
Ok(String::new())
};
macos::signature(Path::new("/private/owned/PhotoCraft.app"), TEAM_ID, run, CheckTurn { stop: &stop, phase: &mut report })
});
assert_eq!(phase.recv().unwrap(), JobPhase::Waiting);
assert!(!ran.load(std::sync::atomic::Ordering::SeqCst), "the second check started during the first");
release();
assert_eq!(phase.recv().unwrap(), JobPhase::Installing);
assert_eq!(second.join().unwrap(), Signature::Valid);
});
});
}
#[test]
fn a_job_canceled_while_it_waits_for_the_signature_check_stops_there() {
let refused = behind_a_running_check(|_| {
let bundle = Path::new("/private/owned/PhotoCraft.app");
let expect = Expect { team: Some(TEAM_ID), version: None, quit_first: false };
let stop = Stop::default();
let mut cancel_when_waiting = |p| {
if p == JobPhase::Waiting {
stop.cancel();
}
};
let wait = CheckTurn { stop: &stop, phase: &mut cancel_when_waiting };
macos::check(bundle, &found_at(bundle), &expect, |_, _, _| Err(HelperFailure::Unfinished("codesign ran".into())), wait)
});
assert_eq!(refused, Err(CANCELLED.into()));
}
/// A copy waiting to replace the installed 0.5.0, and the installed copy.
fn staged_and_installed(dir: &Path) -> (PathBuf, PathBuf) {
(bundle(&dir.join("staged"), "0.6.0"), bundle(&dir.join("Applications"), "0.5.0"))
}
fn version_at(app: &Path) -> String {
std::fs::read_to_string(app.join("Contents/MacOS/PhotoCraft")).unwrap()
}
/// The stand-in for `codesign` opens the app, so the app is running only once the check
/// has started.
#[test]
fn an_app_opened_during_the_signature_check_is_not_replaced() {
let dir = tempfile::tempdir().unwrap();
let (staged, installed) = staged_and_installed(dir.path());
let expect = Expect { team: Some(TEAM_ID), version: None, quit_first: true };
let opened = std::cell::Cell::new(false);
let run = |_: &str, _: &[&str], _: std::time::Duration| {
opened.set(true);
Ok(String::new())
};
let result = place_checked(photocraft(), &staged, &installed, &expect, run, |_| Ok(opened.get()));
assert_eq!(result, Err("PhotoCraft is running. Quit it, then try again.".into()));
assert_eq!(version_at(&installed), "0.5.0");
}
#[test]
fn an_app_whose_state_could_not_be_checked_is_not_replaced() {
let dir = tempfile::tempdir().unwrap();
let (staged, installed) = staged_and_installed(dir.path());
let expect = Expect { team: None, version: None, quit_first: true };
let result = place_checked(photocraft(), &staged, &installed, &expect, |_, _, _| Ok(String::new()), |_| Err("too slow".into()));
assert_eq!(result, Err("couldn't check whether PhotoCraft is running (too slow), so nothing was changed".into()));
assert_eq!(version_at(&installed), "0.5.0");
}
#[test]
fn a_closed_app_is_replaced() {
let dir = tempfile::tempdir().unwrap();
let (staged, installed) = staged_and_installed(dir.path());
let expect = Expect { team: None, version: None, quit_first: true };
place_checked(photocraft(), &staged, &installed, &expect, |_, _, _| Ok(String::new()), |_| Ok(false)).unwrap();
assert_eq!(version_at(&installed), "0.6.0");
}
#[test]
fn an_app_replacing_itself_is_replaced_while_it_runs() {
let dir = tempfile::tempdir().unwrap();
let (staged, installed) = staged_and_installed(dir.path());
let expect = Expect { team: None, version: None, quit_first: false };
place_checked(photocraft(), &staged, &installed, &expect, |_, _, _| Ok(String::new()), |_| Ok(true)).unwrap();
assert_eq!(version_at(&installed), "0.6.0");
}
#[test]
fn only_a_self_update_replaces_an_app_that_is_running() {
let release = Release { tag: "v0.6.0".into(), version: version::parse("0.6.0"), ..Release::default() };
assert!(Expect::release(&release).quit_first);
assert!(!Expect::self_update(&release).quit_first);
}
#[test]
fn the_bundle_must_be_the_releases_version() {
let dir = tempfile::tempdir().unwrap();
let apps = dir.path().join("Applications");
let current = bundle(&apps, "0.5.0");
let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None };
let paths = Paths::under(dir.path());
let image = dmg(dir.path(), "0.6.0");
for (release, ok) in [("0.6.0", true), ("0.7.0", false), ("0.6.0-rc.1", true), ("0.5.0", false)] {
std::fs::write(current.join("Contents/MacOS/PhotoCraft"), "0.5.0").unwrap();
let want = version::parse(release).unwrap();
let expect = Expect { team: None, version: Some(&want), quit_first: false };
let result = install_dmg(photocraft(), &image, Some(&existing), None, false, &paths, &expect);
assert_eq!(result.is_ok(), ok, "release {release}: {result:?}");
let now = std::fs::read_to_string(current.join("Contents/MacOS/PhotoCraft")).unwrap();
assert_eq!(now, if ok { "0.6.0" } else { "0.5.0" }, "release {release}");
if !ok {
assert_eq!(result, Err(format!("the app inside isn't version {release}, so it wasn't installed")));
}
assert_eq!(
std::fs::read_dir(&apps).unwrap().map(|e| e.unwrap().file_name()).collect::<Vec<_>>(),
[std::ffi::OsString::from("PhotoCraft.app")]
);
}
// A bundle that names a pre-release must name the release's.
let rc = dmg(dir.path(), "0.8.0-rc.2");
let want = version::parse("0.8.0-rc.1").unwrap();
let expect = Expect { team: None, version: Some(&want), quit_first: false };
assert!(install_dmg(photocraft(), &rc, Some(&existing), None, false, &paths, &expect).is_err());
}
#[test]
fn an_unsigned_update_leaves_the_installed_copy_alone() {
let dir = tempfile::tempdir().unwrap();
let apps = dir.path().join("Applications");
let current = bundle(&apps, "0.5.0");
let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None };
let image = dmg(dir.path(), "0.6.0");
let release = Release { tag: "v0.6.0".into(), version: version::parse("0.6.0"), ..Release::default() };
let result = install_dmg(photocraft(), &image, Some(&existing), None, false, &Paths::under(dir.path()), &Expect::release(&release));
let refusal = result.unwrap_err();
assert!(refusal.starts_with("the app failed its signature check (") && refusal.contains("not signed"), "{refusal}");
assert_eq!(std::fs::read_to_string(current.join("Contents/MacOS/PhotoCraft")).unwrap(), "0.5.0");
assert_eq!(
std::fs::read_dir(&apps).unwrap().map(|e| e.unwrap().file_name()).collect::<Vec<_>>(),
[std::ffi::OsString::from("PhotoCraft.app")]
);
}
/// Detaches the image at a mount point when dropped, so a failing test leaves none attached.
struct Attached(PathBuf);
impl Drop for Attached {
fn drop(&mut self) {
if self.0.exists() {
let _ = std::process::Command::new("hdiutil").args(["detach", "-force", "-quiet"]).arg(&self.0).status();
}
}
}
#[test]
fn an_install_that_ended_partway_does_not_block_the_next() {
let dir = tempfile::tempdir().unwrap();
let apps = dir.path().join("Applications");
let current = bundle(&apps, "0.5.0");
let existing = Installed { version: None, source: InstallSource::MacApp, location: current.display().to_string(), package: None };
let paths = Paths::under(dir.path());
let image = dmg(dir.path(), "0.6.0");
// What an install in a process that has ended left behind: its image still attached,
// and the copy it was making beside the app.
let mut ended = std::process::Command::new("/usr/bin/true").spawn().unwrap();
let pid = ended.id();
ended.wait().unwrap();
let mount = paths.cache.join(format!("mount-photocraft-{pid}-1"));
std::fs::create_dir_all(&mount).unwrap();
// The disk-image helper turns requests away while it is busy, as in `dmg`.
let attached = {
let _one_at_a_time = super::macos::DISK_IMAGES.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut said = String::new();
let mut attached = false;
for attempt in 0..5u64 {
if attempt > 0 {
std::thread::sleep(std::time::Duration::from_millis(400 * attempt));
}
let output = std::process::Command::new("hdiutil")
.args(["attach", "-nobrowse", "-readonly", "-noautoopen", "-noverify", "-mountpoint"])
.arg(&mount)
.arg(&image)
.output()
.unwrap();
if output.status.success() {
attached = true;
break;
}
said = String::from_utf8_lossy(&output.stderr).trim().to_string();
}
attached.then_some(()).ok_or(said)
};
let _attached = Attached(mount.clone());
assert_eq!(attached, Ok(()), "hdiutil attach failed after 5 tries");
let staging = apps.join(format!(".artcraft-launcher-staging-{pid}-Ab12Cd"));
bundle(&staging, "0.6.0");
std::fs::write(staging.join(".artcraft-launcher-staging"), "").unwrap();
let result =
install_dmg(photocraft(), &image, Some(&existing), None, false, &paths, &Expect { team: None, version: None, quit_first: false });
assert_eq!(result, Ok(current.clone()));
assert_eq!(std::fs::read_to_string(current.join("Contents/MacOS/PhotoCraft")).unwrap(), "0.6.0");
assert!(!mount.exists(), "the earlier install's image is still attached");
assert_eq!(
std::fs::read_dir(&apps).unwrap().map(|e| e.unwrap().file_name()).collect::<Vec<_>>(),
[std::ffi::OsString::from("PhotoCraft.app")]
);
let mounts = std::fs::read_dir(&paths.cache).unwrap().filter(|e| e.as_ref().unwrap().file_name().to_string_lossy().starts_with("mount-"));
assert_eq!(mounts.count(), 0);
}
#[test]
fn only_staging_folders_of_installs_that_ended_are_removed() {
let dir = tempfile::tempdir().unwrap();
let apps = dir.path().join("Applications");
let (ended, running, ours) = (4_000_001, 4_000_002, std::process::id());
let staging = |pid: u32, rest: &str| {
let folder = apps.join(format!(".artcraft-launcher-staging-{pid}-{rest}"));
bundle(&folder, "0.6.0");
std::fs::write(folder.join(".artcraft-launcher-staging"), "").unwrap();
folder
};
let left = staging(ended, "Ab12Cd");
let kept = [
staging(running, "Ef34Gh"),
staging(ours, "Ij56Kl"),
// Not the name or the contents of a staging folder.
staging(ended, "photo"),
staging(ended, "Ab12Cd7"),
staging(ended, "Ab-2Cd"),
// Without the marker, a folder with something in it may be the user's.
bundle(&apps.join(format!(".artcraft-launcher-staging-{ended}-Yz34Ab")), "0.6.0"),
// A backup may hold the only copy of the app.
bundle(&apps.join(".artcraft-launcher-backup-Mn78Op/old"), "0.5.0"),
bundle(&apps.join(format!(".artcraft-launcher-staging-{ended}")), "0.6.0"),
bundle(&apps.join(format!(".artcraft-launcher-staging-x{ended}-Uv12Wx")), "0.6.0"),
bundle(&apps, "0.5.0"),
];
// An install that stopped before it wrote the marker.
let empty = apps.join(format!(".artcraft-launcher-staging-{ended}-Zz99Yy"));
std::fs::create_dir(&empty).unwrap();
let file = apps.join(format!(".artcraft-launcher-staging-{ended}-file"));
std::fs::write(&file, "the user's").unwrap();
let elsewhere = bundle(&dir.path().join("Elsewhere"), "0.4.0");
let link = apps.join(format!(".artcraft-launcher-staging-{ended}-link"));
std::os::unix::fs::symlink(elsewhere.parent().unwrap(), &link).unwrap();
// Staging folders of launcher versions that didn't record the process.
let old = bundle(&apps.join(".artcraft-launcher-Qr90St"), "0.6.0");
let old_kept = [
bundle(&apps.join(".artcraft-launcher-Qr90St7"), "0.6.0"),
{
let two = bundle(&apps.join(".artcraft-launcher-St12Uv"), "0.6.0");
std::fs::write(apps.join(".artcraft-launcher-St12Uv/notes.txt"), "the user's").unwrap();
two
},
{
let other = apps.join(".artcraft-launcher-Wx34Yz/VectorCraft.app");
std::fs::create_dir_all(&other).unwrap();
other
},
];
let now = std::time::SystemTime::now();
super::macos::clear_staging(photocraft(), &apps, |pid| pid == ended, now);
assert!(!left.exists(), "the partial copy of an install that ended is still there");
assert!(!empty.exists(), "the empty staging folder of an install that ended is still there");
for path in kept.iter().chain([&file, &link, &old]).chain(&old_kept) {
assert!(path.exists(), "{} was removed", path.display());
}
assert_eq!(std::fs::read_to_string(elsewhere.join("Contents/MacOS/PhotoCraft")).unwrap(), "0.4.0");
// A day later, with every process still in the same state.
let day = std::time::Duration::from_secs(24 * 60 * 60);
super::macos::clear_staging(photocraft(), &apps, |pid| pid == ended, now + day + std::time::Duration::from_secs(60));
assert!(!old.exists(), "the partial copy of an earlier launcher version's install is still there");
for path in kept.iter().chain([&file, &link]).chain(&old_kept) {
assert!(path.exists(), "{} was removed", path.display());
}
}
#[test]
fn only_mount_points_of_installs_that_ended_are_cleared() {
let dir = tempfile::tempdir().unwrap();
let cache = dir.path();
let (ended, running, ours) = (4_000_001, 4_000_002, std::process::id());
let folder = |name: String| {
let path = cache.join(name);
std::fs::create_dir(&path).unwrap();
path
};
let left = folder(format!("mount-photocraft-{ended}-1"));
let busy = folder(format!("mount-photocraft-{ended}-2"));
let own_left = folder(format!("mount-photocraft-{ours}-3"));
let own_in_use = folder(format!("mount-photocraft-{ours}-4"));
let _in_use = super::macos::InUse::new(&own_in_use);
let kept = [
folder(format!("mount-photocraft-{running}-5")),
folder(format!("mount-vectorcraft-{ended}-6")),
folder(format!("mount-photocraft-{ended}-x7")),
folder(format!("mount-photocraft-{ended}")),
folder("downloads".into()),
];
let asked = std::cell::RefCell::new(Vec::new());
super::macos::clear_mounts(
photocraft(),
cache,
|pid| pid == ended,
|mount| {
asked.borrow_mut().push(mount.to_path_buf());
if mount == busy { Err("Resource busy".into()) } else { Ok(()) }
},
);
let mut asked = asked.into_inner();
asked.sort();
let mut cleared = vec![left.clone(), busy.clone(), own_left.clone()];
cleared.sort();
assert_eq!(asked, cleared);
assert!(!left.exists() && !own_left.exists());
for path in kept.iter().chain([&busy, &own_in_use]) {
assert!(path.exists(), "{} was removed", path.display());
}
}
#[test]
fn a_failed_attach_detaches_only_unmounted_attachments_of_its_image() {
let image = |path: &str, entities: &[(&str, Option<&str>)]| {
let entities: String = entities
.iter()
.map(|(dev, mount)| {
let mount = mount.map(|m| format!("<key>mount-point</key><string>{m}</string>")).unwrap_or_default();
format!("<dict><key>dev-entry</key><string>{dev}</string>{mount}</dict>")
})
.collect();
format!("<dict><key>image-path</key><string>{path}</string><key>system-entities</key><array>{entities}</array></dict>")
};
let ours = "/Users/a/Library/Caches/craft/photocraft-0.6.0.dmg";
let info = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"><dict><key>images</key><array>{}{}{}{}</array></dict></plist>",
image(ours, &[("/dev/disk5", None), ("/dev/disk5s1", None), ("/dev/disk9", None)]),
image(ours, &[("/dev/disk6", None), ("/dev/disk6s1", Some("/Users/a/Library/Caches/craft/mount-photocraft-1-2"))]),
image("/Users/a/Downloads/other.dmg", &[("/dev/disk7", None)]),
image(&format!("/private{ours}"), &[("/dev/disk8", None)]),
);
let paths = [PathBuf::from(ours)];
assert_eq!(super::macos::unmounted_attachments(&info, &paths), ["/dev/disk5"]);
let both = [PathBuf::from(ours), PathBuf::from(format!("/private{ours}"))];
assert_eq!(super::macos::unmounted_attachments(&info, &both), ["/dev/disk5", "/dev/disk8"]);
assert!(super::macos::unmounted_attachments("hdiutil: info failed", &paths).is_empty());
}
#[test]
fn attachments_are_left_alone_unless_no_attach_of_the_image_is_running() {
let dmg = Path::new("/Users/a/Library/Caches/craft/photocraft-0.6.0.dmg");
let info = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?><plist version=\"1.0\"><dict><key>images</key><array><dict><key>image-path</key><string>{}</string><key>system-entities</key><array><dict><key>dev-entry</key><string>/dev/disk5</string></dict></array></dict></array></dict></plist>",
dmg.display()
);
for (answer, expected, outcome) in [
(Ok(true), vec![], Ok(())),
// pgrep gave no answer: an attach of the image may still be running, and the image
// may still be attached.
(
Err("pgrep didn't finish within 30 seconds".to_string()),
vec![],
Err("couldn't check for a running attach of the image (pgrep didn't finish within 30 seconds)".to_string()),
),
(Ok(false), vec!["info -plist".to_string(), "detach /dev/disk5 -force -quiet".to_string()], Ok(())),
] {
let ran = std::cell::RefCell::new(Vec::new());
let result = super::macos::detach_unmounted_with(
dmg,
|path| {
assert_eq!(path, dmg);
answer.clone()
},
|program, args, _| {
assert_eq!(program, "/usr/bin/hdiutil");
ran.borrow_mut().push(args.join(" "));
Ok(if args[0] == "info" { info.clone() } else { String::new() })
},
);
assert_eq!(result, outcome, "with the process check giving {answer:?}");
assert_eq!(ran.into_inner(), expected, "with the process check giving {answer:?}");
}
}
#[test]
fn a_failed_cleanup_is_reported_after_the_reason_the_install_failed() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let placed = PathBuf::from("/Applications/PhotoCraft.app");
let mount = dir.path().join("mount-photocraft-1-2");
std::fs::create_dir(&mount).unwrap();
let busy = |_: &Path| Err("Resource busy".to_string());
assert_eq!(
super::macos::close_image(Err("the copied app is incomplete".into()), &mount, busy),
Err(format!("the copied app is incomplete; the disk image is still attached at {} (Resource busy)", mount.display()))
);
assert_eq!(super::macos::close_image(Ok(placed.clone()), &mount, busy), Ok(placed.clone()));
assert!(mount.exists());
assert_eq!(super::macos::close_image(Err("failed".into()), &mount, |_| Ok(())), Err("failed".into()));
assert!(!mount.exists());
// A staging folder with a copy that can't be removed.
let locked_staging = || {
let staging = tempfile::Builder::new().prefix(".artcraft-launcher-staging-").tempdir_in(dir.path()).unwrap();
let copy = bundle(staging.path(), "0.6.0").join("Contents");
std::fs::set_permissions(&copy, std::fs::Permissions::from_mode(0o555)).unwrap();
(staging, copy)
};
let (staging, copy) = locked_staging();
let path = staging.path().to_path_buf();
let failed = super::macos::remove_staging(Err("the app isn't signed by its developer, so it wasn't installed".into()), staging);
std::fs::set_permissions(&copy, std::fs::Permissions::from_mode(0o755)).unwrap();
let message = failed.unwrap_err();
let start =
format!("the app isn't signed by its developer, so it wasn't installed; couldn't remove the partial copy at {} (", path.display());
assert!(message.starts_with(&start), "{message}");
assert!(path.exists());
let (staging, copy) = locked_staging();
let placed_anyway = super::macos::remove_staging(Ok(placed.clone()), staging);
std::fs::set_permissions(&copy, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(placed_anyway, Ok(placed.clone()));
let staging = tempfile::Builder::new().prefix(".artcraft-launcher-staging-").tempdir_in(dir.path()).unwrap();
let path = staging.path().to_path_buf();
bundle(&path, "0.6.0");
assert_eq!(super::macos::remove_staging(Err("failed".into()), staging), Err("failed".into()));
assert!(!path.exists());
}
/// A real release passes. Run with `CRAFT_APP=/path/to/PhotoCraft.app cargo test -p
/// artcraft-launcher-native -- --ignored a_real_craft_app`.
#[test]
#[ignore = "needs a Craft App from a real release"]
fn a_real_craft_app_carries_the_teams_signature() {
let app = std::env::var("CRAFT_APP").expect("set CRAFT_APP to a Craft App bundle");
assert!(signed_by(Path::new(&app), TEAM_ID), "{app} isn't signed by {TEAM_ID}");
}
}
}