diff --git a/.github/scripts/bundle-appimage.sh b/.github/scripts/bundle-appimage.sh index 03234afe..d5d25d72 100755 --- a/.github/scripts/bundle-appimage.sh +++ b/.github/scripts/bundle-appimage.sh @@ -62,6 +62,12 @@ chmod +x "$APPDIR/usr/bin/tty7-app" cp "target/${TARGET}/release/tty7" "$APPDIR/usr/bin/tty7" chmod +x "$APPDIR/usr/bin/tty7" +# The in-app updater, beside the GUI the way every platform ships it. The GUI +# copies it out of the mount into its staging directory before use — the mount +# is gone by the time an install runs (see src/bin/tty7-updater.rs). +cp "target/${TARGET}/release/tty7-updater" "$APPDIR/usr/bin/tty7-updater" +chmod +x "$APPDIR/usr/bin/tty7-updater" + # A desktop entry + icon are mandatory AppImage metadata; linuxdeploy places # them and generates AppRun. Icon basename must match the desktop's Icon= key. cat > "$TOOLS/tty7.desktop" <<'DESKTOP' @@ -75,6 +81,12 @@ Categories=System;TerminalEmulator; Terminal=false StartupWMClass=tty7 DESKTOP +# The release version, stamped where the in-app updater can read it back with +# one `--appimage-extract` and no mount: a downloaded image must state the +# version it claims before it may replace the installed one (`verify_update` +# in src/bin/tty7-updater.rs). X-AppImage-Version is the AppImage convention +# for exactly this. Appended outside the heredoc, which is quoted on purpose. +echo "X-AppImage-Version=${VERSION}" >> "$TOOLS/tty7.desktop" # linuxdeploy only accepts fixed icon resolutions (…256, 384, 512 — NOT the # source's 1024), so downscale to 256×256. convert assets/app-icon.png -resize 256x256 "$TOOLS/tty7.png" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3fab3929..21ef47c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,7 +128,6 @@ jobs: run: cargo test --locked --target ${{ matrix.target }} - name: Test desktop updater - if: runner.os == 'macOS' || runner.os == 'Windows' timeout-minutes: 10 run: cargo test --locked --features updater --bin tty7-updater --target ${{ matrix.target }} diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 9bad230b..c3bb4871 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -122,15 +122,15 @@ jobs: with: workspaces: tty7 + # The updater ships on every GUI platform now: inside the macOS bundle, + # beside the Windows app, and inside the Linux AppImage. - name: Build working-directory: tty7 shell: bash run: | cargo build --release --target "${{ matrix.target }}" - if [[ "${{ matrix.os }}" == "macos" || "${{ matrix.os }}" == "windows" ]]; then - cargo build --release --features updater \ - --bin tty7-updater --target "${{ matrix.target }}" - fi + cargo build --release --features updater \ + --bin tty7-updater --target "${{ matrix.target }}" - name: Bundle macOS DMG if: matrix.os == 'macos' @@ -174,6 +174,26 @@ jobs: working-directory: tty7 run: bash .github/scripts/bundle-appimage.sh "${{ matrix.target }}" "${{ matrix.arch }}" + # The same facts the in-app updater checks on the user's machine, + # checked here so a packaging mistake fails the nightly instead. See + # the twin step in release.yml. + - name: Verify Linux AppImage update package + if: matrix.os == 'linux' + working-directory: tty7 + shell: bash + run: | + set -euo pipefail + VERSION="${{ needs.plan.outputs.version }}" + IMAGE="$PWD/dist/tty7-${VERSION}-linux-${{ matrix.arch }}.AppImage" + VERIFY_ROOT="$RUNNER_TEMP/tty7-appimage-update-verify" + rm -rf "$VERIFY_ROOT" + mkdir -p "$VERIFY_ROOT" + (cd "$VERIFY_ROOT" && "$IMAGE" --appimage-extract >/dev/null) + test -x "$VERIFY_ROOT/squashfs-root/usr/bin/tty7-app" + test -x "$VERIFY_ROOT/squashfs-root/usr/bin/tty7-updater" + grep -Fxq "X-AppImage-Version=${VERSION}" \ + "$VERIFY_ROOT/squashfs-root/usr/share/applications/tty7.desktop" + # See release.yml for why this is best-effort rather than required. - name: Fetch the bundled Linux server if: matrix.os == 'windows' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 350faf24..921e4653 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,15 +74,15 @@ jobs: # recorded, not whatever cargo would re-resolve at build time. Safe here # (unlike nightly) precisely because nothing rewrites Cargo.toml: this is # a plain checkout of the tagged commit. + # The updater ships on every GUI platform now: inside the macOS bundle, + # beside the Windows app, and inside the Linux AppImage. - name: Build working-directory: tty7 shell: bash run: | cargo build --release --locked --target "${{ matrix.target }}" - if [[ "${{ matrix.os }}" == "macos" || "${{ matrix.os }}" == "windows" ]]; then - cargo build --release --locked --features updater \ - --bin tty7-updater --target "${{ matrix.target }}" - fi + cargo build --release --locked --features updater \ + --bin tty7-updater --target "${{ matrix.target }}" # ---- Packaging: one step per OS ---------------------------------------- # macOS gets a signed + notarized drag-to-Applications DMG. Windows gets @@ -117,6 +117,26 @@ jobs: working-directory: tty7 run: bash .github/scripts/bundle-appimage.sh "${{ matrix.target }}" "${{ matrix.arch }}" + # The in-app updater refuses an image whose bundled helper or stamped + # version is wrong — on the user's machine, after the download. Check the + # same facts here so a packaging mistake fails the release instead. + - name: Verify Linux AppImage update package + if: matrix.os == 'linux' + working-directory: tty7 + shell: bash + run: | + set -euo pipefail + VERSION="$(grep -m1 '^version = "' Cargo.toml | sed -E 's/.*"([^"]+)".*/\1/')" + IMAGE="$PWD/dist/tty7-${VERSION}-linux-${{ matrix.arch }}.AppImage" + VERIFY_ROOT="$RUNNER_TEMP/tty7-appimage-update-verify" + rm -rf "$VERIFY_ROOT" + mkdir -p "$VERIFY_ROOT" + (cd "$VERIFY_ROOT" && "$IMAGE" --appimage-extract >/dev/null) + test -x "$VERIFY_ROOT/squashfs-root/usr/bin/tty7-app" + test -x "$VERIFY_ROOT/squashfs-root/usr/bin/tty7-updater" + grep -Fxq "X-AppImage-Version=${VERSION}" \ + "$VERIFY_ROOT/squashfs-root/usr/share/applications/tty7.desktop" + # The bundled server for WSL. `continue-on-error` mirrors `server-musl`'s # own probe step: if there is no server asset, the release still ships and # `bundle-windows.ps1` warns. It is not silent at runtime either — a WSL diff --git a/src/bin/tty7-updater.rs b/src/bin/tty7-updater.rs index be2a8824..d9841d16 100644 --- a/src/bin/tty7-updater.rs +++ b/src/bin/tty7-updater.rs @@ -2,7 +2,10 @@ all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows" )] -#![cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] +#![cfg_attr( + not(any(target_os = "macos", target_os = "windows", target_os = "linux")), + allow(dead_code) +)] #[cfg(target_os = "macos")] mod macos { @@ -590,6 +593,692 @@ mod macos { } } +/// The Linux half serves exactly one installation shape: an AppImage. The +/// installed artifact is a single file (the path `$APPIMAGE` names), so the +/// whole install is one atomic swap — move the running image aside, rename +/// the verified download into its place, and start it. Tarball and distro +/// installs never reach this program; `package_for_current_install` in +/// `core::update` hands them the release page instead. +/// +/// One Linux-specific constraint shapes the code: the image the GUI runs +/// from is a FUSE mount the AppImage runtime tears down when the app exits — +/// which is the moment `install` starts working. The GUI therefore copies +/// this helper out of the mount into the staging directory and runs the +/// copy, the same way the Windows path runs a private copy because Setup +/// replaces the installed one. +#[cfg(target_os = "linux")] +mod linux { + use std::fs::{self, OpenOptions}; + use std::io::{Read as _, Write as _}; + use std::os::unix::fs::PermissionsExt as _; + use std::path::{Path, PathBuf}; + use std::process::{Child, Command, Stdio}; + use std::thread; + use std::time::Duration; + + const PARENT_POLL: Duration = Duration::from_millis(100); + const LAUNCH_GRACE: Duration = Duration::from_secs(1); + + /// Where `bundle-appimage.sh` installs the desktop entry inside the + /// image. The root-level `tty7.desktop` is linuxdeploy's symlink to this + /// file, and extracting a symlink alone yields a dangling link — so the + /// real path is the one asked for. + const DESKTOP_ENTRY: &str = "usr/share/applications/tty7.desktop"; + /// The helper inside the image, beside the app the way every platform + /// ships it. + const BUNDLED_UPDATER: &str = "usr/bin/tty7-updater"; + /// The desktop-entry key `bundle-appimage.sh` stamps the release version + /// into — the AppImage convention for stating a version where tools can + /// read it without running the app. + const VERSION_KEY: &str = "X-AppImage-Version="; + + pub fn run() -> Result<(), String> { + let mut args = std::env::args_os().skip(1); + let command = args + .next() + .and_then(|arg| arg.into_string().ok()) + .ok_or_else(usage)?; + match command.as_str() { + "verify" => { + let archive = next_path(&mut args)?; + let checksums = next_path(&mut args)?; + let asset_name = next_string(&mut args)?; + let stage = next_path(&mut args)?; + let expected_version = next_string(&mut args)?; + reject_extra(args)?; + verify_archive(&archive, &checksums, &asset_name)?; + verify_update(&archive, &stage, &expected_version) + } + "install" => { + let parent_pid = next_string(&mut args)? + .parse::() + .map_err(|_| "parent pid is not an unsigned integer".to_string())?; + let current = next_path(&mut args)?; + let archive = next_path(&mut args)?; + let checksums = next_path(&mut args)?; + let asset_name = next_string(&mut args)?; + let stage = next_path(&mut args)?; + let expected_version = next_string(&mut args)?; + let log = next_path(&mut args)?; + let options = tail_options(args)?; + options.apply(); + install(InstallPlan { + parent_pid, + current, + archive, + checksums, + asset_name, + stage, + expected_version, + log, + result_file: options.result_file, + }) + } + _ => Err(usage()), + } + } + + fn usage() -> String { + "usage: tty7-updater verify \ + \n\ + or: tty7-updater install \ + \ + [--config-dir ] [--result-file ]" + .to_string() + } + + fn next_path(args: &mut impl Iterator) -> Result { + args.next().map(PathBuf::from).ok_or_else(usage) + } + + fn next_string(args: &mut impl Iterator) -> Result { + args.next() + .and_then(|arg| arg.into_string().ok()) + .ok_or_else(usage) + } + + fn reject_extra(mut args: impl Iterator) -> Result<(), String> { + if args.next().is_some() { + Err(usage()) + } else { + Ok(()) + } + } + + /// The named options an install verb takes after its positional + /// arguments. See the Windows half of this file for why these are + /// arguments and not the environment. + #[derive(Default)] + struct TailOptions { + config_dir: Option, + result_file: Option, + } + + fn tail_options( + mut args: impl Iterator, + ) -> Result { + let mut options = TailOptions::default(); + while let Some(arg) = args.next() { + match arg.to_str() { + Some("--config-dir") => options.config_dir = Some(next_path(&mut args)?), + Some("--result-file") => options.result_file = Some(next_path(&mut args)?), + _ => return Err(usage()), + } + } + Ok(options) + } + + impl TailOptions { + fn apply(&self) { + let Some(dir) = &self.config_dir else { return }; + tty7_core::core::config::set_config_dir(dir.clone()); + // Re-exported so the relaunched app — a child of this process — + // keeps answering for the same config directory. Safe here: + // argument parsing runs before any thread exists. + unsafe { std::env::set_var("TTY7_CONFIG_DIR", dir) }; + } + } + + /// The terminal outcome of the attempt, for the next GUI launch to merge + /// into the update state (#540). Best-effort, like every log line here. + fn report_outcome( + result_file: Option<&Path>, + log: &Path, + version: &str, + result: &Result<(), String>, + ) { + let Some(path) = result_file else { return }; + let outcome = tty7_core::daemon::install::outcome::UpdateOutcome { + version: version.to_string(), + ok: result.is_ok(), + detail: result.as_ref().err().cloned(), + }; + if let Err(error) = tty7_core::daemon::install::outcome::write_outcome(path, &outcome) { + log_line( + log, + &format!( + "could not record the update outcome at {}: {error}", + path.display() + ), + ); + } + } + + struct InstallPlan { + parent_pid: u32, + current: PathBuf, + archive: PathBuf, + checksums: PathBuf, + asset_name: String, + stage: PathBuf, + expected_version: String, + log: PathBuf, + result_file: Option, + } + + fn install(plan: InstallPlan) -> Result<(), String> { + install_inner(&plan) + } + + // The daemon is deliberately left running, exactly as on macOS: nothing + // locks a running executable's file on Linux, and the daemon serves its + // panes from the old mount until the user chooses to restart it — that + // is what keeps their shells alive across the update. + fn install_inner(plan: &InstallPlan) -> Result<(), String> { + wait_for_exit(plan.parent_pid); + log_line(&plan.log, "re-verifying the staged tty7 update"); + let verification = verify_archive(&plan.archive, &plan.checksums, &plan.asset_name) + .and_then(|()| verify_update(&plan.archive, &plan.stage, &plan.expected_version)); + if let Err(error) = verification { + log_line(&plan.log, &error); + let _ = fs::remove_dir_all(&plan.stage); + let result = Err(error); + // The outcome lands before the old app does: the relaunched GUI + // merges it at startup, and a write afterward races that merge + // (#540). + report_outcome( + plan.result_file.as_deref(), + &plan.log, + &plan.expected_version, + &result, + ); + let _ = launch_app(&plan.current); + return result; + } + log_line(&plan.log, &format!("replacing {}", plan.current.display())); + let report = |result: &Result<(), String>| { + report_outcome( + plan.result_file.as_deref(), + &plan.log, + &plan.expected_version, + result, + ); + }; + replace_and_relaunch( + &plan.current, + &plan.archive, + &plan.stage, + launch_app, + report, + ) + .inspect_err(|error| log_line(&plan.log, error)) + } + + fn verify_archive(archive: &Path, checksums: &Path, asset_name: &str) -> Result<(), String> { + let bytes = + fs::read(archive).map_err(|error| format!("reading {}: {error}", archive.display()))?; + let manifest = fs::read_to_string(checksums) + .map_err(|error| format!("reading {}: {error}", checksums.display()))?; + tty7_core::daemon::install::checksums::verify(&manifest, asset_name, &bytes) + .map_err(|error| error.to_string()) + } + + /// What the downloaded file has to prove before it may become the + /// installation: it is a type-2 AppImage at all, it states the version + /// this update was for, and it carries its own updater — an image + /// without one would install fine and then be the last version that + /// ever could. Runs only after `verify_archive` has pinned the bytes to + /// the release's checksums.txt; from there, running the image's own + /// `--appimage-extract` is running the released code, which is exactly + /// what the swap is about to do anyway. + fn verify_update(staged: &Path, stage: &Path, expected_version: &str) -> Result<(), String> { + if !is_appimage(&read_header(staged)?) { + return Err(format!("{} is not a type-2 AppImage", staged.display())); + } + // Downloaded bytes land without the execute bit; extraction needs the + // runtime to run. The definitive mode is set again at swap time, taken + // from the file being replaced. + make_executable(staged)?; + let desktop = extract_entry(staged, stage, DESKTOP_ENTRY)?; + let text = fs::read_to_string(&desktop) + .map_err(|error| format!("reading {}: {error}", desktop.display()))?; + let actual = version_from_desktop_entry(&text).ok_or_else(|| { + format!("the staged AppImage's desktop entry carries no {VERSION_KEY}") + })?; + if actual != expected_version { + return Err(format!( + "the staged AppImage reports version {actual}, expected {expected_version}" + )); + } + extract_entry(staged, stage, BUNDLED_UPDATER)?; + Ok(()) + } + + /// ELF with the AppImage type-2 marker (`AI\x02` at offset 8). The + /// runtime the swap is about to spawn only exists behind this shape; a + /// wrongly published asset — a tarball under the AppImage name, an HTML + /// error page — fails here with a name instead of at launch. + fn is_appimage(header: &[u8]) -> bool { + header.len() >= 11 + && header[..4] == [0x7f, b'E', b'L', b'F'] + && header[8..11] == [b'A', b'I', 0x02] + } + + fn read_header(path: &Path) -> Result, String> { + let mut file = + fs::File::open(path).map_err(|error| format!("reading {}: {error}", path.display()))?; + let mut header = [0u8; 16]; + let read = file + .read(&mut header) + .map_err(|error| format!("reading {}: {error}", path.display()))?; + Ok(header[..read].to_vec()) + } + + fn make_executable(path: &Path) -> Result<(), String> { + let mode = fs::metadata(path) + .map_err(|error| format!("reading the mode of {}: {error}", path.display()))? + .permissions() + .mode(); + fs::set_permissions(path, fs::Permissions::from_mode(mode | 0o755)) + .map_err(|error| format!("marking {} executable: {error}", path.display())) + } + + /// Unpacks one entry of the staged image into `/squashfs-root/` + /// and returns the extracted file's path. + /// + /// `--appimage-extract` is answered by the AppImage runtime itself, + /// before any application code, and unpacks without mounting — so it + /// works on machines whose FUSE setup the eventual launch will need but + /// this verification should not. Run from the staging directory so the + /// `squashfs-root` it creates lands beside the package and is removed + /// with it. The runtime exits zero even when nothing matched, which is + /// why the answer is the extracted file's existence rather than the + /// exit status. + fn extract_entry(appimage: &Path, stage: &Path, entry: &str) -> Result { + // Anchored before the spawn: exec resolves a relative program path + // against the child's working directory, which the line below moves — + // a hand-run `tty7-updater verify ./pkg.AppImage …` would otherwise + // fail with a bare "No such file or directory". + let appimage = std::path::absolute(appimage) + .map_err(|error| format!("resolving {}: {error}", appimage.display()))?; + run_checked( + Command::new(&appimage) + .args(["--appimage-extract", entry]) + .current_dir(stage) + .stdout(Stdio::null()), + "extracting from the staged AppImage", + )?; + let extracted = stage.join("squashfs-root").join(entry); + if !extracted.is_file() { + return Err(format!("the staged AppImage carries no {entry}")); + } + Ok(extracted) + } + + fn version_from_desktop_entry(text: &str) -> Option { + text.lines() + .find_map(|line| line.strip_prefix(VERSION_KEY)) + .map(|version| version.trim().to_string()) + .filter(|version| !version.is_empty()) + } + + fn replace_and_relaunch( + current: &Path, + replacement: &Path, + stage: &Path, + launch: impl Fn(&Path) -> Result<(), String>, + report: impl Fn(&Result<(), String>), + ) -> Result<(), String> { + // The staging directory is a fresh TempDir created beside the current + // image, so a backup here stays on the same filesystem without using a + // predictable sibling path. In particular, never delete a fixed-name + // path beside the image: it may be a recovery copy left by an + // interrupted update (or simply an unrelated user-owned path). + let backup = stage.join("previous.AppImage"); + if backup.exists() { + let result = Err(format!( + "the update staging backup already exists: {}", + backup.display() + )); + report(&result); + return result; + } + // The replacement wears the current image's own mode: a rename keeps + // the staged file's permissions, which are the download's, and the + // user's choice of who may run their tty7 is not this program's to + // revise. Owner execute is guaranteed on top — without it nothing can + // relaunch — and grants nobody else anything. + if let Err(error) = carry_mode(current, replacement) { + report(&Err(error.clone())); + return Err(error); + } + if let Err(error) = fs::rename(current, &backup) { + let result = Err(format!("moving the current AppImage aside: {error}")); + report(&result); + return result; + } + + if let Err(error) = fs::rename(replacement, current) { + let _ = fs::rename(&backup, current); + let _ = fs::remove_dir_all(stage); + let result = Err(format!("putting the staged AppImage in place: {error}")); + report(&result); + return result; + } + + match launch(current) { + Ok(()) => { + let _ = remove_path(&backup); + let _ = fs::remove_dir_all(stage); + let result = Ok(()); + report(&result); + result + } + Err(error) => { + let _ = remove_path(current); + let (result, relaunch) = match fs::rename(&backup, current) { + Ok(()) => { + let _ = fs::remove_dir_all(stage); + (Err(error), true) + } + Err(restore) => ( + Err(format!("{error}; restoring the previous image: {restore}")), + false, + ), + }; + // The outcome lands before the old app does: the relaunched + // GUI merges it at startup, and a write afterward races that + // merge (#540). + report(&result); + if relaunch { + let _ = launch(current); + } + result + } + } + } + + /// Puts the mode of the file being replaced onto its replacement, + /// with owner execute assured. Falls back to plain 0o755 when the + /// current image cannot answer — it is about to be renamed away, not + /// consulted as an authority. + fn carry_mode(current: &Path, replacement: &Path) -> Result<(), String> { + let mode = fs::metadata(current) + .map(|meta| meta.permissions().mode()) + .unwrap_or(0o755); + fs::set_permissions(replacement, fs::Permissions::from_mode(mode | 0o700)) + .map_err(|error| format!("setting the mode of {}: {error}", replacement.display())) + } + + /// Starts the image at its installed path. The runtime sets `$APPIMAGE` + /// and `$APPDIR` for the process it mounts, overwriting the stale pair + /// this process inherited from the app that spawned it. + fn launch_app(appimage: &Path) -> Result<(), String> { + let mut child = Command::new(appimage) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("launching {}: {error}", appimage.display()))?; + healthy_after_grace(&mut child) + } + + fn healthy_after_grace(child: &mut Child) -> Result<(), String> { + thread::sleep(LAUNCH_GRACE); + match child + .try_wait() + .map_err(|error| format!("checking the relaunched app: {error}"))? + { + None => Ok(()), + Some(status) => Err(format!( + "the relaunched app exited immediately with {status}" + )), + } + } + + fn wait_for_exit(pid: u32) { + // The updater is spawned directly by the app it waits for, so while + // that app lives it *is* this process's parent, and the kernel + // reparents us the moment it exits. Watching getppid() is therefore + // immune to pid reuse, which `kill(pid, 0)` is not: a recycled pid + // keeps answering 0 forever. Same reasoning as the macos module; + // Linux reparents to init or the nearest subreaper, and either way + // the answer stops being `pid`. + let pid = pid as libc::pid_t; + if unsafe { libc::getppid() } == pid { + while unsafe { libc::getppid() } == pid { + thread::sleep(PARENT_POLL); + } + return; + } + // Not our parent — a hand-run updater. The polling fallback keeps + // that invocation working, pid-reuse caveat and all. + while process_alive(pid) { + thread::sleep(PARENT_POLL); + } + } + + fn process_alive(pid: libc::pid_t) -> bool { + unsafe { libc::kill(pid, 0) == 0 } + } + + fn remove_path(path: &Path) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + if path.is_dir() { + fs::remove_dir_all(path) + } else { + fs::remove_file(path) + } + .map_err(|error| format!("removing {}: {error}", path.display())) + } + + fn run_checked(command: &mut Command, context: &str) -> Result<(), String> { + let output = command + .output() + .map_err(|error| format!("{context}: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err(format!( + "{context}: {}", + String::from_utf8_lossy(&output.stderr).trim() + )) + } + } + + fn log_line(path: &Path, message: &str) { + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Ok(mut file) = OpenOptions::new().create(true).append(true).open(path) { + let _ = writeln!(file, "{message}"); + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn successful_launch_commits_the_replacement() { + let root = tempfile::tempdir().unwrap(); + let current = root.path().join("tty7.AppImage"); + let stage = root.path().join("stage"); + fs::create_dir(&stage).unwrap(); + let replacement = stage.join("tty7-new.AppImage"); + fs::write(¤t, b"old image").unwrap(); + fs::write(&replacement, b"new image").unwrap(); + + replace_and_relaunch(¤t, &replacement, &stage, |_| Ok(()), |_| ()).unwrap(); + + assert_eq!(fs::read(¤t).unwrap(), b"new image"); + assert!(!stage.exists()); + } + + #[test] + fn failed_launch_restores_and_relaunches_the_previous_image() { + let root = tempfile::tempdir().unwrap(); + let current = root.path().join("tty7.AppImage"); + let stage = root.path().join("stage"); + fs::create_dir(&stage).unwrap(); + let replacement = stage.join("tty7-new.AppImage"); + fs::write(¤t, b"old image").unwrap(); + fs::write(&replacement, b"new image").unwrap(); + let launches = std::cell::Cell::new(0); + let reported_after_launches = std::cell::Cell::new(usize::MAX); + + let error = replace_and_relaunch( + ¤t, + &replacement, + &stage, + |_| { + launches.set(launches.get() + 1); + if launches.get() == 1 { + Err("new app failed".to_string()) + } else { + Ok(()) + } + }, + |_| reported_after_launches.set(launches.get()), + ) + .unwrap_err(); + + assert_eq!(error, "new app failed"); + assert_eq!(launches.get(), 2); + // The outcome is reported after the failed first launch but before + // the old app comes back — the relaunched GUI must find it already + // on disk at startup (#540). + assert_eq!(reported_after_launches.get(), 1); + assert_eq!(fs::read(¤t).unwrap(), b"old image"); + assert!(!stage.exists()); + } + + /// The installed image keeps the mode the user gave it — a 0700 + /// image stays private — while the download's missing execute bit + /// never survives into the installation. + #[test] + fn the_replacement_wears_the_current_images_mode() { + let root = tempfile::tempdir().unwrap(); + let current = root.path().join("tty7.AppImage"); + let stage = root.path().join("stage"); + fs::create_dir(&stage).unwrap(); + let replacement = stage.join("tty7-new.AppImage"); + fs::write(¤t, b"old image").unwrap(); + fs::write(&replacement, b"new image").unwrap(); + fs::set_permissions(¤t, fs::Permissions::from_mode(0o700)).unwrap(); + fs::set_permissions(&replacement, fs::Permissions::from_mode(0o644)).unwrap(); + + replace_and_relaunch(¤t, &replacement, &stage, |_| Ok(()), |_| ()).unwrap(); + + let mode = fs::metadata(¤t).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "mode {mode:o}"); + } + + /// A leftover backup means an earlier attempt stopped between its two + /// renames; installing over it would overwrite the one copy of the + /// previous version. + #[test] + fn an_existing_backup_stops_the_replacement() { + let root = tempfile::tempdir().unwrap(); + let current = root.path().join("tty7.AppImage"); + let stage = root.path().join("stage"); + fs::create_dir(&stage).unwrap(); + let replacement = stage.join("tty7-new.AppImage"); + fs::write(¤t, b"old image").unwrap(); + fs::write(&replacement, b"new image").unwrap(); + fs::write(stage.join("previous.AppImage"), b"earlier backup").unwrap(); + + let error = replace_and_relaunch( + ¤t, + &replacement, + &stage, + |_| panic!("nothing may launch when the backup path is taken"), + |_| (), + ) + .unwrap_err(); + + assert!(error.contains("backup already exists"), "{error}"); + assert_eq!(fs::read(¤t).unwrap(), b"old image"); + } + + #[test] + fn archive_verification_rejects_bytes_that_do_not_match_the_manifest() { + let root = tempfile::tempdir().unwrap(); + let archive = root.path().join("tty7.AppImage"); + let manifest = root.path().join("checksums.txt"); + fs::write(&archive, b"downloaded bytes").unwrap(); + fs::write( + &manifest, + format!( + "{} tty7.AppImage\n", + tty7_core::daemon::install::checksums::hex( + &tty7_core::daemon::install::checksums::sha256(b"published bytes") + ) + ), + ) + .unwrap(); + + let error = verify_archive(&archive, &manifest, "tty7.AppImage").unwrap_err(); + assert!(error.contains("failed sha256 verification"), "{error}"); + } + + /// The magic check runs before anything executes the download, so a + /// mis-published asset is named without being run. + #[test] + fn verification_rejects_a_file_that_is_not_an_appimage() { + let mut elf_with_marker = vec![0x7f, b'E', b'L', b'F', 2, 1, 1, 0, b'A', b'I', 0x02]; + elf_with_marker.resize(16, 0); + assert!(is_appimage(&elf_with_marker)); + // A plain ELF — the tarball's binary, say — is not an AppImage. + let mut bare_elf = vec![0x7f, b'E', b'L', b'F', 2, 1, 1, 0, 0, 0, 0]; + bare_elf.resize(16, 0); + assert!(!is_appimage(&bare_elf)); + assert!(!is_appimage(b"Not Found")); + assert!(!is_appimage(b"")); + assert!(!is_appimage(&[0x7f, b'E', b'L', b'F'])); + + let root = tempfile::tempdir().unwrap(); + let staged = root.path().join("tty7.AppImage"); + fs::write(&staged, b"Not Found").unwrap(); + let error = verify_update(&staged, root.path(), "27.1.0").unwrap_err(); + assert!(error.contains("not a type-2 AppImage"), "{error}"); + } + + #[test] + fn the_desktop_entry_states_the_version() { + let text = "[Desktop Entry]\nType=Application\nName=tty7\nExec=tty7-app\n\ + Icon=tty7\nX-AppImage-Version=26.8.4\n"; + assert_eq!(version_from_desktop_entry(text).as_deref(), Some("26.8.4")); + // The nightly stamp survives whole — the identity the GUI + // compares against carries the prerelease tail. + assert_eq!( + version_from_desktop_entry("X-AppImage-Version=26.8.4-nightly.202608140200\n") + .as_deref(), + Some("26.8.4-nightly.202608140200") + ); + assert_eq!( + version_from_desktop_entry("[Desktop Entry]\nName=tty7\n"), + None + ); + // A stated nothing is not a version. + assert_eq!(version_from_desktop_entry("X-AppImage-Version=\n"), None); + assert_eq!(version_from_desktop_entry("X-AppImage-Version= \n"), None); + } + } +} + #[cfg(target_os = "windows")] mod windows { use std::collections::HashSet; @@ -3366,8 +4055,16 @@ fn main() { } } -#[cfg(not(any(target_os = "macos", target_os = "windows")))] +#[cfg(target_os = "linux")] fn main() { - eprintln!("tty7-updater is only available on macOS and Windows"); + if let Err(error) = linux::run() { + eprintln!("tty7-updater: {error}"); + std::process::exit(1); + } +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +fn main() { + eprintln!("tty7-updater is only available on macOS, Windows and Linux"); std::process::exit(1); } diff --git a/src/core/update.rs b/src/core/update.rs index bfdcf63d..1d4ed849 100644 --- a/src/core/update.rs +++ b/src/core/update.rs @@ -132,8 +132,10 @@ pub enum UpdateInstallHint { UnsupportedMacos, #[cfg(target_os = "linux")] UnsupportedLinux, - /// Linux updates by hand, but "use the release page" leaves the user to - /// work out which of five files is theirs. This names it. + /// The Linux shapes that still update by hand — a tarball install, or an + /// AppImage that cannot replace itself (read-only directory, no bundled + /// helper). "Use the release page" would leave the user to work out which + /// of five files is theirs; this names it. #[cfg(target_os = "linux")] LinuxManualPackage(String), #[cfg(target_os = "windows")] @@ -1170,9 +1172,10 @@ fn sweep_orphaned_stages(keep: Option) { } } -/// Where each platform's staging directories are created — beside the bundle on -/// macOS (it has to be on the app's own volume to rename into place), and the -/// per-user temp directory on Windows. +/// Where each platform's staging directories are created — beside the bundle +/// on macOS and beside the image on Linux (both have to be on the installed +/// file's own volume to rename into place), and the per-user temp directory +/// on Windows. // The `return`s are what let one cfg block win per platform; clippy sees only // the surviving one and reads it as redundant. #[allow(clippy::needless_return)] @@ -1184,11 +1187,18 @@ fn stage_roots() -> Vec { .into_iter() .collect(); } + #[cfg(target_os = "linux")] + { + return current_appimage() + .and_then(|appimage| appimage.parent().map(Path::to_path_buf)) + .into_iter() + .collect(); + } #[cfg(target_os = "windows")] { return vec![std::env::temp_dir()]; } - #[cfg(not(any(target_os = "macos", target_os = "windows")))] + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] Vec::new() } @@ -1970,6 +1980,7 @@ fn select_release_asset_for( /// The release package this installation can replace itself with. Split from /// the bare filename so the Windows Inno layout can carry "yes, but the /// install needs a UAC prompt" alongside it (#504). +#[derive(Debug)] struct PackageOffer { name: String, #[cfg(target_os = "windows")] @@ -2017,14 +2028,16 @@ fn package_for_current_install(version: &str) -> Result, outcome: Option<&Path>) -> Vec Result { tempfile::Builder::new() .prefix(".tty7-update-") @@ -2304,6 +2321,70 @@ fn prepare_macos_update( }) } +/// Stages a downloaded AppImage beside the installed one and has the bundled +/// updater verify it while this process is still around to show a failure. +/// +/// Staging lives in the image's own directory for the same reason macOS +/// stages beside the bundle: the swap is two renames, and renames only stay +/// atomic on one filesystem. The helper that performs them is *copied* into +/// staging rather than run from the image — the image is a FUSE mount the +/// runtime tears down when the GUI exits, which is precisely the moment the +/// installer starts working. +#[cfg(target_os = "linux")] +fn prepare_linux_update( + version: &str, + asset_name: &str, + archive: &[u8], + checksums: &[u8], +) -> Result { + let current = current_appimage().context("tty7 is not running from an AppImage")?; + let parent = current + .parent() + .context("the AppImage has no parent directory")?; + let bundled = bundled_updater().context("tty7-updater is not bundled with this image")?; + let staging = update_staging_dir(parent)?; + let dir = staging.path().to_path_buf(); + let archive = write_staged_asset(&dir, asset_name, archive)?; + let checksums = write_staged_asset(&dir, "checksums.txt", checksums)?; + run_updater( + &bundled, + [ + PathBuf::from("verify"), + archive.clone(), + checksums.clone(), + PathBuf::from(asset_name), + dir.clone(), + PathBuf::from(version), + ], + )?; + let updater = dir.join("tty7-updater"); + std::fs::copy(&bundled, &updater) + .with_context(|| format!("copying the updater to {}", updater.display()))?; + let log = + crate::core::config::config_path("update.log").unwrap_or_else(|| dir.join("update.log")); + if let Some(parent) = log.parent() { + std::fs::create_dir_all(parent).context("creating the update log directory")?; + } + let dir = staging.keep(); + Ok(PreparedUpdate { + updater, + command: "install".to_string(), + rest: vec![ + current, + archive, + checksums, + PathBuf::from(asset_name), + dir.clone(), + PathBuf::from(version), + log, + ], + config_dir: crate::core::config::config_dir_path(), + stage: dir, + needs_elevation: false, + expected_sha256: None, + }) +} + #[cfg(target_os = "windows")] fn prepare_windows_update( version: &str, @@ -2441,11 +2522,63 @@ fn bundled_updater() -> Option { updater.is_file().then_some(updater) } -#[cfg(not(any(target_os = "macos", target_os = "windows")))] +/// The updater shipped inside the mounted image, beside this executable at +/// `usr/bin`. Resolved through `current_exe` rather than `$APPDIR` so a +/// stale or hand-set variable cannot point the update machinery at a binary +/// that is not the one this process actually runs beside. +#[cfg(target_os = "linux")] +fn bundled_updater() -> Option { + let updater = std::env::current_exe().ok()?.parent()?.join("tty7-updater"); + updater.is_file().then_some(updater) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] fn bundled_updater() -> Option { None } +/// The image this process is running from. The AppImage runtime exports +/// `$APPIMAGE`, pointing at the file it mounted; nothing else identifies +/// which of the Linux packages is installed. Required to name an existing +/// absolute path — the variable outlives moves and deletions, and every +/// answer given here is a file the updater will later rename. +#[cfg(target_os = "linux")] +fn current_appimage() -> Option { + let appimage = PathBuf::from(std::env::var_os("APPIMAGE")?); + (appimage.is_absolute() && appimage.is_file()).then_some(appimage) +} + +#[cfg(target_os = "linux")] +fn is_appimage_update_writable(appimage: &Path) -> bool { + appimage.parent().is_some_and(can_stage_replacement_in) +} + +/// The Linux answer, split from the probing so the policy is testable +/// without an AppImage runtime setting variables. Only an AppImage that can +/// stage and swap itself is offered an install; every other shape keeps the +/// named-package hint it always had — a tarball unpacks wherever the user +/// chose, a distro package belongs to its package manager, and guessing at +/// either is exactly what `package_for_current_install` must never do. +#[cfg(target_os = "linux")] +fn linux_package_for( + version: &str, + arch: &str, + is_appimage: bool, + can_self_update: bool, +) -> Result { + if !is_appimage { + return Err(UpdateInstallHint::LinuxManualPackage(format!( + "tty7-{version}-linux-{arch}.tar.gz" + ))); + } + let name = format!("tty7-{version}-linux-{arch}.AppImage"); + if can_self_update { + Ok(PackageOffer::plain(name)) + } else { + Err(UpdateInstallHint::LinuxManualPackage(name)) + } +} + #[cfg(target_os = "windows")] #[derive(Clone, Debug, PartialEq, Eq)] enum WindowsUpdateLayout { @@ -2685,7 +2818,7 @@ fn windows_all_users_install_path() -> Option { (!value.is_empty()).then(|| PathBuf::from(std::ffi::OsString::from_wide(&value))) } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] fn can_stage_replacement_in(dir: &Path) -> bool { tempfile::Builder::new() .prefix(".tty7-update-write-test-") @@ -2816,6 +2949,64 @@ mod tests { ); } + /// The one Linux shape that installs itself: an AppImage whose directory + /// takes a staging directory and whose image carries the helper. + #[cfg(target_os = "linux")] + #[test] + fn an_appimage_that_can_replace_itself_is_offered_the_appimage() { + let offer = linux_package_for("27.1.0", "x86_64", true, true) + .expect("a self-updating AppImage yields an offer"); + assert_eq!(offer.name, "tty7-27.1.0-linux-x86_64.AppImage"); + } + + /// Everything else keeps the manual hint, and the hint names the exact + /// package for the installation shape rather than pointing at a release + /// page with five files on it. + #[cfg(target_os = "linux")] + #[test] + fn linux_installs_that_cannot_self_update_name_their_package() { + // An AppImage in a read-only directory, or one from before the + // helper shipped: still an AppImage, still updated by hand. + assert_eq!( + linux_package_for("27.1.0", "x86_64", true, false).unwrap_err(), + UpdateInstallHint::LinuxManualPackage("tty7-27.1.0-linux-x86_64.AppImage".to_string()) + ); + // A tarball (or distro-packaged) install is never guessed at. + assert_eq!( + linux_package_for("27.1.0", "x86_64", false, false).unwrap_err(), + UpdateInstallHint::LinuxManualPackage("tty7-27.1.0-linux-x86_64.tar.gz".to_string()) + ); + // `can_self_update` without an AppImage cannot happen (the probe is + // gated on the variable), but the policy must not invent an offer if + // it ever does. + assert!(linux_package_for("27.1.0", "x86_64", false, true).is_err()); + } + + /// The offered AppImage name must match what the release actually + /// publishes, checksums manifest included — the same end-to-end shape the + /// macOS selection test pins. + #[cfg(target_os = "linux")] + #[test] + fn appimage_selection_matches_the_published_asset_names() { + let name = "tty7-27.1.0-linux-x86_64.AppImage"; + let offer = linux_package_for("27.1.0", "x86_64", true, true).unwrap(); + let assets = [ + github_asset("tty7-27.1.0-linux-x86_64.tar.gz"), + github_asset(name), + github_asset("checksums.txt"), + ]; + let selected = select_release_asset_for(Ok(offer), &assets); + assert_eq!( + selected.asset, + Some(ReleaseAsset { + name: name.to_string(), + url: format!("https://example.test/{name}"), + checksums_url: "https://example.test/checksums.txt".to_string(), + }) + ); + assert_eq!(selected.reason, None); + } + #[cfg(target_os = "windows")] #[test] fn portable_backup_scan_separates_interrupted_from_finished() {