diff --git a/AGENTS.md b/AGENTS.md index 2b16b0c..068e23b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,13 @@ Inside `crates/tapesctl/src`: - `api/` — the ` ` read client. - `cassette/` — the runtime-discovered `cassettes ` surface: discovery, the spec reducer, the cache, and clap synthesis. +- `upgrade/` — verified, crash-safe self-replacement: bucket resolution, + staging, digest verification, and the atomic swap. +- `install_layout.rs` — where this binary actually lives, derived from the + running executable. `upgrade` and `uninstall` both build on it. +- `rc_block.rs` — removal of the installer's sentinel block from shell rc + files. Its markers are pinned against `install.sh` by a test. +- `uninstall.rs` — the binary, the local state, and that block. - `plugin.rs`, `capture.rs`, `logging.rs`, `error.rs` — the remaining command entry points and cross-cutting support. - `ports/` — search, skills, and seed. diff --git a/Cargo.lock b/Cargo.lock index 6b5379b..7096a16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -165,7 +165,7 @@ dependencies = [ "bitflags 2.13.1", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "proc-macro2", "quote", "regex", @@ -231,7 +231,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -931,6 +931,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1170,6 +1179,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1879,6 +1897,7 @@ dependencies = [ "http", "http-body", "http-body-util", + "libc", "reqwest", "serde", "serde_json", @@ -1898,6 +1917,7 @@ dependencies = [ "tracing-subscriber", "url", "uuid", + "versions", "wiremock", "zstd", ] @@ -2366,6 +2386,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "versions" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80a7e511ce1795821207a837b7b1c8d8aca0c648810966ad200446ae58f6667f" +dependencies = [ + "itertools 0.14.0", + "nom 8.0.0", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 6d386fc..4357be3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -199,12 +199,24 @@ dirs = "5" time = { version = "0.3", features = ["formatting", "parsing", "serde", "macros"] } uuid = { version = "1", features = ["v4", "serde"] } -# --- test --- -# Recomputing the vendored fixture corpora's DIGEST seals, which is how a stale -# or hand-edited vendored copy fails in this repo's CI rather than silently -# testing cases nobody upstream still has. Test-only: nothing shipped hashes. +# Two callers, one of them shipped: recomputing the vendored fixture corpora's +# DIGEST seals (how a stale or hand-edited vendored copy fails in this repo's CI +# rather than silently testing cases nobody upstream still has), and verifying a +# downloaded artifact against its published `.sha256` in `tapesctl upgrade`. sha2 = "0.10" +# Also shipped: `upgrade` and `uninstall` probe install-directory writability by +# creating a file there, which respects ACLs and read-only mounts where a +# mode-bit inspection would not. tempfile = "3" + +# Version comparison for `tapesctl upgrade`, and deliberately not `semver`: +# release labels here are compared, not validated, and `versions` accepts the +# version-like-but-not-SemVer forms a release process can produce while still +# ordering them sensibly. It also ignores build metadata for equality, which is +# what lets a stamped `v0.7.0+3f2a1b9` compare equal to the bucket's `v0.7.0`. +versions = "7" + +# --- test --- test-case = "3" wiremock = "0.6" diff --git a/README.md b/README.md index 7aaaa9f..d8bca22 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,26 @@ curl -sSfL https://download.tapes.dev/tapesctl/install | bash Every published artifact carries a `.sha256` sidecar. Where `sha256sum` or `shasum` is available, the installer verifies the download against that sidecar before installing, and a missing sidecar is a hard failure rather than a skipped -check; with neither tool present it warns and installs unverified. Binaries land -in `/usr/local/bin` (via `sudo` only if that directory is not writable). Set -`TAPESCTL_VERSION` to install a specific release or nightly, and +check; with neither tool present it warns and installs unverified. + +The binary lands in `$HOME/.local/bin` — a directory you own, so a normal +install never asks for `sudo`. That directory is not on every default `PATH`, +so the installer also writes a guarded `PATH` export into your shell's rc file +(`.bashrc`, `.zshrc`, or `config.fish`), inside a sentinel-marked block it +rewrites in place rather than duplicating on re-install. If your rc file is +read-only — managed by nix or a dotfiles manager — the install still succeeds +and prints the line to add yourself. + +Set `TAPESCTL_VERSION` to install a specific release or nightly, and `TAPESCTL_INSTALL_DIR` to install somewhere else. +Installs predating this layout put the binary in `/usr/local/bin`. Re-running +the installer migrates them: it removes the old root-owned binary with a single +announced `sudo` — the last one tapesctl will ever ask for — because a binary +left there would shadow the new one in every context that still has the default +`PATH` order. Declining is fine; the install still succeeds and prints the +removal command. + Confirm it landed: ```bash @@ -38,6 +53,42 @@ tapesctl version Supported platforms are Linux and macOS, on x86-64 and arm64. +### Upgrading + +```bash +tapesctl upgrade +``` + +No curl, no re-running the installer. It checks the published version, says +`already up to date` and exits successfully when there is nothing to do, and +otherwise replaces this binary in place and prints `old → new`. + +Nothing touches the installed binary until the download's SHA-256 matches the +published sidecar and the staged file has answered `version` sensibly — so a +corrupted download, a wrong-architecture artifact, or a lost connection all +leave the binary you had still working. A missing sidecar aborts rather than +installing something unverifiable. + +`--version v0.6.0` pins an exact release, older ones included, because a bad +release needs an escape hatch. `--nightly` installs the rolling nightly build. + +An install still living in `/usr/local/bin` cannot upgrade itself — the +directory is not yours to write — and says so, naming the installer as the way +to migrate. + +### Uninstalling + +```bash +tapesctl uninstall +``` + +Removes the binary, `~/.tapes`, the cassette cache, and the `PATH` block the +installer wrote — leaving every other line of your rc file byte-for-byte +intact, and listing each path before you confirm. A cache location you pinned +with `TAPESCTL_CACHE_DIR` is named rather than deleted. Capture plugins installed into a harness are left alone, because those +live in the harness's own config; `tapesctl plugin uninstall ` removes +one. Pass `-y` to skip the confirmation. + ## Your first capture `start` launches a harness the way you normally would, with a capture proxy in diff --git a/crates/tapesctl/Cargo.toml b/crates/tapesctl/Cargo.toml index c9d5801..ecd842f 100644 --- a/crates/tapesctl/Cargo.toml +++ b/crates/tapesctl/Cargo.toml @@ -17,6 +17,7 @@ name = "tapesctl" path = "src/main.rs" [dependencies] +libc = { workspace = true } tapes-harnesses = { workspace = true } tapes-capture = { workspace = true } tapes-client = { workspace = true } @@ -36,7 +37,9 @@ reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } +sha2 = { workspace = true } snafu = { workspace = true } +tempfile = { workspace = true } time = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } toml = { workspace = true } @@ -45,12 +48,11 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } url = { workspace = true } uuid = { workspace = true } +versions = { workspace = true } zstd = { workspace = true } [dev-dependencies] -sha2 = { workspace = true } test-case = { workspace = true } -tempfile = { workspace = true } wiremock = { workspace = true } [lints] diff --git a/crates/tapesctl/src/cassette/cache.rs b/crates/tapesctl/src/cassette/cache.rs index c75f819..8a78324 100644 --- a/crates/tapesctl/src/cassette/cache.rs +++ b/crates/tapesctl/src/cassette/cache.rs @@ -33,16 +33,52 @@ pub const REVALIDATE_AFTER: Duration = Duration::from_secs(600); /// location in CI. pub const CACHE_DIR_ENV: &str = "TAPESCTL_CACHE_DIR"; +/// Directory name under the platform cache dir, when no override is set. +const APP_DIR_NAME: &str = "tapesctl/cassettes"; + /// tapesctl's cache parameterization for one base URL. fn config(key: &str) -> CacheConfig<'_> { CacheConfig { - app_dir_name: "tapesctl/cassettes", + app_dir_name: APP_DIR_NAME, env_override_var: CACHE_DIR_ENV, revalidate_after: REVALIDATE_AFTER, key, } } +/// The directory this crate *chose* for cached surfaces — the platform cache +/// directory plus [`APP_DIR_NAME`] — ignoring [`CACHE_DIR_ENV`] entirely. +/// `None` when the platform names no cache directory. +/// +/// The override is deliberately not honored here, and this is the only reason +/// the function exists separately from the resolution +/// [`tapes_client::cassettes::cache`] performs internally: the one caller is +/// `uninstall`, which passes what it gets to `remove_dir_all`. An override +/// names a directory the *user* picked, which may hold anything — +/// `TAPESCTL_CACHE_DIR=$HOME` would turn an uninstall into a recursive delete +/// of the home directory. A path this crate derived itself is one it owns and +/// may remove; a path the environment supplied is not. +/// +/// Callers that want the location actually in use want +/// [`tapes_client::cassettes::cache`] via [`read`]/[`write`], not this. +#[must_use] +pub fn owned_cache_dir() -> Option { + Some(dirs::cache_dir()?.join(APP_DIR_NAME)) +} + +/// The override's value, when it is set to something non-empty. +/// +/// `uninstall` names it in its report rather than deleting it: leaving a cache +/// behind is a nuisance, deleting a directory the user pointed us at is not +/// recoverable. +#[must_use] +pub fn cache_dir_override() -> Option { + std::env::var(CACHE_DIR_ENV) + .ok() + .filter(|raw| !raw.trim().is_empty()) + .map(std::path::PathBuf::from) +} + /// Read the cached surface for a base URL, if there is a usable one. #[must_use] pub fn read(base: &str) -> Option { diff --git a/crates/tapesctl/src/cli.rs b/crates/tapesctl/src/cli.rs index 343af57..d6ad715 100644 --- a/crates/tapesctl/src/cli.rs +++ b/crates/tapesctl/src/cli.rs @@ -337,6 +337,44 @@ pub enum Command { #[command(subcommand)] Config(ConfigCommand), + /// Upgrade tapesctl in place: download the requested build, verify its + /// published checksum, and atomically replace this binary. + /// + /// With no flags, installs the newest release; exits successfully without + /// downloading when already up to date. Nothing touches the installed + /// binary until the download's sha256 matches the published one and the + /// staged file has answered `version` sensibly, so every failure leaves the + /// binary you had still working. + /// + /// Deliberately named `upgrade` with no `update` alias: `upgrade` means + /// "replace the binary with another build", and `update` is reserved for + /// possible future semantics such as refreshing configuration or a + /// discovered cassette surface. + Upgrade { + /// Release version to install (e.g. `v0.7.0` or `0.7.0`). + /// + /// Defaults to the newest published release; older versions are + /// allowed, because a bad release needs an escape hatch. + #[arg(long, conflicts_with = "nightly")] + version: Option, + + /// Install the rolling nightly build instead of a release. + #[arg(long)] + nightly: bool, + }, + + /// Remove tapesctl: the binary, this tool's local state, and the PATH + /// block the installer wrote into your shell's rc file. + /// + /// Harness-side capture plugins are left alone — a plugin registration + /// lives in the harness's own config file, and `tapesctl plugin uninstall` + /// is the command that speaks that contract. + Uninstall { + /// Skip the interactive confirmation prompt. + #[arg(short = 'y', long = "yes")] + assume_yes: bool, + }, + /// Print version information. Version, } diff --git a/crates/tapesctl/src/error.rs b/crates/tapesctl/src/error.rs index 92ab39d..08dfd0f 100644 --- a/crates/tapesctl/src/error.rs +++ b/crates/tapesctl/src/error.rs @@ -775,6 +775,28 @@ pub enum Error { /// Underlying IO failure. source: std::io::Error, }, + + /// `tapesctl upgrade` could not replace the binary. + /// + /// Every variant beneath this one means the installed binary is still the + /// pre-upgrade file: nothing destructive happens before the final atomic + /// rename. + #[snafu(display("upgrade failed"))] + Upgrade { + /// Underlying upgrade failure. + source: crate::upgrade::UpgradeCliError, + }, + + /// `tapesctl uninstall` could not run. + /// + /// Only the steps that make the command impossible to start reach here; + /// every individual removal warns and continues, so a partial teardown is + /// a report, not an error. + #[snafu(display("uninstall failed"))] + Uninstall { + /// Underlying uninstall failure. + source: crate::uninstall::UninstallError, + }, } /// Map the shared client's errors onto the variants this CLI surfaced when the diff --git a/crates/tapesctl/src/install_layout.rs b/crates/tapesctl/src/install_layout.rs new file mode 100644 index 0000000..fef9209 --- /dev/null +++ b/crates/tapesctl/src/install_layout.rs @@ -0,0 +1,271 @@ +//! Resolves the install layout from the running executable's real location. +//! +//! [`InstallLayout`] canonicalizes `std::env::current_exe()` so a PATH +//! invocation, an absolute one, and one through a symlink all collapse to the +//! directory the real binary lives in. `upgrade` and `uninstall` both derive +//! every install path from this one type, so the two commands can never +//! disagree about where "the install" is — and no runtime code needs to +//! hardcode an install directory. + +use std::path::{Path, PathBuf}; + +use snafu::{OptionExt, ResultExt, Snafu}; + +/// Basename of the staged replacement binary written by `tapesctl upgrade`. +/// +/// Dotted so a crashed run cannot leave a PATH-visible half-binary behind. +const STAGING_FILE_NAME: &str = ".tapesctl.new"; + +/// Every install path upgrade and uninstall touch, derived from one +/// canonicalized executable path. +/// +/// Constructed, never assumed: the paths describe the directory the running +/// binary actually resides in — not a guessed install prefix — so operations +/// act on the binary the user is really running. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallLayout { + /// Directory containing the canonicalized binary. + install_dir: PathBuf, + /// The canonicalized `tapesctl` binary itself. + tapesctl_path: PathBuf, + /// Staging file for upgrade downloads. Lives in `install_dir` so the final + /// rename over `tapesctl_path` stays on a single filesystem. + staging_path: PathBuf, +} + +impl InstallLayout { + /// Resolve the layout from `std::env::current_exe()`. + pub fn from_current_exe() -> Result { + use install_layout_error::*; + + let exe = std::env::current_exe().context(CurrentExeSnafu)?; + Self::from_exe_path(exe) + } + + /// Resolve the layout from an explicit executable path. + /// + /// Canonicalizes `exe` (following symlinks, so an invocation through one + /// lands on the real file) and derives every other path from the result. + /// Test-friendly entry point: production code goes through + /// [`InstallLayout::from_current_exe`]. + pub fn from_exe_path(exe: impl AsRef) -> Result { + use install_layout_error::*; + + let exe = exe.as_ref(); + let tapesctl_path = exe + .canonicalize() + .context(CanonicalizeSnafu { path: exe })?; + let install_dir = + tapesctl_path + .parent() + .map(Path::to_path_buf) + .context(NoInstallDirSnafu { + path: &tapesctl_path, + })?; + Ok(Self { + staging_path: install_dir.join(STAGING_FILE_NAME), + tapesctl_path, + install_dir, + }) + } + + /// Directory containing the real binary. + #[must_use] + pub fn install_dir(&self) -> &Path { + &self.install_dir + } + + /// The canonicalized `tapesctl` binary path. + #[must_use] + pub fn tapesctl_path(&self) -> &Path { + &self.tapesctl_path + } + + /// The upgrade staging file (`.tapesctl.new`) inside the install directory. + #[must_use] + pub fn staging_path(&self) -> &Path { + &self.staging_path + } + + /// Probe whether the invoking user can mutate the install directory. + /// + /// `unlink(2)` and `rename(2)` require write permission on the *containing + /// directory*, so this one check gates both upgrade's swap and uninstall's + /// binary removal. No escalation is ever attempted; on failure the caller + /// prints remediation (the installer command) and exits nonzero. + pub fn ensure_writable(&self) -> Result<(), EnsureWritableError> { + use ensure_writable_error::*; + + // Creating (and immediately dropping, which deletes) a uniquely named + // file exercises exactly the directory-write permission that unlink and + // rename need — unlike a mode-bit inspection, it also respects ACLs and + // read-only mounts. + match tempfile::Builder::new() + .prefix(".tapesctl-writable-probe.") + .tempfile_in(&self.install_dir) + { + Ok(_probe) => Ok(()), + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::ReadOnlyFilesystem + ) => + { + NotWritableSnafu { + dir: &self.install_dir, + } + .fail() + } + Err(source) => Err(source).context(ProbeSnafu { + dir: &self.install_dir, + }), + } + } +} + +/// Failure modes for constructing an [`InstallLayout`]. +#[derive(Debug, Snafu)] +#[snafu(module, visibility(pub(crate)))] +#[non_exhaustive] +pub enum InstallLayoutError { + /// `std::env::current_exe()` itself failed (exe deleted mid-run, procfs + /// unavailable). + #[snafu(display("could not resolve the current executable path"))] + CurrentExe { + /// Underlying I/O failure. + source: std::io::Error, + }, + /// Canonicalizing the executable path failed (dangling symlink, unreadable + /// path component). + #[snafu(display("could not canonicalize executable path '{}'", path.display()))] + Canonicalize { + /// The path we tried to canonicalize. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + /// The canonicalized executable has no containing directory (`/`). + #[snafu(display("executable path '{}' has no containing directory", path.display()))] + NoInstallDir { + /// The canonicalized executable path. + path: PathBuf, + }, +} + +/// Failure modes for [`InstallLayout::ensure_writable`]. +#[derive(Debug, Snafu)] +#[snafu(module, visibility(pub(crate)))] +#[non_exhaustive] +pub enum EnsureWritableError { + /// The install directory rejects mutation by the invoking user — the + /// unmigrated root-owned-install state. + #[snafu(display("install directory '{}' is not writable", dir.display()))] + NotWritable { + /// The unwritable install directory. + dir: PathBuf, + }, + /// Probing the directory failed outright (an I/O error distinct from a + /// clean permission refusal). + #[snafu(display("could not probe install directory '{}'", dir.display()))] + Probe { + /// The directory being probed. + dir: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn install_layout_canonicalizes_a_symlinked_invocation() { + // Given a real binary with a symlink pointing at it — the shape a + // shim, or a package manager's bin directory, produces + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("tapesctl"); + std::fs::write(&real, b"#!/bin/sh\n").unwrap(); + let link = dir.path().join("tapesctl-link"); + std::os::unix::fs::symlink(&real, &link).unwrap(); + + // When the layout is built from the symlink path + let layout = InstallLayout::from_exe_path(&link).unwrap(); + + // Then every path resolves to the real file's directory + // (canonicalized, so the tempdir's own symlinkness — /var → + // /private/var on macOS — collapses too) + let real_dir = dir.path().canonicalize().unwrap(); + assert_eq!(layout.install_dir(), real_dir); + assert_eq!(layout.tapesctl_path(), real_dir.join("tapesctl")); + assert_eq!(layout.staging_path(), real_dir.join(STAGING_FILE_NAME)); + } + + #[test] + fn the_staging_file_shares_the_install_directory() { + // The invariant the whole crash-safe swap rests on: `rename(2)` is + // only atomic within one filesystem, so the staged file must be a + // sibling of the binary it will replace — never in /tmp. + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("tapesctl"); + std::fs::write(&real, b"#!/bin/sh\n").unwrap(); + + let layout = InstallLayout::from_exe_path(&real).unwrap(); + + assert_eq!(layout.staging_path().parent(), Some(layout.install_dir())); + } + + #[test] + fn a_writable_directory_passes_the_probe_and_leaves_nothing_behind() { + // Given an ordinary user-owned install directory + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("tapesctl"); + std::fs::write(&real, b"#!/bin/sh\n").unwrap(); + let layout = InstallLayout::from_exe_path(&real).unwrap(); + + // When writability is probed + assert!(layout.ensure_writable().is_ok()); + + // Then the probe file is gone — only the binary remains + let entries: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!(entries, vec![std::ffi::OsString::from("tapesctl")]); + } + + #[test] + fn an_unwritable_directory_refuses_by_name() { + use std::os::unix::fs::PermissionsExt; + + // Given an install directory the user cannot write — what an + // unmigrated root-owned install looks like from inside the binary + let dir = tempfile::tempdir().unwrap(); + let nested = dir.path().join("bin"); + std::fs::create_dir(&nested).unwrap(); + let real = nested.join("tapesctl"); + std::fs::write(&real, b"#!/bin/sh\n").unwrap(); + let layout = InstallLayout::from_exe_path(&real).unwrap(); + std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o555)).unwrap(); + // Root writes through 0o555 (CAP_DAC_OVERRIDE), so the unwritable + // precondition cannot be constructed from mode bits alone. Skip + // rather than assert a refusal the kernel will never produce — + // containerized CI runs as root. + if std::fs::write(nested.join(".root-probe"), b"").is_ok() { + let _ = std::fs::remove_file(nested.join(".root-probe")); + return; + } + + // When writability is probed + let result = layout.ensure_writable(); + + // Then it is a clean, typed refusal naming the directory — never an + // escalation attempt + std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!( + matches!(result, Err(EnsureWritableError::NotWritable { .. })), + "got: {result:?}" + ); + } +} diff --git a/crates/tapesctl/src/lib.rs b/crates/tapesctl/src/lib.rs index d3ab7cf..6e4b890 100644 --- a/crates/tapesctl/src/lib.rs +++ b/crates/tapesctl/src/lib.rs @@ -9,14 +9,19 @@ pub mod cli; pub mod codex_app; pub mod config; pub mod error; +pub mod install_layout; pub mod logging; pub mod machine; pub mod plugin; pub mod ports; +pub mod rc_block; pub mod start; pub mod transcript; +pub mod uninstall; +pub mod upgrade; use clap::{ArgMatches, CommandFactory, FromArgMatches}; +use snafu::ResultExt; use tapes_client::DirectHttp; use url::Url; @@ -303,6 +308,12 @@ pub async fn run(cli: Cli) -> Result<()> { Command::Plugin(PluginCommand::Uninstall(args)) => plugin::uninstall(args), Command::Plugin(PluginCommand::Hook(args)) => codex_app::hook::run(&args).await, Command::Config(command) => config::run(&command), + Command::Uninstall { assume_yes } => { + uninstall::run(assume_yes).context(error::error::UninstallSnafu) + } + Command::Upgrade { version, nightly } => upgrade::run(version, nightly) + .await + .context(error::error::UpgradeSnafu), } } diff --git a/crates/tapesctl/src/main.rs b/crates/tapesctl/src/main.rs index 6c7d3f4..047752e 100644 --- a/crates/tapesctl/src/main.rs +++ b/crates/tapesctl/src/main.rs @@ -38,9 +38,19 @@ async fn main() -> ExitCode { match tapesctl::dispatch(invocation).await { Ok(()) => ExitCode::SUCCESS, Err(err) => { - // The daemon/proxy work will grow structured error reporting; for - // now a single line to stderr is enough and keeps `main` panic-free. + // The whole chain, not just the outermost message. Every error in + // this crate is a typed wrapper around the one beneath it, and the + // outermost is deliberately the least specific — "upgrade failed" + // is a category, while the cause a user acts on ("sha256 mismatch", + // "install directory is not writable; re-run the installer") lives + // one or two links down. Printing only the top discards exactly the + // half that says what to do about it. eprintln!("tapesctl: {err}"); + let mut source = std::error::Error::source(&err); + while let Some(cause) = source { + eprintln!(" caused by: {cause}"); + source = cause.source(); + } ExitCode::FAILURE } } diff --git a/crates/tapesctl/src/rc_block.rs b/crates/tapesctl/src/rc_block.rs new file mode 100644 index 0000000..6110ced --- /dev/null +++ b/crates/tapesctl/src/rc_block.rs @@ -0,0 +1,374 @@ +//! Surgically removes the installer-written sentinel block from shell rc files. +//! +//! `install.sh` wraps everything it adds to a user's rc file between the +//! [`BLOCK_BEGIN`] and [`BLOCK_END`] marker lines. That sentinel contract is +//! what makes touching user dotfiles defensible: removal deletes exactly the +//! marked block and preserves every byte outside it — no regex over user +//! content, no whole-file reformatting. +//! +//! The marker strings are deliberately duplicated in the bash installer +//! (single-sourcing them through the binary would put binary execution on the +//! installer's critical path before PATH exists); a consistency test reads the +//! script so drift turns into a red build instead of a silent contract break. +//! +//! The markers name tapesctl. paperctl writes a block with the same glyph and +//! different text into the same rc files, and both removers compare whole +//! lines — so the two blocks coexist and neither tool disturbs the other's. + +use std::path::{Path, PathBuf}; + +use snafu::{ResultExt, Snafu}; + +/// First line of the installer-written rc block. Must match `install.sh` +/// byte-for-byte. +pub const BLOCK_BEGIN: &str = "# > [|o=o|] > tapesctl path > [|o=o|] >"; + +/// Last line of the installer-written rc block. Must match `install.sh` +/// byte-for-byte. +pub const BLOCK_END: &str = "# < [|o=o|] < tapesctl path < [|o=o|] <"; + +/// The rc files the installer may have written a block into, resolved against +/// the current user's home directory and `XDG_CONFIG_HOME`. +/// +/// Empty when no home directory can be resolved — with no home there is no rc +/// file the installer could have edited either. The environment is read here, +/// at the command boundary; the path construction itself is the injectable +/// [`rc_files_under`]. +#[must_use] +pub fn known_rc_files() -> Vec { + dirs::home_dir() + .map(|home| { + let xdg = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from); + rc_files_under(&home, xdg.as_deref()) + }) + .unwrap_or_default() +} + +/// The rc-file set under an explicit home directory: `.bashrc`, `.zshrc`, and +/// fish's `config.fish` — the same set the installer targets. +/// +/// The fish path honors `xdg_config_home` exactly like the installer's +/// `${XDG_CONFIG_HOME:-$HOME/.config}` expression (an empty value falls back +/// like an unset one), so uninstall removes the block from the file the +/// installer actually wrote. Pure path construction; nothing is checked for +/// existence. Split out so tests can drive a tempdir home. +#[must_use] +pub fn rc_files_under(home: &Path, xdg_config_home: Option<&Path>) -> Vec { + let config_dir = xdg_config_home + .filter(|p| !p.as_os_str().is_empty()) + .map_or_else(|| home.join(".config"), Path::to_path_buf); + vec![ + home.join(".bashrc"), + home.join(".zshrc"), + config_dir.join("fish").join("config.fish"), + ] +} + +/// What [`remove_block`] did to the rc file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoveOutcome { + /// A block was found and removed; the file was rewritten with every byte + /// outside the markers intact. + Removed, + /// The file does not exist or contains no block; it was not rewritten at + /// all. + NoBlock, + /// A begin marker with no matching end marker. Rewriting would drop + /// everything after the orphaned marker, so the file was left untouched — + /// callers surface this so the user knows the block is still there. + Malformed, +} + +/// Remove the sentinel block from the rc file at `rc_path`. +/// +/// The file is only rewritten on [`RemoveOutcome::Removed`]; content outside +/// the markers survives byte-for-byte. +pub fn remove_block(rc_path: &Path) -> Result { + use remove_block_error::*; + + let contents = match std::fs::read(rc_path) { + Ok(contents) => contents, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(RemoveOutcome::NoBlock), + Err(source) => return Err(source).context(ReadSnafu { path: rc_path }), + }; + let remaining = match strip_block(&contents) { + StripOutcome::Stripped(remaining) => remaining, + StripOutcome::NoBlock => return Ok(RemoveOutcome::NoBlock), + StripOutcome::Malformed => return Ok(RemoveOutcome::Malformed), + }; + std::fs::write(rc_path, remaining).context(WriteSnafu { path: rc_path })?; + Ok(RemoveOutcome::Removed) +} + +/// What [`strip_block`] found in raw file contents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StripOutcome { + /// A well-formed block was present; the payload is the file with the block + /// removed and every byte outside it intact. + Stripped(Vec), + /// No block is present — the signal that the file must not be rewritten. + NoBlock, + /// A begin marker without a matching end marker — the file must not be + /// rewritten (see [`RemoveOutcome::Malformed`]). + Malformed, +} + +/// Pure core of [`remove_block`]: strip the marked block from raw file +/// contents. Operates on bytes so rc files with non-UTF-8 content pass through +/// undamaged. +#[must_use] +pub fn strip_block(contents: &[u8]) -> StripOutcome { + let begin = BLOCK_BEGIN.as_bytes(); + let end = BLOCK_END.as_bytes(); + let mut remaining = Vec::with_capacity(contents.len()); + let mut inside_block = false; + let mut removed = false; + let mut rest = contents; + while !rest.is_empty() { + let line_len = rest + .iter() + .position(|&b| b == b'\n') + .map_or(rest.len(), |i| i + 1); + let (line, tail) = rest.split_at(line_len); + rest = tail; + // Exact whole-line comparison against the markers — the trailing + // newline is the only byte stripped before comparing, so nothing + // resembling a pattern ever runs over user content, and a paperctl + // block's marker line (same glyph, different text) never matches. + let body = line.strip_suffix(b"\n").unwrap_or(line); + if inside_block { + if body == end { + inside_block = false; + } + continue; + } + if body == begin { + inside_block = true; + removed = true; + continue; + } + remaining.extend_from_slice(line); + } + // A begin marker with no matching end marker means the block is malformed; + // refusing to rewrite preserves the user's bytes instead of dropping + // everything after the orphaned marker. + match (removed, inside_block) { + (true, false) => StripOutcome::Stripped(remaining), + (true, true) => StripOutcome::Malformed, + (false, _) => StripOutcome::NoBlock, + } +} + +/// Failure modes for [`remove_block`]. +#[derive(Debug, Snafu)] +#[snafu(module, visibility(pub(crate)))] +#[non_exhaustive] +pub enum RemoveBlockError { + /// Reading the rc file failed (a missing file is + /// [`RemoveOutcome::NoBlock`], not this variant). + #[snafu(display("could not read rc file '{}'", path.display()))] + Read { + /// The rc file being read. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + /// Writing the edited rc file back failed (read-only file, full disk). + #[snafu(display("could not write rc file '{}'", path.display()))] + Write { + /// The rc file being rewritten. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn rc_block_removal_preserves_bytes_outside_markers() { + // Given an rc file whose tapesctl sentinel block sits between user + // content: aliases and another tool's block before it, more user + // content after it + let dir = tempfile::tempdir().unwrap(); + let rc = dir.path().join(".zshrc"); + let before = "# user aliases\n\ + alias ll='ls -al'\n\ + # >>> conda initialize >>>\n\ + . /opt/conda/etc/profile.d/conda.sh\n\ + # <<< conda initialize <<<\n\ + export EDITOR=vim\n"; + let after = "\n# added by another tool\neval \"$(direnv hook zsh)\"\n"; + let block = format!("{BLOCK_BEGIN}\nexport PATH=\"$HOME/.local/bin:$PATH\"\n{BLOCK_END}\n"); + std::fs::write(&rc, format!("{before}{block}{after}")).unwrap(); + + // When the sentinel block is removed + let removed = remove_block(&rc).unwrap(); + + // Then a block was found, and every byte outside the markers survives + // untouched + assert_eq!(removed, RemoveOutcome::Removed); + let remaining = std::fs::read(&rc).unwrap(); + assert_eq!(remaining, format!("{before}{after}").into_bytes()); + } + + #[test] + fn a_paperctl_block_in_the_same_file_is_left_alone() { + // Given an rc file carrying both CLIs' blocks — the state of anyone + // who installed paperctl and tapesctl. The glyph is shared; only the + // marker text differs, and removal compares whole lines. + let dir = tempfile::tempdir().unwrap(); + let rc = dir.path().join(".zshrc"); + let paper_block = "# > [|o=o|] > paper shell init script > [|o=o|] >\n\ + export PATH=\"$HOME/.local/bin:$PATH\"\n\ + # < [|o=o|] < paper shell init < [|o=o|] <\n"; + let ours = format!("{BLOCK_BEGIN}\nexport PATH=\"$HOME/.local/bin:$PATH\"\n{BLOCK_END}\n"); + std::fs::write(&rc, format!("{paper_block}{ours}")).unwrap(); + + // When tapesctl removes its block + assert_eq!(remove_block(&rc).unwrap(), RemoveOutcome::Removed); + + // Then paperctl's block survives byte-for-byte + assert_eq!(std::fs::read(&rc).unwrap(), paper_block.as_bytes()); + } + + #[test] + fn malformed_block_reports_malformed_and_leaves_file_untouched() { + // Given an rc file whose block has a begin marker but no end — a + // hand-edited or truncated file + let dir = tempfile::tempdir().unwrap(); + let rc = dir.path().join(".zshrc"); + let contents = format!("# user content\n{BLOCK_BEGIN}\nexport PATH=oops\n"); + std::fs::write(&rc, &contents).unwrap(); + + // When removal runs + let outcome = remove_block(&rc).unwrap(); + + // Then the malformed state is reported and the file's bytes are + // untouched — rewriting would drop everything after the marker + assert_eq!(outcome, RemoveOutcome::Malformed); + assert_eq!(std::fs::read(&rc).unwrap(), contents.into_bytes()); + } + + #[test] + fn a_missing_file_is_not_an_error() { + let dir = tempfile::tempdir().unwrap(); + let outcome = remove_block(&dir.path().join("nothing-here")).unwrap(); + assert_eq!(outcome, RemoveOutcome::NoBlock); + } + + #[test] + fn non_utf8_content_outside_the_block_survives() { + // rc files are shell, not necessarily UTF-8 — a latin-1 comment or a + // stray byte must pass through a removal undamaged, which is why the + // stripper works on bytes. + let dir = tempfile::tempdir().unwrap(); + let rc = dir.path().join(".bashrc"); + let mut contents = b"# caf\xe9 alias\n".to_vec(); + contents + .extend_from_slice(format!("{BLOCK_BEGIN}\nexport PATH=x\n{BLOCK_END}\n").as_bytes()); + contents.extend_from_slice(b"# \xff\xfe tail\n"); + std::fs::write(&rc, &contents).unwrap(); + + assert_eq!(remove_block(&rc).unwrap(), RemoveOutcome::Removed); + + let mut expected = b"# caf\xe9 alias\n".to_vec(); + expected.extend_from_slice(b"# \xff\xfe tail\n"); + assert_eq!(std::fs::read(&rc).unwrap(), expected); + } + + #[test] + fn rc_files_honor_xdg_config_home_for_fish() { + // Given a home dir and a distinct XDG_CONFIG_HOME — the state in which + // the installer writes fish's config under the XDG dir + let home = Path::new("/home/u"); + let xdg = Path::new("/xdg/config"); + + // When the rc-file set is derived + let files = rc_files_under(home, Some(xdg)); + + // Then the fish path lives under XDG_CONFIG_HOME while bash/zsh stay + // under home + assert!(files.contains(&home.join(".bashrc"))); + assert!(files.contains(&home.join(".zshrc"))); + assert!(files.contains(&xdg.join("fish").join("config.fish"))); + assert!(!files.iter().any(|f| f.starts_with(home.join(".config")))); + } + + #[test] + fn rc_files_fall_back_to_dot_config_when_xdg_unset_or_empty() { + // Given no XDG_CONFIG_HOME (or an empty one — the shell `:-` + // expansion treats both alike) + let home = Path::new("/home/u"); + for xdg in [None, Some(Path::new(""))] { + let files = rc_files_under(home, xdg); + assert!( + files.contains(&home.join(".config").join("fish").join("config.fish")), + "fish path missing for xdg={xdg:?}: {files:?}" + ); + } + } + + /// The value of a single-quoted shell assignment `name='...'`, from the + /// first line that makes one. + fn shell_assignment(contents: &str, name: &str) -> Option { + contents.lines().find_map(|line| { + let rest = line.trim_start().strip_prefix(name)?.strip_prefix("='")?; + rest.strip_suffix('\'').map(str::to_owned) + }) + } + + #[test] + fn sentinel_markers_match_the_installer_script() { + // Given the installer — the one writer of the sentinel block — it must + // agree with the Rust remover byte-for-byte. + // + // Equality against the parsed assignment, NOT `contents.contains(..)`: + // a substring check passes for every drift that matters. Append one + // space to the shell's marker and `contains` still succeeds, while + // both the shell's whole-line awk and the Rust stripper below stop + // matching it — the exact state that leaves a block in a user's + // dotfile that neither tool can ever remove. It would also happily + // match a stale marker sitting in a comment while the live assignment + // had moved on. + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join(".."); + let script = repo_root.join("install.sh"); + let contents = std::fs::read_to_string(&script) + .unwrap_or_else(|e| panic!("could not read {}: {e}", script.display())); + + let begin = shell_assignment(&contents, "RC_BLOCK_BEGIN") + .unwrap_or_else(|| panic!("{} has no RC_BLOCK_BEGIN='..'", script.display())); + let end = shell_assignment(&contents, "RC_BLOCK_END") + .unwrap_or_else(|| panic!("{} has no RC_BLOCK_END='..'", script.display())); + + assert_eq!( + begin, + BLOCK_BEGIN, + "begin marker drifted from {}", + script.display() + ); + assert_eq!( + end, + BLOCK_END, + "end marker drifted from {}", + script.display() + ); + } + + #[test] + fn the_marker_pin_catches_a_trailing_space() { + // The pin's own regression test: the drift it exists to catch is + // invisible to a substring check, so prove the parse-and-compare + // notices it. + let drifted = "RC_BLOCK_BEGIN='# > [|o=o|] > tapesctl path > [|o=o|] > '\n"; + let parsed = shell_assignment(drifted, "RC_BLOCK_BEGIN").unwrap(); + assert!( + drifted.contains(BLOCK_BEGIN), + "a substring check would pass here" + ); + assert_ne!(parsed, BLOCK_BEGIN, "the parse-and-compare must not"); + } +} diff --git a/crates/tapesctl/src/uninstall.rs b/crates/tapesctl/src/uninstall.rs new file mode 100644 index 0000000..28415b1 --- /dev/null +++ b/crates/tapesctl/src/uninstall.rs @@ -0,0 +1,637 @@ +//! `tapesctl uninstall` — remove the binary, the installer's rc block, and +//! this tool's local state. +//! +//! The installer writes into two places a user did not choose by hand: a +//! directory on their `PATH` and a sentinel block in their shell rc file. +//! Neither should be something only a hand-edit can take back, which is what +//! this command exists for. +//! +//! Destruction is sequenced so an *interruption* leaves a tool that can +//! retry: the self-unlink is dead last — safe on Unix because the running +//! process keeps its inode until it exits. A step that *fails* is a warning +//! naming the leftover path, not an abort: the run continues to the end, +//! trading a guaranteed retry tool for finishing everything that can finish. +//! +//! What is deliberately *not* removed is anything a harness owns: a +//! Codex plugin registration lives in the harness's own config file, and +//! `tapesctl plugin uninstall` is the command that speaks that contract. + +use std::io::Write; +use std::path::{Path, PathBuf}; + +use snafu::{ResultExt, Snafu}; + +use crate::install_layout::InstallLayout; +use crate::rc_block::{self, RemoveOutcome}; + +/// The installer command, printed as the remediation when the install +/// directory cannot be mutated. Matches the documented one-liner. +pub const INSTALL_COMMAND: &str = "curl -sSfL https://download.tapes.dev/tapesctl/install | bash"; + +/// The local state directories uninstall removes, injected rather than +/// resolved. +/// +/// `Machine::resolve` refuses to read the real environment under `cfg(test)` — +/// for good reason, see `crate::machine` — so the ambient lookup happens once +/// at the command boundary in [`run`] and every step below takes explicit +/// paths. A `None` means "this machine names no such location", which is a +/// nothing-to-do, not a failure. +#[derive(Debug, Clone, Default)] +pub struct PurgePaths { + /// `~/.tapes` — the configuration directory. + pub config_dir: Option, + /// The cassette surface cache directory — the one this crate derived, never + /// an environment-supplied one (see [`crate::cassette::cache::owned_cache_dir`]). + pub cache_dir: Option, + /// A cache location the environment overrode us to. Reported, never + /// removed: it names a directory the user chose, which may hold anything. + pub cache_dir_override: Option, + /// Shell rc files that may carry the installer's sentinel block. + pub rc_files: Vec, +} + +/// Run `tapesctl uninstall`, resolving every ambient location first. +pub fn run(assume_yes: bool) -> Result<(), UninstallError> { + // Optional, not a precondition. `from_current_exe` fails when + // `current_exe()` errors (no /proc in a minimal container) or when + // canonicalization does (an unreadable path component; on Linux, a binary + // whose file was already unlinked). None of that is a reason to leave the + // config, the cache, and the user's rc block in place — there is simply no + // binary location to act on, so the rest of the teardown still runs. + let layout = InstallLayout::from_current_exe().ok(); + let paths = PurgePaths { + config_dir: crate::machine::Machine::resolve() + .ok() + .and_then(|machine| machine.tapes_config_path().parent().map(Path::to_path_buf)), + cache_dir: crate::cassette::cache::owned_cache_dir(), + cache_dir_override: crate::cassette::cache::cache_dir_override(), + rc_files: rc_block::known_rc_files(), + }; + run_with( + &mut std::io::stdout(), + &mut std::io::stderr(), + &mut std::io::stdin().lock(), + layout.as_ref(), + &paths, + assume_yes, + ) +} + +/// Command core with every seam injected: the output writer, the confirmation +/// reader, the install layout, and the state paths. +/// +/// Step order is deliberate — state first, dotfile next, binary last — so an +/// interruption at any point leaves a working `tapesctl` that can be run again +/// to finish the job. +// Six clears clippy's stock threshold (7) but not the forest dev +// environment's clippy.toml (too-many-arguments-threshold = 5). Every +// parameter is a deliberate test seam — the two output streams are separate +// precisely so a test can assert the prompt is not on stdout — and a struct +// would only rename them. +#[allow(clippy::too_many_arguments)] +pub fn run_with( + out: &mut W, + err: &mut E, + input: &mut R, + layout: Option<&InstallLayout>, + paths: &PurgePaths, + assume_yes: bool, +) -> Result<(), UninstallError> +where + W: Write, + E: Write, + R: std::io::BufRead, +{ + use uninstall_error::WriteSnafu; + + if !assume_yes && !confirm(err, input, layout, paths)? { + writeln!(err, "Nothing was removed.").context(WriteSnafu)?; + return Ok(()); + } + + remove_dir_step(out, paths.config_dir.as_deref(), "configuration")?; + remove_dir_step(out, paths.cache_dir.as_deref(), "cassette cache")?; + if let Some(overridden) = paths.cache_dir_override.as_deref() { + // Named, not removed. The variable points at a directory the user + // chose; recursively deleting whatever it happens to name is not a + // liberty an uninstall gets to take. + writeln!( + out, + "Note: {} is set, so a cache may also live at {}.\n\ + Left alone — remove it yourself if you want it gone.", + crate::cassette::cache::CACHE_DIR_ENV, + overridden.display() + ) + .context(WriteSnafu)?; + } + remove_rc_blocks_step(out, &paths.rc_files)?; + writeln!( + out, + "\nHarness-side capture plugins are not touched — a plugin\n\ + registration lives in the harness's own config. Remove one with:\n\ + \x20 tapesctl plugin uninstall " + ) + .context(WriteSnafu)?; + match layout { + Some(layout) => remove_own_binary_step(out, layout)?, + // No resolvable binary location. Everything else is gone, and saying + // so is better than a silent partial teardown the user cannot see. + None => writeln!( + out, + "! could not work out where this binary lives, so it was left in \ + place; remove it yourself." + ) + .context(WriteSnafu)?, + } + writeln!(out, "\nAll local tapesctl state removed.").context(WriteSnafu) +} + +/// Ask before destroying anything, naming the binary that will go. +/// +/// A reader that is at EOF (a pipe, CI) yields no line, which is treated as a +/// decline: an uninstall that proceeds because nobody was there to say no is +/// the one outcome this prompt exists to prevent. +fn confirm( + err: &mut E, + input: &mut R, + layout: Option<&InstallLayout>, + paths: &PurgePaths, +) -> Result +where + E: Write, + R: std::io::BufRead, +{ + use uninstall_error::WriteSnafu; + + // Every target enumerated, because two of them are recursive deletes and + // "tapesctl's local state" is not a description anyone can check before + // typing y. + writeln!(err, "This will remove:").context(WriteSnafu)?; + for (what, path) in [ + ("binary", layout.map(InstallLayout::tapesctl_path)), + ("configuration", paths.config_dir.as_deref()), + ("cassette cache", paths.cache_dir.as_deref()), + ] { + if let Some(path) = path { + writeln!(err, " {what:<15} {}", path.display()).context(WriteSnafu)?; + } + } + for rc in &paths.rc_files { + writeln!( + err, + " {:<15} the tapesctl block in {}", + "PATH block", + rc.display() + ) + .context(WriteSnafu)?; + } + write!(err, "Continue? [y/N] ").context(WriteSnafu)?; + err.flush().context(WriteSnafu)?; + + let mut answer = String::new(); + if input.read_line(&mut answer).unwrap_or(0) == 0 { + writeln!(err).context(WriteSnafu)?; + return Ok(false); + } + Ok(matches!( + answer.trim().to_ascii_lowercase().as_str(), + "y" | "yes" + )) +} + +/// Remove one state directory, warn-and-continue. +/// +/// A missing directory is silent success — that is the state uninstall is +/// trying to reach, and reporting it as a removal would be a lie. +fn remove_dir_step(out: &mut W, dir: Option<&Path>, what: &str) -> Result<(), UninstallError> +where + W: Write, +{ + use uninstall_error::WriteSnafu; + + let Some(dir) = dir else { + return Ok(()); + }; + match std::fs::remove_dir_all(dir) { + Ok(()) => writeln!(out, "Removed {what} at {}", dir.display()).context(WriteSnafu), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => { + writeln!(out, "! could not remove {what} at {}: {e}", dir.display()).context(WriteSnafu) + } + } +} + +/// Remove the installer's sentinel block from every candidate rc file. +/// +/// A read-only rc file (nix, home-manager) is a warning line, not an abort: the +/// binary still goes, and the block is inert once the binary it guards on is +/// gone. A malformed block is reported specifically, because leaving it is the +/// deliberate choice — rewriting would drop everything after the orphaned +/// marker. +fn remove_rc_blocks_step(out: &mut W, rc_files: &[PathBuf]) -> Result<(), UninstallError> +where + W: Write, +{ + use uninstall_error::WriteSnafu; + + for rc_path in rc_files { + match rc_block::remove_block(rc_path) { + Ok(RemoveOutcome::Removed) => { + writeln!(out, "Removed the PATH block from {}", rc_path.display()) + .context(WriteSnafu)?; + } + Ok(RemoveOutcome::NoBlock) => {} + Ok(RemoveOutcome::Malformed) => { + writeln!( + out, + "! {} has a begin marker with no matching end; left it alone \ + rather than risk dropping what follows it", + rc_path.display() + ) + .context(WriteSnafu)?; + } + Err(e) => { + writeln!(out, "! could not edit {}: {e}", rc_path.display()).context(WriteSnafu)?; + } + } + } + Ok(()) +} + +/// Unlink the running `tapesctl` binary itself — the final destructive act. +/// +/// When the install directory is not writable (an unmigrated root-owned +/// install), this refuses without escalating and prints both the installer +/// command and the manual `rm`. Reinstalling must not be the only path out. +fn remove_own_binary_step(out: &mut W, layout: &InstallLayout) -> Result<(), UninstallError> +where + W: Write, +{ + use uninstall_error::WriteSnafu; + + let binary = layout.tapesctl_path(); + // unlink needs write permission on the containing directory, not the file — + // an unwritable directory means an unmigrated root-owned install, and the + // fix is the installer, never sudo from inside the binary. + if let Err(e) = layout.ensure_writable() { + writeln!( + out, + "! could not remove tapesctl at {}: {e}", + binary.display() + ) + .context(WriteSnafu)?; + writeln!( + out, + " Re-run the installer to migrate to a user-owned install: {INSTALL_COMMAND}" + ) + .context(WriteSnafu)?; + writeln!(out, " Or remove the binary yourself:").context(WriteSnafu)?; + writeln!(out, " sudo rm {}", binary.display()).context(WriteSnafu)?; + return Ok(()); + } + // The upgrade machinery's residue goes with the binary: a staging file an + // interrupted upgrade left behind (its sweep runs only on the *next* + // upgrade, and there will not be one) and the pipeline lock file. Both + // removals are quiet best-effort — neither file existing is the normal + // case. + for residue in [ + layout.staging_path().to_path_buf(), + crate::upgrade::artifact::upgrade_lock_path(layout), + ] { + match std::fs::remove_file(&residue) { + Ok(()) => writeln!(out, "Removed upgrade residue at {}", residue.display()) + .context(WriteSnafu)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => writeln!(out, "! could not remove {}: {e}", residue.display()) + .context(WriteSnafu)?, + } + } + match std::fs::remove_file(binary) { + Ok(()) => writeln!(out, "Removed tapesctl at {}", binary.display()).context(WriteSnafu), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => writeln!( + out, + "! could not remove tapesctl at {}: {e}", + binary.display() + ) + .context(WriteSnafu), + } +} + +/// Failure modes for [`run`]. +/// +/// Deliberately short: every removal step warns and continues, so only failures +/// that make the command impossible to *start* — resolving the layout — or that +/// make its report unreadable are representable here. +#[derive(Debug, Snafu)] +#[snafu(module, visibility(pub(crate)))] +#[non_exhaustive] +pub enum UninstallError { + /// The install layout could not be resolved from the running executable. + #[snafu(display("could not resolve the install layout"))] + ResolveLayout { + /// Underlying layout-construction failure. + source: crate::install_layout::InstallLayoutError, + }, + /// The output writer rejected our bytes. + #[snafu(display("could not write uninstall output"))] + Write { + /// Underlying I/O failure. + source: std::io::Error, + }, +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use crate::rc_block::{BLOCK_BEGIN, BLOCK_END}; + + /// Drive `run_with` with the streams tests care about, returning + /// `(stdout, stderr)`. + fn run_capturing( + layout: Option<&InstallLayout>, + paths: &PurgePaths, + input: &str, + assume_yes: bool, + ) -> (String, String) { + let (mut out, mut err) = (Vec::new(), Vec::new()); + let mut input = std::io::Cursor::new(input.as_bytes().to_vec()); + run_with(&mut out, &mut err, &mut input, layout, paths, assume_yes).unwrap(); + ( + String::from_utf8(out).unwrap(), + String::from_utf8(err).unwrap(), + ) + } + + /// A tempdir install: a real binary file plus the layout over it. + fn layout_in(dir: &Path) -> InstallLayout { + let binary = dir.join("tapesctl"); + std::fs::write(&binary, b"#!/bin/sh\n").unwrap(); + InstallLayout::from_exe_path(&binary).unwrap() + } + + fn rc_with_block(path: &Path) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write( + path, + format!("# mine\n{BLOCK_BEGIN}\nexport PATH=x\n{BLOCK_END}\n"), + ) + .unwrap(); + } + + #[test] + fn a_full_uninstall_removes_state_the_block_and_the_binary() { + // Given an install with configuration, a cache, and a shell rc file + // carrying the installer's block + let home = tempfile::tempdir().unwrap(); + let install = tempfile::tempdir().unwrap(); + let layout = layout_in(install.path()); + let config_dir = home.path().join(".tapes"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write(config_dir.join("config.toml"), b"api-url = 'x'").unwrap(); + let cache_dir = home.path().join("cache").join("tapesctl").join("cassettes"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let rc = home.path().join(".zshrc"); + rc_with_block(&rc); + // Plus the upgrade machinery's residue: the staging and lock files an + // interrupted upgrade could leave in the install directory. + let staging = layout.staging_path().to_path_buf(); + let lock = crate::upgrade::artifact::upgrade_lock_path(&layout); + std::fs::write(&staging, b"partial").unwrap(); + std::fs::write(&lock, b"").unwrap(); + let paths = PurgePaths { + config_dir: Some(config_dir.clone()), + cache_dir: Some(cache_dir.clone()), + cache_dir_override: None, + rc_files: vec![rc.clone()], + }; + + // When uninstall runs with confirmation skipped + let (out, _) = run_capturing(Some(&layout), &paths, "", /* assume_yes */ true); + + // Then every artifact is gone, the user's own rc content survives, and + // the report names the harness-plugin caveat + assert!(!config_dir.exists(), "config dir should be gone"); + assert!(!cache_dir.exists(), "cache dir should be gone"); + assert!(!layout.tapesctl_path().exists(), "binary should be gone"); + assert!(!staging.exists(), "staging residue should be gone"); + assert!(!lock.exists(), "lock residue should be gone"); + assert_eq!(std::fs::read_to_string(&rc).unwrap(), "# mine\n"); + let s = out; + assert!(s.contains("plugin uninstall"), "stdout: {s}"); + assert!( + s.contains("All local tapesctl state removed"), + "stdout: {s}" + ); + } + + #[test] + fn declining_the_prompt_removes_nothing() { + // Given an install and a user who answers no + let install = tempfile::tempdir().unwrap(); + let layout = layout_in(install.path()); + let home = tempfile::tempdir().unwrap(); + let rc = home.path().join(".zshrc"); + rc_with_block(&rc); + let paths = PurgePaths { + rc_files: vec![rc.clone()], + ..PurgePaths::default() + }; + + let (_, err) = run_capturing(Some(&layout), &paths, "n\n", false); + + // Then the binary and the block are both still there + assert!(layout.tapesctl_path().exists()); + assert!(std::fs::read_to_string(&rc).unwrap().contains(BLOCK_BEGIN)); + assert!(err.contains("Nothing was removed"), "stderr: {err}"); + } + + #[test] + fn no_answer_at_all_is_a_decline() { + // A pipe with nothing in it — CI, or a `///`. +const BINARY_OBJECT: &str = "tapesctl"; + +/// Suffix appended to [`BINARY_OBJECT`] for its published digest object. +const CHECKSUM_SUFFIX: &str = ".sha256"; + +/// Bound on retries when exec of the freshly staged binary races an overlay +/// filesystem's post-close writeback (see [`probe_staged_binary`]). Capped so a +/// genuinely un-execable artifact still fails promptly. +const MAX_PROBE_SPAWN_ATTEMPTS: u32 = 50; + +/// Delay between [`probe_staged_binary`] spawn retries — 50 × 20ms bounds the +/// transient-ETXTBSY wait at ~1s. +const PROBE_SPAWN_RETRY_DELAY: Duration = Duration::from_millis(20); + +/// Wall-clock bound on the sanity probe. +/// +/// The probe is the one step that *executes code the bucket supplied*, so it is +/// the one step that must not be allowed to run forever: an artifact that +/// blocks — an infinite loop, a `version` path that dials a black-holed host — +/// would otherwise hang the upgrade with an executable staging file on disk, +/// where a Ctrl-C skips `Drop` and leaves it behind. `version` is a constant +/// print, so anything beyond a couple of seconds is already pathological. +const PROBE_TIMEOUT: Duration = Duration::from_secs(10); + +/// Cap on probe output retained for the version check and error messages. +/// +/// `Command::output()` buffers without limit, so a chatty artifact could +/// exhaust memory in the updater. The version line is the first thing printed; +/// 64 KiB is far past any honest `version` output. +const MAX_PROBE_OUTPUT_BYTES: usize = 64 * 1024; + +/// Which published build `tapesctl upgrade` should install. +/// +/// Parse-don't-validate: a pinned version travels as a parsed [`Versioning`], +/// never as a raw string, so downstream code can compare and format it without +/// re-validating. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpgradeTarget { + /// The newest published release (`latest/` prefix). The default when the + /// user names no target. + Latest, + /// The rolling nightly build (`nightly/` prefix). Nightly is not an + /// orderable version, so this target skips version comparison and always + /// downloads. + Nightly, + /// An explicit release version (`v/` prefix). Downgrades are + /// allowed — the pipeline is direction-agnostic. + Pinned(Versioning), +} + +impl UpgradeTarget { + /// Parse a user-supplied target spec. + /// + /// `latest` and `nightly` select the corresponding rolling targets; + /// anything else must parse as a version (a leading `v` is accepted and + /// stripped) and becomes [`UpgradeTarget::Pinned`]. Non-version strings are + /// a typed parse error, so an [`UpgradeTarget`] can never hold junk. + pub fn parse(spec: &str) -> Result { + use parse_target_error::*; + + let trimmed = spec.trim(); + match trimmed { + "latest" => return Ok(Self::Latest), + "nightly" => return Ok(Self::Nightly), + _ => {} + } + // `versions` would hold junk like "not-a-version" as a `Mess`, so + // require a numeric first component before treating the spec as a + // version. + let bare = trimmed.strip_prefix('v').unwrap_or(trimmed); + let version = Versioning::new(bare) + .filter(|v| v.nth(0).is_some()) + .context(InvalidSpecSnafu { spec })?; + Ok(Self::Pinned(version)) + } + + /// The bucket prefix this target downloads from: `latest`, `nightly`, or + /// `v`. + /// + /// The `v` is re-added after parsing, matching how release artifacts are + /// uploaded — so `--version 0.7.0` and `--version v0.7.0` reach the same + /// objects. + pub(super) fn bucket_prefix(&self) -> String { + match self { + Self::Latest => "latest".to_owned(), + Self::Nightly => "nightly".to_owned(), + Self::Pinned(version) => format!("v{version}"), + } + } +} + +/// Build the [`UpgradeTarget`] the clap arguments select: `--nightly` → +/// [`UpgradeTarget::Nightly`], `--version ` → the parsed spec, neither → +/// [`UpgradeTarget::Latest`]. clap's `conflicts_with` keeps both from arriving +/// together. +pub(super) fn target_from_args( + version: Option<&str>, + nightly: bool, +) -> Result { + match version { + Some(spec) => UpgradeTarget::parse(spec), + None if nightly => Ok(UpgradeTarget::Nightly), + None => Ok(UpgradeTarget::Latest), + } +} + +/// Failure modes for [`UpgradeTarget::parse`]. +#[derive(Debug, Snafu)] +#[snafu(module, visibility(pub(crate)))] +#[non_exhaustive] +pub enum ParseTargetError { + /// The spec was neither `latest`, `nightly`, nor a parseable version. + #[snafu(display("'{spec}' is not 'latest', 'nightly', or a version like 'v0.7.0'"))] + InvalidSpec { + /// The offending user input. + spec: String, + }, +} + +/// The bucket's normalized `/` directory names for the running host +/// (`linux`/`darwin`, `amd64`/`arm64`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct BucketPlatform { + /// Bucket OS directory name. + os: &'static str, + /// Bucket architecture directory name. + arch: &'static str, +} + +/// Map the running platform onto [`BucketPlatform`], normalizing Rust's +/// `x86_64`/`aarch64` arch names to the bucket's `amd64`/`arm64`. Hosts the +/// release pipeline does not publish for are a typed error. +pub(super) fn bucket_platform() -> Result { + use super::upgrade_error::*; + + let os = match std::env::consts::OS { + "macos" => "darwin", + "linux" => "linux", + other => { + return UnsupportedPlatformSnafu { + os: other, + arch: std::env::consts::ARCH, + } + .fail(); + } + }; + let arch = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + // `os`, not `std::env::consts::OS`: the OS arm above reports the + // bucket's spelling (`darwin`), so reporting Rust's (`macos`) here + // would give one host two different names depending on which half was + // unsupported. + other => return UnsupportedPlatformSnafu { os, arch: other }.fail(), + }; + Ok(BucketPlatform { os, arch }) +} + +/// Build the URL of one published object: +/// `////`. +fn artifact_url(base_url: &str, prefix: &str, platform: BucketPlatform, object: &str) -> String { + format!( + "{}/{prefix}/{}/{}/{object}", + base_url.trim_end_matches('/'), + platform.os, + platform.arch, + ) +} + +/// The `(binary, digest)` URL pair for one published build: the `tapesctl` +/// object and its `.sha256` beside it. +pub(super) fn artifact_urls( + base_url: &str, + prefix: &str, + platform: BucketPlatform, +) -> (String, String) { + ( + artifact_url(base_url, prefix, platform, BINARY_OBJECT), + artifact_url( + base_url, + prefix, + platform, + &format!("{BINARY_OBJECT}{CHECKSUM_SUFFIX}"), + ), + ) +} + +/// Name of the advisory lock file that serializes upgrade pipelines, inside +/// the install directory next to the staging file. +pub(crate) const UPGRADE_LOCK_FILE_NAME: &str = ".tapesctl.upgrade.lock"; + +/// The upgrade lock file's path for `layout`'s install directory. +pub(crate) fn upgrade_lock_path(layout: &InstallLayout) -> PathBuf { + layout.install_dir().join(UPGRADE_LOCK_FILE_NAME) +} + +/// Held for the lifetime of one upgrade pipeline: an exclusive advisory +/// `flock(2)` on [`UPGRADE_LOCK_FILE_NAME`]. +/// +/// Two pipelines sharing one staging path must never interleave: each run's +/// stale-staging sweep would unlink the other run's live staging file, and the +/// path-resolved commit could then rename a partial, unverified download over +/// the installed binary while printing success. The kernel releases the lock +/// when the holding process exits — however it exits — so a crashed upgrade +/// can never leave the lock stuck. +/// +/// The lock file itself is never unlinked here: removing a lock file another +/// process may be about to open reintroduces the race the lock closes (holder +/// A keeps the old inode while process B creates and locks a fresh one). +/// Uninstall removes it along with the binary. +#[derive(Debug)] +pub(super) struct UpgradeLock { + _file: std::fs::File, +} + +/// Take the exclusive upgrade lock, or refuse because another upgrade holds +/// it. Non-blocking on purpose: the holding run will either finish the job +/// this run was asked to do or leave a state worth looking at — queueing +/// behind it silently helps nobody. +pub(super) fn acquire_upgrade_lock(layout: &InstallLayout) -> Result { + use super::upgrade_error::*; + use snafu::IntoError; + use std::os::fd::AsRawFd; + use std::os::unix::fs::OpenOptionsExt; + + let path = upgrade_lock_path(layout); + let file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .mode(0o600) + .open(&path) + .context(LockSnafu { path: &path })?; + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + let err = std::io::Error::last_os_error(); + if err.kind() == std::io::ErrorKind::WouldBlock { + return AnotherUpgradeRunningSnafu { path }.fail(); + } + return Err(LockSnafu { path }.into_error(err)); + } + Ok(UpgradeLock { _file: file }) +} + +/// Remove a stale staging file left behind by a previous crashed run, so +/// repeated failed upgrades converge instead of accumulating debris. A missing +/// staging file is the normal case and not an error. +pub(super) fn clean_stale_staging(layout: &InstallLayout) -> Result<(), UpgradeError> { + use super::upgrade_error::*; + + // remove_file, not remove_dir_all: the staging path is a file this pipeline + // owns. Anything else squatting there (a directory, say) is not ours to + // destroy recursively — fail instead. + match std::fs::remove_file(layout.staging_path()) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(source).context(CleanStagingSnafu { + path: layout.staging_path(), + }), + } +} + +/// Hash the staged file with SHA-256 and compare against `expected` (lowercase +/// hex). A mismatch is the integrity gate tripping: the staged file is never +/// executed or installed, and the caller's staging guard removes it. +pub(super) fn verify_staged_digest(staging: &Path, expected: &str) -> Result<(), UpgradeError> { + use super::upgrade_error::*; + + let mut file = std::fs::File::open(staging).context(ReadStagingSnafu { path: staging })?; + let mut hasher = Sha256::new(); + std::io::copy(&mut file, &mut hasher).context(ReadStagingSnafu { path: staging })?; + let actual: String = hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + snafu::ensure!( + actual == expected, + ChecksumMismatchSnafu { expected, actual } + ); + Ok(()) +} + +/// Mark the verified staged file executable. Called strictly after +/// [`verify_staged_digest`] succeeds — unverified bytes never gain execute +/// permission. +/// +/// The mode is inherited from the binary being replaced, with the execute bits +/// forced on, rather than hardcoded: the rename installs whatever mode the +/// staged file carries, so a fixed `0o755` would silently widen a deliberately +/// private install (`0o700` on a shared host) to world-readable on every +/// upgrade. `0o755` remains the fallback when the current mode cannot be read, +/// which is what a fresh install would have had anyway. +pub(super) fn make_executable(path: &Path, installed: &Path) -> Result<(), UpgradeError> { + use super::upgrade_error::*; + + let mode = std::fs::metadata(installed) + .map(|meta| meta.permissions().mode() & 0o7777) + .map_or(0o755, |mode| mode | 0o100 | (mode & 0o044) >> 2); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)) + .context(MakeExecutableSnafu { path }) +} + +/// Read a probe pipe to [`MAX_PROBE_OUTPUT_BYTES`], then drain the rest +/// without buffering it. +/// +/// The cap is what bounds the updater's memory against a chatty artifact; the +/// drain is what keeps a chatty-but-terminating artifact from blocking on a +/// full pipe the probe stopped reading. +async fn read_capped(pipe: Option) -> Vec +where + R: tokio::io::AsyncRead + Unpin, +{ + use tokio::io::AsyncReadExt; + + let mut captured = Vec::new(); + if let Some(mut pipe) = pipe { + let mut limited = (&mut pipe).take(MAX_PROBE_OUTPUT_BYTES as u64); + let _ = limited.read_to_end(&mut captured).await; + let _ = tokio::io::copy(&mut pipe, &mut tokio::io::sink()).await; + } + captured +} + +/// Run the staged binary with the `version` argument and return its trimmed +/// stdout. +/// +/// The digest proves the bytes match what was published; the probe catches what +/// the digest can't — a correctly-published artifact for the wrong OS or +/// architecture, which fails to exec or exits nonzero here instead of after it +/// has replaced the install. +pub(super) async fn probe_staged_binary(path: &Path) -> Result { + use super::upgrade_error::*; + use snafu::IntoError; + + // A file that was written, closed, made executable, and immediately exec'd + // can transiently fail with ETXTBSY on overlay filesystems: the kernel + // briefly still counts a writer reference after the descriptor closed. + // Container/CI layers hit this; a real install onto a normal filesystem + // never does. Retry the spawn under a bounded delay so the probe is robust + // either way, while a genuinely un-execable artifact still fails once the + // bound is exhausted. Only the spawn is retried here; the running probe + // is bounded separately below. + let mut attempt = 0; + let mut child = loop { + // The probe runs as the leader of its own process group. SIGKILLing + // the direct child reaches exactly one process; an artifact that + // forked would leave descendants running unsupervised after the + // upgrade reported failure and swept the staging file. Group + // membership is inherited, so the group signal in the timeout arm + // reaches them too. (A descendant that re-groups itself — setsid, a + // double fork — is beyond what any parent can contain without + // OS-level isolation; the group kill is the strongest containment a + // CLI has. Leading its own group also keeps the probe out of the + // terminal's foreground group, so a Ctrl-C reaches tapesctl but not + // the artifact — the cost of a group the parent can kill without + // killing itself.) `kill_on_drop` stays on as the backstop for paths that + // drop the child without reaching that arm, e.g. the whole upgrade + // future being cancelled. + let spawned = tokio::process::Command::new(path) + .arg("version") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .process_group(0) + .kill_on_drop(true) + .spawn(); + match spawned { + Ok(child) => break child, + Err(source) + if source.kind() == std::io::ErrorKind::ExecutableFileBusy + && attempt + 1 < MAX_PROBE_SPAWN_ATTEMPTS => + { + attempt += 1; + tokio::time::sleep(PROBE_SPAWN_RETRY_DELAY).await; + } + Err(source) => return Err(ProbeSpawnSnafu { path }.into_error(source)), + } + }; + + let stdout_pipe = child.stdout.take(); + let stderr_pipe = child.stderr.take(); + let ran = tokio::time::timeout(PROBE_TIMEOUT, async { + let (stdout, stderr) = tokio::join!(read_capped(stdout_pipe), read_capped(stderr_pipe)); + (stdout, stderr, child.wait().await) + }) + .await; + let (stdout, stderr, status) = match ran { + Ok((stdout, stderr, Ok(status))) => (stdout, stderr, status), + // A wait that itself errors is as good as a binary that would not + // run; it shares the spawn variant rather than growing one for a + // path no platform is known to take. + Ok((_, _, Err(source))) => return Err(ProbeSpawnSnafu { path }.into_error(source)), + Err(_) => { + // The child is still owned here, unreaped, so its pid — which + // `process_group(0)` made the group id — cannot have been + // recycled. Kill the whole group, then reap the direct child so + // no zombie outlives the probe; SIGKILL cannot be ignored, so + // the reap completes promptly. A negative pid addresses the + // process group. + if let Some(pid) = child.id() { + unsafe { libc::kill(-(pid as i32), libc::SIGKILL) }; + } + let _ = child.start_kill(); + let _ = child.wait().await; + return ProbeTimedOutSnafu { + path, + timeout: PROBE_TIMEOUT, + } + .fail(); + } + }; + let truncate = |bytes: &[u8]| { + String::from_utf8_lossy(&bytes[..bytes.len().min(MAX_PROBE_OUTPUT_BYTES)]) + .trim() + .to_owned() + }; + snafu::ensure!( + status.success(), + ProbeFailedSnafu { + path, + status: status.code(), + stderr: truncate(&stderr), + } + ); + Ok(truncate(&stdout)) +} + +/// Check that the probed binary reports the version the pipeline resolved. +/// +/// The digest proves the bytes match what the bucket published; this catches +/// what the digest can't — a prefix serving the wrong build (a stale `latest/`, +/// a mispublished pinned version). The probe output is scanned by whitespace +/// token because `tapesctl version` prints a multi-line block — +/// `tapesctl `, the sha, the build date, then the canary — and any +/// token that parses to the same version as `expected` passes. The stamped +/// version carries the commit as build metadata (`v0.7.0+3f2a1b9`), which +/// `versions` ignores for equality, so it matches the bucket's bare `v0.7.0`. +/// +/// Callers skip this for Nightly, which carries no orderable version to compare +/// against — and if `expected` itself is unorderable (impossible for today's +/// Latest/Pinned targets, both parse-gated upstream) there is likewise nothing +/// to compare, so the check passes. +pub(super) fn verify_probed_version( + path: &Path, + probed: &str, + expected: &str, +) -> Result<(), UpgradeError> { + use super::upgrade_error::*; + + let Some(expected_version) = parse_comparable_version(expected) else { + return Ok(()); + }; + let matched = probed + .split_whitespace() + .any(|token| parse_comparable_version(token).is_some_and(|v| v == expected_version)); + snafu::ensure!( + matched, + ProbeVersionMismatchSnafu { + path, + expected, + probed: probed.trim(), + } + ); + Ok(()) +} + +/// Flush the staged file to disk, then atomically rename it over the installed +/// binary. +/// +/// The fsync-before-rename ordering means a crash at any point leaves either +/// the old binary or the complete new one — never a torn file. A running +/// process keeps executing its old inode through the swap. +pub(super) fn commit_staged_binary(layout: &InstallLayout) -> Result<(), UpgradeError> { + use super::upgrade_error::*; + + let staging = layout.staging_path(); + let file = std::fs::File::open(staging).context(FsyncSnafu { path: staging })?; + file.sync_all().context(FsyncSnafu { path: staging })?; + drop(file); + std::fs::rename(staging, layout.tapesctl_path()).context(RenameSnafu { + from: staging, + to: layout.tapesctl_path(), + }) +} + +/// Removes the staging file on drop unless the upgrade committed it. +/// +/// Constructed right after the download begins and disarmed only once the +/// staged file has been renamed over the target, so every abort path — early +/// return, `?`, panic — funnels through one cleanup and can never leave a stale +/// `.tapesctl.new` behind. Removal is best-effort: cleanup failure must not +/// mask the error that aborted the upgrade. +#[derive(Debug)] +pub(super) struct StagingGuard { + /// The staging file to remove on drop. + path: PathBuf, + /// Whether drop should still remove the file. + armed: bool, +} + +impl StagingGuard { + /// Guard the staging file at `path`, armed. + pub(super) fn new(path: impl Into) -> Self { + Self { + path: path.into(), + armed: true, + } + } + + /// Disarm after the staged file has been renamed over the target — the file + /// no longer exists under the staging name, and the rename's destination + /// must not be touched. + pub(super) fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for StagingGuard { + fn drop(&mut self) { + if self.armed { + let _ = std::fs::remove_file(&self.path); + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + const PLATFORM: BucketPlatform = BucketPlatform { + os: "darwin", + arch: "arm64", + }; + + #[test] + fn the_upgrade_lock_is_exclusive_and_released_on_drop() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("tapesctl"); + std::fs::write(&exe, b"#!/bin/sh\n").unwrap(); + let layout = InstallLayout::from_exe_path(&exe).unwrap(); + + let held = acquire_upgrade_lock(&layout).unwrap(); + let contested = acquire_upgrade_lock(&layout); + assert!( + matches!(contested, Err(UpgradeError::AnotherUpgradeRunning { .. })), + "got: {contested:?}" + ); + + drop(held); + // Reacquisition can transiently fail under a parallel test run: a + // sibling test's fork duplicates this process's descriptors between + // our flock and the close, and the duplicated description holds the + // lock until that child execs (CLOEXEC then closes it). Bounded + // retry, because the property under test is eventual release, which + // that microsecond window does not contradict. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + match acquire_upgrade_lock(&layout) { + Ok(_) => break, + Err(UpgradeError::AnotherUpgradeRunning { .. }) + if std::time::Instant::now() < deadline => + { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + Err(other) => panic!("lock not released after drop: {other:?}"), + } + } + } + + #[test] + fn the_rolling_targets_parse_by_name() { + assert_eq!( + UpgradeTarget::parse("latest").unwrap(), + UpgradeTarget::Latest + ); + assert_eq!( + UpgradeTarget::parse("nightly").unwrap(), + UpgradeTarget::Nightly + ); + } + + #[test] + fn a_version_parses_with_or_without_its_leading_v() { + // Both spellings must reach the same bucket objects — a user who types + // the tag as they see it in a release page and one who types the bare + // number are asking for the same build. + let with = UpgradeTarget::parse("v0.7.0").unwrap(); + let without = UpgradeTarget::parse("0.7.0").unwrap(); + assert_eq!(with, without); + assert_eq!(with.bucket_prefix(), "v0.7.0"); + } + + #[test] + fn junk_is_refused_rather_than_held_as_a_mess() { + // `versions` would happily keep "not-a-version" as a Mess and let it + // travel all the way to a 404. The numeric-first-component gate is what + // turns it into an error the user can read. + assert!(UpgradeTarget::parse("not-a-version").is_err()); + assert!(UpgradeTarget::parse("").is_err()); + } + + #[test] + fn the_arguments_select_the_target() { + assert_eq!( + target_from_args(None, false).unwrap(), + UpgradeTarget::Latest + ); + assert_eq!( + target_from_args(None, true).unwrap(), + UpgradeTarget::Nightly + ); + assert_eq!( + target_from_args(Some("v0.6.0"), false).unwrap(), + UpgradeTarget::parse("v0.6.0").unwrap() + ); + } + + #[test] + fn artifact_urls_address_the_binary_and_its_digest_sidecar() { + let (binary, digest) = + artifact_urls("https://download.tapes.dev/tapesctl", "latest", PLATFORM); + assert_eq!( + binary, + "https://download.tapes.dev/tapesctl/latest/darwin/arm64/tapesctl" + ); + assert_eq!(format!("{binary}.sha256"), digest); + } + + #[test] + fn a_trailing_slash_on_the_base_url_does_not_double_up() { + let (binary, _) = + artifact_urls("https://download.tapes.dev/tapesctl/", "nightly", PLATFORM); + assert_eq!( + binary, + "https://download.tapes.dev/tapesctl/nightly/darwin/arm64/tapesctl" + ); + } + + #[test] + fn a_digest_mismatch_is_reported_with_both_sides() { + let dir = tempfile::tempdir().unwrap(); + let staged = dir.path().join("staged"); + std::fs::write(&staged, b"the wrong bytes").unwrap(); + + let err = verify_staged_digest(&staged, &"0".repeat(64)).unwrap_err(); + + assert!( + matches!(err, UpgradeError::ChecksumMismatch { .. }), + "got: {err:?}" + ); + // Both digests belong in the message: "mismatch" alone leaves the user + // unable to tell a corrupted download from a mispublished sidecar. + let rendered = err.to_string(); + assert!(rendered.contains(&"0".repeat(64)), "got: {rendered}"); + } + + #[test] + fn a_matching_digest_passes() { + let dir = tempfile::tempdir().unwrap(); + let staged = dir.path().join("staged"); + std::fs::write(&staged, b"payload").unwrap(); + let expected: String = Sha256::digest(b"payload") + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + + assert!(verify_staged_digest(&staged, &expected).is_ok()); + } + + #[test] + fn the_probed_version_matches_through_its_build_metadata() { + // What a stamped release actually prints: the version carries the + // commit as semver build metadata, and the block continues with the + // sha, the date, and the canary. The bucket publishes the bare tag. + let probed = "tapesctl v0.7.0+3f2a1b9\nSha: 3f2a1b9c0d\nBuilt at: unknown\n\ + All in all, just another tape in the stereo"; + assert!(verify_probed_version(Path::new("/staged"), probed, "v0.7.0").is_ok()); + } + + #[test] + fn a_prefix_serving_the_wrong_build_is_caught() { + // The failure the digest cannot see: the bytes are exactly what the + // bucket published, but the bucket published the wrong build behind + // this prefix. + let probed = "tapesctl v0.6.0+aaaaaaa"; + let err = verify_probed_version(Path::new("/staged"), probed, "v0.7.0").unwrap_err(); + assert!( + matches!(err, UpgradeError::ProbeVersionMismatch { .. }), + "got: {err:?}" + ); + } + + #[test] + fn an_unorderable_expectation_skips_the_comparison() { + // Nightly never reaches here, but an expectation that cannot be parsed + // has nothing to compare against — passing is the only honest answer. + assert!(verify_probed_version(Path::new("/staged"), "anything at all", "nightly").is_ok()); + } + + #[test] + fn a_stale_staging_file_is_swept_and_a_missing_one_is_fine() { + let dir = tempfile::tempdir().unwrap(); + let binary = dir.path().join("tapesctl"); + std::fs::write(&binary, b"#!/bin/sh\n").unwrap(); + let layout = InstallLayout::from_exe_path(&binary).unwrap(); + + // Given debris from a previous crashed run + std::fs::write(layout.staging_path(), b"half a download").unwrap(); + clean_stale_staging(&layout).unwrap(); + assert!(!layout.staging_path().exists()); + + // And sweeping again, with nothing there, is not an error — repeated + // failures have to converge rather than accumulate. + assert!(clean_stale_staging(&layout).is_ok()); + } + + #[test] + fn the_staging_guard_cleans_up_unless_disarmed() { + let dir = tempfile::tempdir().unwrap(); + let staged = dir.path().join(".tapesctl.new"); + + // An aborted run: the guard drops armed and takes the file with it + std::fs::write(&staged, b"partial").unwrap(); + drop(StagingGuard::new(&staged)); + assert!(!staged.exists(), "an armed guard should remove the file"); + + // A committed run: the name now belongs to the rename's destination, + // so a disarmed guard must not touch it + std::fs::write(&staged, b"committed").unwrap(); + let mut guard = StagingGuard::new(&staged); + guard.disarm(); + drop(guard); + assert!(staged.exists(), "a disarmed guard must leave the file"); + } + + #[test] + fn committing_replaces_the_binary_atomically() { + let dir = tempfile::tempdir().unwrap(); + let binary = dir.path().join("tapesctl"); + std::fs::write(&binary, b"old").unwrap(); + let layout = InstallLayout::from_exe_path(&binary).unwrap(); + std::fs::write(layout.staging_path(), b"new").unwrap(); + + commit_staged_binary(&layout).unwrap(); + + assert_eq!(std::fs::read(layout.tapesctl_path()).unwrap(), b"new"); + assert!( + !layout.staging_path().exists(), + "the staged name is consumed by the rename" + ); + } + + #[test] + fn the_swap_keeps_a_private_install_private() { + // Given a deliberately private install — 0o700 on a shared host + let dir = tempfile::tempdir().unwrap(); + let installed = dir.path().join("tapesctl"); + std::fs::write(&installed, b"old").unwrap(); + std::fs::set_permissions(&installed, std::fs::Permissions::from_mode(0o700)).unwrap(); + let staged = dir.path().join(".tapesctl.new"); + std::fs::write(&staged, b"new").unwrap(); + + // When the staged replacement is marked executable + make_executable(&staged, &installed).unwrap(); + + // Then it carries the install's mode, not a hardcoded 0o755 — the + // rename installs this mode, so inventing one would quietly publish a + // private binary to every user on the box. + let mode = std::fs::metadata(&staged).unwrap().permissions().mode() & 0o7777; + assert_eq!(mode, 0o700, "got {mode:o}"); + } + + #[test] + fn a_readable_install_gains_the_matching_execute_bits() { + // The ordinary case: 0o644 on disk (a download that was never chmod'd) + // must come out executable for everyone who can read it. + let dir = tempfile::tempdir().unwrap(); + let installed = dir.path().join("tapesctl"); + std::fs::write(&installed, b"old").unwrap(); + std::fs::set_permissions(&installed, std::fs::Permissions::from_mode(0o644)).unwrap(); + let staged = dir.path().join(".tapesctl.new"); + std::fs::write(&staged, b"new").unwrap(); + + make_executable(&staged, &installed).unwrap(); + + let mode = std::fs::metadata(&staged).unwrap().permissions().mode() & 0o7777; + assert_eq!(mode, 0o755, "got {mode:o}"); + } + + #[tokio::test] + async fn a_hanging_artifact_does_not_hang_the_upgrade() { + // The probe executes bucket-supplied code. An artifact that blocks + // forever must not park the upgrade with an executable staging file on + // disk, where a Ctrl-C would skip Drop and leave it behind. + let dir = tempfile::tempdir().unwrap(); + let staged = dir.path().join("staged"); + let pid_file = dir.path().join("probe.pid"); + let descendant_file = dir.path().join("descendant.pid"); + std::fs::write( + &staged, + format!( + "#!/bin/sh\necho $$ > \"{}\"\nsleep 300 &\necho $! > \"{}\"\nexec sleep 300\n", + pid_file.display(), + descendant_file.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let started = tokio::time::Instant::now(); + let err = tokio::time::timeout( + std::time::Duration::from_secs(60), + probe_staged_binary(&staged), + ) + .await + .expect("the probe must return on its own, not via this outer bound") + .unwrap_err(); + + assert!( + matches!(err, UpgradeError::ProbeTimedOut { .. }), + "got: {err:?}" + ); + assert!(started.elapsed() < std::time::Duration::from_secs(30)); + + // And the bucket-supplied processes must be dead, not merely + // abandoned: the dropped future only borrowed the child, so nothing + // dies with the drop — both the direct child and the backgrounded + // descendant die because the timeout arm SIGKILLs the process group + // the probe was spawned into. Reaping is asynchronous, so poll until + // both pids are gone rather than asserting on the first look. + let read_pid = |file: &std::path::Path, who: &str| -> i32 { + std::fs::read_to_string(file) + .unwrap_or_else(|_| panic!("{who} should have started")) + .trim() + .parse() + .unwrap() + }; + let pid = read_pid(&pid_file, "the probe child"); + let descendant = read_pid(&descendant_file, "the probe child's descendant"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + // Signal 0 probes existence without an external `kill` binary, + // which a minimal container may not ship. A zombie still counts + // as existing until it is reaped. + let alive: Vec = [pid, descendant] + .into_iter() + .filter(|&p| unsafe { libc::kill(p, 0) } == 0) + .collect(); + if alive.is_empty() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "probe processes {alive:?} still running after the probe timed out" + ); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + + #[tokio::test] + async fn a_chatty_artifact_is_capped_and_drained_not_deadlocked() { + // Past the cap, output is discarded rather than buffered or left in + // the pipe: a probe that stopped reading would fill the pipe and + // deadlock a chatty-but-terminating artifact into the timeout path. + let dir = tempfile::tempdir().unwrap(); + let staged = dir.path().join("staged"); + std::fs::write( + &staged, + "#!/bin/sh\nyes chatty | head -c 200000\nprintf 'and then some'\n", + ) + .unwrap(); + std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let out = probe_staged_binary(&staged).await.unwrap(); + + assert!(out.len() <= MAX_PROBE_OUTPUT_BYTES, "len: {}", out.len()); + assert!(out.starts_with("chatty"), "unexpected start of output"); + } + + #[tokio::test] + async fn a_binary_that_will_not_execute_fails_the_probe() { + // The wrong-architecture signature: correctly published bytes that + // this host cannot run. Caught before the swap, not after. + let dir = tempfile::tempdir().unwrap(); + let staged = dir.path().join("staged"); + std::fs::write(&staged, b"\x7fELF not really").unwrap(); + make_executable(&staged, &staged).unwrap(); + + let err = probe_staged_binary(&staged).await.unwrap_err(); + + assert!( + matches!( + err, + UpgradeError::ProbeSpawn { .. } | UpgradeError::ProbeFailed { .. } + ), + "got: {err:?}" + ); + } +} diff --git a/crates/tapesctl/src/upgrade/http.rs b/crates/tapesctl/src/upgrade/http.rs new file mode 100644 index 0000000..593ff80 --- /dev/null +++ b/crates/tapesctl/src/upgrade/http.rs @@ -0,0 +1,431 @@ +//! HTTP-facing mechanics for `tapesctl upgrade`: the timeout-configured +//! client, target resolution against the release bucket, and the +//! streamed/capped object transfers. +//! +//! Split from [`super::artifact`], which owns the local file mechanics +//! (staging, digest verification, probing, the swap). Every function upholds +//! the pipeline's abort contract: a failure leaves the installed binary +//! byte-identical. + +use std::io::Write; +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::time::Duration; + +use reqwest::Client; +use snafu::{OptionExt, ResultExt}; +use versions::Versioning; + +use super::artifact::UpgradeTarget; +use super::{LATEST_VERSION_PATH, UpgradeError}; + +/// Connect timeout for every bucket request. The bucket is a CDN, and a connect +/// that has not completed within 2 s is down, not slow. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(2); + +/// Overall per-request timeout for the small metadata objects — the +/// latest-version lookup and the `.sha256` digest, both well under a kilobyte. +const METADATA_TIMEOUT: Duration = Duration::from_secs(2); + +/// Per-read stall timeout, applied client-wide. The binary download +/// deliberately carries NO overall timeout — artifact size and link speed vary +/// too much for one number to be honest, and a hard cap would false-abort big +/// downloads on slow links. A stalled transfer is caught here instead: a +/// connection that delivers no bytes for this long is dead, while a +/// slow-but-moving multi-minute download never trips it. +const READ_STALL_TIMEOUT: Duration = Duration::from_secs(30); + +/// Cap on the latest-version response body. A version label is a handful of +/// bytes; anything larger is not one. +const MAX_VERSION_BODY_BYTES: u64 = 128; + +/// Hops a bucket redirect may take before the transfer is refused. +const MAX_REDIRECTS: usize = 5; + +/// Sanity ceiling on the downloaded artifact. +/// +/// The transfer carries no overall *timeout* by design (see +/// [`READ_STALL_TIMEOUT`]), which leaves size as the only bound — without one, +/// a server streaming indefinitely fills the filesystem the install directory +/// lives on, usually `/` or `$HOME`. Well past any honest artifact: the +/// released binary is tens of megabytes. +const MAX_ARTIFACT_BYTES: u64 = 512 * 1024 * 1024; + +/// Cap on the `.sha256` response body. sha256sum format is ~75 bytes; 1 KiB is +/// generous headroom while still refusing to buffer a misconfigured endpoint's +/// unbounded junk. +const MAX_CHECKSUM_BODY_BYTES: u64 = 1024; + +/// Build the HTTP client the pipeline uses for every bucket request, with the +/// connect and per-read stall timeouts baked in (see [`CONNECT_TIMEOUT`] / +/// [`READ_STALL_TIMEOUT`]). +pub(super) fn build_client() -> Result { + use super::upgrade_error::*; + + Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .read_timeout(READ_STALL_TIMEOUT) + // Redirects are followed only while they stay on https. reqwest's + // default policy would happily follow https → http, and the digest + // gate is no defense there: anything able to redirect the binary can + // redirect the `.sha256` beside it, since both travel this same + // client. The rest of this crate already refuses redirects outright on + // the tapes API for a related reason — a silently followed redirect + // destroys the diagnosis — so downgrading the transport here would be + // the odd one out. + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if attempt.url().scheme() != "https" { + attempt.error("refusing to follow a redirect off https") + } else if attempt.previous().len() >= MAX_REDIRECTS { + attempt.error("too many redirects") + } else { + attempt.follow() + } + })) + .build() + .context(BuildHttpClientSnafu) +} + +/// Read at most `cap` bytes of `response`'s body; `Ok(None)` when the declared +/// or streamed length exceeds the cap. +async fn read_body_capped( + mut response: reqwest::Response, + cap: u64, +) -> Result>, reqwest::Error> { + if response.content_length().is_some_and(|len| len > cap) { + return Ok(None); + } + let mut body = Vec::new(); + while let Some(chunk) = response.chunk().await? { + // Checked before appending: growing past the cap and then noticing + // would buffer one whole frame beyond what the cap permits. + if body.len() as u64 + chunk.len() as u64 > cap { + return Ok(None); + } + body.extend_from_slice(&chunk); + } + Ok(Some(body)) +} + +/// What resolving an [`UpgradeTarget`] against the bucket decided: the install +/// is already current, or the pipeline should download from `prefix` and +/// announce `version` on success. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Resolution { + /// The installed version already satisfies the target. + AlreadyCurrent { + /// The version that is both installed and targeted. + version: String, + }, + /// A download is warranted. + Download { + /// Bucket prefix to fetch from (`latest`, `nightly`, `v`). + prefix: String, + /// Version label to report as the upgrade's destination. + version: String, + }, +} + +/// Resolve `target` against the bucket at `base_url`. +/// +/// `Latest` fetches the plain-text `latest/version` object and compares it with +/// `current_version`; `Pinned` compares without any network call; `Nightly` +/// always resolves to a download because nightly builds carry no orderable +/// version. +pub(super) async fn resolve_target( + client: &Client, + base_url: &str, + current_version: &str, + target: &UpgradeTarget, +) -> Result { + use super::upgrade_error::*; + + let current = parse_comparable_version(current_version); + match target { + UpgradeTarget::Nightly => Ok(Resolution::Download { + prefix: target.bucket_prefix(), + version: "nightly".to_owned(), + }), + UpgradeTarget::Pinned(version) => { + if current.as_ref() == Some(version) { + // `v`-prefixed like the Latest arm's raw body, so the same + // state does not print two different spellings depending on + // how the user asked for it. + Ok(Resolution::AlreadyCurrent { + version: format!("v{version}"), + }) + } else { + Ok(Resolution::Download { + prefix: target.bucket_prefix(), + version: version.to_string(), + }) + } + } + UpgradeTarget::Latest => { + let url = format!("{}/{LATEST_VERSION_PATH}", base_url.trim_end_matches('/')); + let response = client + .get(&url) + .timeout(METADATA_TIMEOUT) + .send() + .await + .context(FetchLatestVersionSnafu { url: &url })?; + snafu::ensure!( + response.status().is_success(), + LatestVersionStatusSnafu { + url: &url, + status: response.status(), + } + ); + // Capped read: a version object is a handful of bytes, so a bigger + // body is not a version — never buffer it whole. + let body = read_body_capped(response, MAX_VERSION_BODY_BYTES) + .await + .context(FetchLatestVersionSnafu { url: &url })? + .context(MalformedLatestVersionSnafu { + value: format!("(body larger than {MAX_VERSION_BODY_BYTES} bytes)"), + })?; + let raw = String::from_utf8_lossy(&body).trim().to_owned(); + let latest = parse_comparable_version(&raw) + .context(MalformedLatestVersionSnafu { value: &raw })?; + if current == Some(latest) { + Ok(Resolution::AlreadyCurrent { version: raw }) + } else { + Ok(Resolution::Download { + prefix: target.bucket_prefix(), + version: raw, + }) + } + } + } +} + +/// Parse a version label for comparison: trimmed, optional leading `v`, and a +/// required numeric first component. +/// +/// `None` for the labels that identify no orderable version — an unstamped +/// build, a nightly, junk. A `None` on the *installed* side means version +/// comparison cannot rule the download out, so the pipeline downloads, which is +/// the right answer for both a dev build and a nightly. +/// +/// The name is split from its build metadata before the sentinel check, because +/// what a stamped build reports is `nightly+3f2a1b9`, not a bare `nightly` — the +/// commit is what distinguishes one nightly from the next, so it is always +/// there. Without the split the sentinel would never match and the answer would +/// fall through to whether `versions` happens to reject the whole string. +pub(super) fn parse_comparable_version(label: &str) -> Option { + let trimmed = label.trim(); + let name = trimmed.split('+').next().unwrap_or(trimmed); + if matches!(name, "" | "dev" | "nightly") { + return None; + } + Versioning::new(trimmed.strip_prefix('v').unwrap_or(trimmed)).filter(|v| v.nth(0).is_some()) +} + +/// Stream the binary at `url` into the staging file. +/// +/// The staging file is created non-executable: execute permission is only +/// granted after the digest verifies, so a half-written or tampered download is +/// never a runnable file. +pub(super) async fn download_to_staging( + client: &Client, + url: &str, + staging: &Path, +) -> Result<(), UpgradeError> { + use super::upgrade_error::*; + + let mut response = client + .get(url) + .send() + .await + .context(DownloadSnafu { url })?; + snafu::ensure!( + response.status().is_success(), + DownloadStatusSnafu { + url, + status: response.status(), + } + ); + // `create_new` + 0o600, not `File::create`, for three reasons that all bite + // the same file: + // + // * `create_new` fails rather than following a symlink planted at the + // staging path, which would otherwise send the download to the link's + // target and hand the subsequent chmod to that target instead. + // * It also fails when a concurrent upgrade already owns the staging + // name — two runs sharing one fixed path would otherwise interleave, + // letting one verify bytes the other has since replaced. + // * 0o600 keeps the partially-downloaded, not-yet-verified bytes + // unreadable by anyone else and unexecutable by everyone, closing the + // window that `File::create`'s truncate-in-place left open when a + // previous run died between chmod and rename. + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(staging) + .context(WriteStagingSnafu { path: staging })?; + let mut written: u64 = 0; + while let Some(chunk) = response.chunk().await.context(DownloadSnafu { url })? { + written += chunk.len() as u64; + snafu::ensure!( + written <= MAX_ARTIFACT_BYTES, + ArtifactTooLargeSnafu { + url, + cap: MAX_ARTIFACT_BYTES + } + ); + file.write_all(&chunk) + .context(WriteStagingSnafu { path: staging })?; + } + Ok(()) +} + +/// Fetch the published `.sha256` object at `url` and return the expected digest +/// as lowercase hex. +/// +/// The object body is sha256sum format (` `); only the digest +/// field is used. A 404 is [`UpgradeError::MissingChecksum`] — an absent +/// checksum aborts the upgrade rather than skipping verification. +pub(super) async fn fetch_expected_digest( + client: &Client, + url: &str, +) -> Result { + use super::upgrade_error::*; + + let response = client + .get(url) + .timeout(METADATA_TIMEOUT) + .send() + .await + .context(DownloadSnafu { url })?; + // Any non-success answer for the sidecar is treated as "no publishable + // checksum here", not just 404: an S3-compatible bucket without + // `ListBucket` answers 403 for a missing object, and a gateway may answer + // 5xx. All of them mean the same thing to us — we cannot verify — and the + // message that says so is the one worth printing. + snafu::ensure!( + response.status().is_success(), + MissingChecksumSnafu { + url, + status: response.status() + } + ); + // Capped read: a sha256sum line is under 100 bytes, so a bigger body is not + // a checksum — never buffer it whole. + let body = read_body_capped(response, MAX_CHECKSUM_BODY_BYTES) + .await + .context(DownloadSnafu { url })? + .context(MalformedChecksumSnafu { + value: format!("(body larger than {MAX_CHECKSUM_BODY_BYTES} bytes)"), + })?; + let body = String::from_utf8_lossy(&body).into_owned(); + let digest = body + .split_whitespace() + .next() + .unwrap_or_default() + .to_ascii_lowercase(); + snafu::ensure!( + digest.len() == 64 && digest.bytes().all(|b| b.is_ascii_hexdigit()), + MalformedChecksumSnafu { value: body.trim() } + ); + Ok(digest) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn a_stamped_release_compares_equal_to_the_bare_tag() { + // The bucket publishes `v0.7.0`; the binary reports `v0.7.0+3f2a1b9`. + // Build metadata does not affect precedence, so these are the same + // release — which is what makes "already up to date" work at all. + assert_eq!( + parse_comparable_version("v0.7.0+3f2a1b9"), + parse_comparable_version("v0.7.0") + ); + } + + #[test] + fn the_labels_that_name_no_release_are_unorderable() { + // Each of these means "comparison cannot rule the download out". The + // stamped forms carry a commit, which is exactly why the sentinel check + // has to look past the build metadata. + for label in ["", "dev", "nightly", "nightly+3f2a1b9", "not-a-version"] { + assert!( + parse_comparable_version(label).is_none(), + "{label:?} should not be orderable" + ); + } + } + + #[test] + fn an_unstamped_development_build_is_orderable_but_never_current() { + // `0.0.0-dev+sha` parses — it is a prerelease of 0.0.0 — and that is + // fine: it compares unequal to every real release, so a developer + // running `upgrade` downloads rather than being told they are current. + let dev = parse_comparable_version("0.0.0-dev+3f2a1b9").unwrap(); + assert_ne!(Some(dev), parse_comparable_version("v0.7.0")); + } + + #[tokio::test] + async fn a_pinned_target_matching_the_install_needs_no_network() { + // The client points nowhere reachable on purpose: resolving a pinned + // target that equals the installed version must not touch the network. + let client = build_client().unwrap(); + let target = UpgradeTarget::parse("v0.7.0").unwrap(); + + let resolution = resolve_target(&client, "http://127.0.0.1:1", "v0.7.0+abc1234", &target) + .await + .unwrap(); + + assert_eq!( + resolution, + Resolution::AlreadyCurrent { + version: "v0.7.0".to_owned() + } + ); + } + + #[tokio::test] + async fn a_pinned_downgrade_resolves_to_that_version_s_prefix() { + let client = build_client().unwrap(); + let target = UpgradeTarget::parse("v0.6.0").unwrap(); + + let resolution = resolve_target(&client, "http://127.0.0.1:1", "v0.7.0+abc1234", &target) + .await + .unwrap(); + + // Direction-agnostic: a bad release needs an escape hatch. + assert_eq!( + resolution, + Resolution::Download { + prefix: "v0.6.0".to_owned(), + version: "0.6.0".to_owned() + } + ); + } + + #[tokio::test] + async fn nightly_always_downloads_without_asking_the_bucket() { + let client = build_client().unwrap(); + + let resolution = resolve_target( + &client, + "http://127.0.0.1:1", + "nightly+abc1234", + &UpgradeTarget::Nightly, + ) + .await + .unwrap(); + + assert_eq!( + resolution, + Resolution::Download { + prefix: "nightly".to_owned(), + version: "nightly".to_owned() + } + ); + } +} diff --git a/crates/tapesctl/src/upgrade/mod.rs b/crates/tapesctl/src/upgrade/mod.rs new file mode 100644 index 0000000..6eb5946 --- /dev/null +++ b/crates/tapesctl/src/upgrade/mod.rs @@ -0,0 +1,527 @@ +//! Crash-safe self-update: download, verify, probe, and atomically swap the +//! running binary. +//! +//! Resolves an [`UpgradeTarget`] against the release bucket, downloads the +//! published binary and its `.sha256` digest into a staging dotfile, verifies +//! the digest, sanity-probes the staged file, and atomically renames it over +//! the running executable. Every path is derived from [`InstallLayout`], so the +//! pipeline always acts on the binary the user is actually running. +//! +//! Invariants the pipeline is built around: +//! +//! * The staging file lives **inside the install directory** +//! ([`InstallLayout::staging_path`]), never `/tmp`: `rename(2)` is only +//! atomic within one filesystem, and a cross-device "rename" degrades to the +//! corruptible copy+delete this design exists to close off. +//! * Nothing executes the staged file before its digest verifies, and the +//! staged file is only marked executable after verification. The installed +//! binary is only ever replaced by the atomic rename — no truncate-and-write, +//! no copy over the live inode. +//! * A missing published `.sha256` object aborts the upgrade. There is no +//! unverified-download fallback. +//! * Every abort leaves the installed binary byte-identical and the staging +//! file removed; a fresh run also sweeps any staging file a crashed previous +//! run left behind, so repeated failures converge. +//! * No escalation, ever. An unwritable install directory is a clean, typed +//! refusal whose message names the installer as the remediation. +//! * One pipeline at a time: an exclusive `flock(2)` on a lock file in the +//! install directory serializes concurrent `tapesctl upgrade` runs. Two +//! runs sharing one staging path would otherwise unlink each other's +//! verified bytes and could commit a partial download as the live binary. +//! * The probe is a sanity check, not a sandbox. Its containment of the +//! staged binary is best-effort by design: an adversarial bucket able to +//! serve hostile *verified* bytes defeats the pipeline at the swap, not at +//! the probe, so the security boundary is the digest gate — the probe's job +//! is catching honest mistakes (wrong arch, wrong build) before they are +//! installed. + +pub mod artifact; +mod http; + +use std::io::Write; +use std::path::PathBuf; + +use snafu::{ResultExt, Snafu}; + +use crate::install_layout::{EnsureWritableError, InstallLayout, InstallLayoutError}; +use crate::uninstall::INSTALL_COMMAND; +use artifact::{ + StagingGuard, acquire_upgrade_lock, artifact_urls, bucket_platform, clean_stale_staging, + commit_staged_binary, make_executable, probe_staged_binary, target_from_args, + verify_probed_version, verify_staged_digest, +}; +use http::{Resolution, build_client, download_to_staging, fetch_expected_digest, resolve_target}; + +pub use artifact::{ParseTargetError, UpgradeTarget}; + +/// Production release bucket, including the `tapesctl` namespace the artifacts +/// live under. +/// +/// Layout: `///tapesctl` and +/// `///tapesctl.sha256`, where `` is `latest`, +/// `nightly`, or `v`; `` is `linux` or `darwin`; `` is the +/// normalized `amd64` or `arm64`. `latest/version` is the plain-text newest +/// release version, published by the release pipeline in the same call as the +/// binaries. +const DOWNLOAD_BASE_URL: &str = "https://download.tapes.dev/tapesctl"; + +/// Env override for the release bucket base URL. Internal seam, not user +/// surface: it lets the end-to-end test point a real binary at a localhost +/// bucket. Deliberately read from the environment in the production entry +/// ([`run`]) and never a clap flag, so it stays out of help output and out of +/// the supported CLI contract. +pub const DOWNLOAD_BASE_URL_ENV: &str = "TAPESCTL_DOWNLOAD_BASE_URL"; + +/// Path under a base URL of the plain-text latest-version object. +pub(super) const LATEST_VERSION_PATH: &str = "latest/version"; + +/// Successful result of an upgrade run. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpgradeOutcome { + /// The installed binary already matches the target; nothing was downloaded + /// and nothing on disk changed. + AlreadyCurrent { + /// The version that is both installed and targeted. + version: String, + }, + /// The installed binary was atomically replaced. + Upgraded { + /// Version label the binary reported before the swap. + from: String, + /// Version label now installed. + to: String, + }, +} + +/// Run `tapesctl upgrade`: resolve the target from the CLI arguments, drive the +/// swap pipeline, and report the outcome. +/// +/// The bucket base URL is [`DOWNLOAD_BASE_URL`] unless the hidden +/// [`DOWNLOAD_BASE_URL_ENV`] override is set; the current version is the one +/// this binary was stamped with. Already current → prints "already up to date", +/// exits 0, nothing touched. Upgraded → prints `old → new`. +/// +/// Unlike paperctl's, this pipeline ends at the swap: tapesctl runs no daemon +/// and installs no symlink, so there is nothing to re-point or bounce +/// afterwards. A capture already in flight keeps running on its old inode and +/// the next invocation is the new binary. +pub async fn run(version: Option, nightly: bool) -> Result<(), UpgradeCliError> { + use upgrade_cli_error::*; + + let target = target_from_args(version.as_deref(), nightly).context(InvalidTargetSnafu)?; + let layout = InstallLayout::from_current_exe().context(ResolveLayoutSnafu)?; + let base_url = + std::env::var(DOWNLOAD_BASE_URL_ENV).unwrap_or_else(|_| DOWNLOAD_BASE_URL.to_owned()); + + run_reporting( + &mut std::io::stdout(), + &layout, + &base_url, + crate::build_info::version(), + &target, + ) + .await +} + +/// Command core with every seam injected: the output writer, the install +/// layout, the bucket base URL, the version the running binary reports, and the +/// parsed target. +pub async fn run_reporting( + out: &mut W, + layout: &InstallLayout, + base_url: &str, + current_version: &str, + target: &UpgradeTarget, +) -> Result<(), UpgradeCliError> +where + W: Write + Send, +{ + use upgrade_cli_error::*; + + match run_at(layout, base_url, current_version, target) + .await + .context(PipelineSnafu)? + { + UpgradeOutcome::AlreadyCurrent { version } => { + writeln!(out, "tapesctl {version} is already up to date.").context(WriteSnafu) + } + UpgradeOutcome::Upgraded { from, to } => { + writeln!(out, "tapesctl upgraded: {from} → {to}").context(WriteSnafu) + } + } +} + +/// Pipeline core with the production seams injected: the install `layout` (a +/// tempdir layout in tests), the bucket `base_url` (wiremock in tests), and +/// `current_version`, the version the running binary reports. +/// +/// Step order — every step aborts the run with the installed binary +/// byte-identical and the staging file removed: +/// +/// 1. refuse an unwritable install directory (the message names the installer +/// as the fix; no escalation is attempted), +/// 2. take the exclusive pipeline lock — concurrent upgrades sharing one +/// staging path would unlink each other's verified bytes — then sweep any +/// stale staging file a crashed previous run left behind, +/// 3. resolve `target` against the bucket, which may short-circuit to +/// [`UpgradeOutcome::AlreadyCurrent`] without downloading, +/// 4. download the binary to the staging dotfile and fetch its published +/// `.sha256` digest, +/// 5. verify the digest — only then mark the staged file executable, +/// 6. probe the staged file (` version`) to catch artifacts the digest +/// can't (a correctly-published wrong-OS/arch build), and — for targets with +/// an orderable version — check the probed version against the resolved one +/// (a prefix serving the wrong build), +/// 7. fsync the staged file and atomically rename it over the installed binary. +pub async fn run_at( + layout: &InstallLayout, + base_url: &str, + current_version: &str, + target: &UpgradeTarget, +) -> Result { + use upgrade_error::*; + + // The writability refusal happens before any network I/O: an unmigrated + // root-owned install gets its instructions instantly, without a byte of + // download it could never apply. + match layout.ensure_writable() { + Ok(()) => {} + Err(EnsureWritableError::NotWritable { dir }) => return NotWritableSnafu { dir }.fail(), + Err(source) => return Err(source).context(WritableCheckSnafu), + } + // One pipeline at a time. Taken before the sweep: the sweep unlinks the + // shared staging path, which is only safe while this run is its sole + // owner. The kernel drops the lock however this process ends. + let _lock = acquire_upgrade_lock(layout)?; + clean_stale_staging(layout)?; + // Hoisted above the client: a host the release pipeline does not publish + // for can never be served, so asking the bucket first would only add a + // round-trip before the same refusal. + let platform = bucket_platform()?; + + let client = build_client()?; + let (prefix, to_version) = + match resolve_target(&client, base_url, current_version, target).await? { + Resolution::AlreadyCurrent { version } => { + return Ok(UpgradeOutcome::AlreadyCurrent { version }); + } + Resolution::Download { prefix, version } => (prefix, version), + }; + + let (binary_url, digest_url) = artifact_urls(base_url, &prefix, platform); + + let mut guard = StagingGuard::new(layout.staging_path()); + download_to_staging(&client, &binary_url, layout.staging_path()).await?; + let expected = fetch_expected_digest(&client, &digest_url).await?; + verify_staged_digest(layout.staging_path(), &expected)?; + make_executable(layout.staging_path(), layout.tapesctl_path())?; + let probed = probe_staged_binary(layout.staging_path()).await?; + // Nightly carries no orderable version to compare the probe against. + if !matches!(target, UpgradeTarget::Nightly) { + verify_probed_version(layout.staging_path(), &probed, &to_version)?; + } + commit_staged_binary(layout)?; + guard.disarm(); + + Ok(UpgradeOutcome::Upgraded { + from: current_version.to_owned(), + to: to_version, + }) +} + +/// Failure modes for the `tapesctl upgrade` command entry ([`run`]). +#[derive(Debug, Snafu)] +#[snafu(module, visibility(pub(crate)))] +#[non_exhaustive] +pub enum UpgradeCliError { + /// The `--version` argument was not a recognizable target. + #[snafu(display("invalid upgrade target"))] + InvalidTarget { + /// Underlying parse failure. + source: ParseTargetError, + }, + + /// The install layout could not be resolved from the running executable. + #[snafu(display("could not resolve the install layout"))] + ResolveLayout { + /// Underlying layout-construction failure. + source: InstallLayoutError, + }, + + /// The swap pipeline failed; the installed binary is untouched. + #[snafu(display("the upgrade pipeline aborted; the installed binary is untouched"))] + Pipeline { + /// Underlying pipeline failure. + source: UpgradeError, + }, + + /// Could not write progress output. + #[snafu(display("write failed"))] + Write { + /// Underlying I/O failure. + source: std::io::Error, + }, +} + +/// Failure modes for the upgrade pipeline ([`run_at`]). +/// +/// Every variant means the installed binary is still the pre-upgrade file: +/// nothing destructive happens before the final rename, and the rename is +/// atomic. +#[derive(Debug, Snafu)] +#[snafu(module, visibility(pub(crate)))] +#[non_exhaustive] +pub enum UpgradeError { + /// The install directory refuses mutation by the invoking user — the + /// unmigrated root-owned-install state. The remediation lives in the + /// message because this refusal *is* the migration path for old installs; + /// no escalation is ever attempted. + #[snafu(display( + "install directory '{}' is not writable; re-run the installer to migrate: {}", + dir.display(), + INSTALL_COMMAND + ))] + NotWritable { + /// The unwritable install directory. + dir: PathBuf, + }, + + /// Probing install-directory writability failed outright (an I/O error + /// distinct from a clean permission refusal). + #[snafu(display("could not check whether the install directory is writable"))] + WritableCheck { + /// Underlying probe failure. + source: EnsureWritableError, + }, + + /// The running OS/arch has no published artifacts in the bucket. + #[snafu(display("no published builds for {os}/{arch}"))] + UnsupportedPlatform { + /// The running operating system. + os: String, + /// The running architecture. + arch: String, + }, + + /// The HTTP client could not be constructed (TLS backend init — effectively + /// unreachable, but never worth a panic). + #[snafu(display("could not build the HTTP client"))] + BuildHttpClient { + /// Underlying reqwest failure. + source: reqwest::Error, + }, + + /// Removing a stale staging file from a previous crashed run failed. + #[snafu(display("could not remove stale staging file '{}'", path.display()))] + CleanStaging { + /// The staging file being removed. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + + /// The latest-version lookup request failed at the transport level. + #[snafu(display("could not fetch the latest published version from '{url}'"))] + FetchLatestVersion { + /// The latest-version endpoint URL. + url: String, + /// Underlying HTTP failure. + source: reqwest::Error, + }, + + /// The latest-version lookup answered a non-success status. + #[snafu(display("latest-version endpoint '{url}' answered {status}"))] + LatestVersionStatus { + /// The latest-version endpoint URL. + url: String, + /// The HTTP status received. + status: reqwest::StatusCode, + }, + + /// The latest-version body did not parse as a version. + #[snafu(display("latest-version endpoint returned unparseable version {value:?}"))] + MalformedLatestVersion { + /// The unparseable response body. + value: String, + }, + + /// Downloading an artifact failed at the transport level. + #[snafu(display("could not download '{url}'"))] + Download { + /// The artifact URL. + url: String, + /// Underlying HTTP failure. + source: reqwest::Error, + }, + + /// An artifact download answered a non-success status. + #[snafu(display("download of '{url}' answered {status}"))] + DownloadStatus { + /// The artifact URL. + url: String, + /// The HTTP status received. + status: reqwest::StatusCode, + }, + + /// The published `.sha256` object is missing. Its absence aborts the + /// upgrade — an unverifiable binary is never installed or executed. + #[snafu(display( + "no published checksum at '{url}' (answered {status}) — refusing to install \ + an unverifiable binary" + ))] + MissingChecksum { + /// The checksum object URL that did not serve a digest. + url: String, + /// What it answered instead. + status: reqwest::StatusCode, + }, + + /// The artifact exceeded the sanity ceiling mid-transfer. + #[snafu(display("'{url}' exceeded the {cap}-byte artifact ceiling"))] + ArtifactTooLarge { + /// The artifact URL. + url: String, + /// The ceiling it exceeded. + cap: u64, + }, + + /// The `.sha256` object's contents were not a sha256sum-format digest line. + #[snafu(display("published checksum is malformed: {value:?}"))] + MalformedChecksum { + /// The unparseable checksum body. + value: String, + }, + + /// Writing downloaded bytes to the staging file failed. + #[snafu(display("could not write staging file '{}'", path.display()))] + WriteStaging { + /// The staging file being written. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + + /// Reading the staged file back for digest verification failed. + #[snafu(display("could not read staging file '{}'", path.display()))] + ReadStaging { + /// The staging file being read. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + + /// The staged file's digest does not match the published digest. The staged + /// file was never executed and is removed; the installed binary is + /// untouched. + #[snafu(display("sha256 mismatch: expected {expected}, downloaded file hashes to {actual}"))] + ChecksumMismatch { + /// The digest the bucket published. + expected: String, + /// The digest the downloaded bytes actually hash to. + actual: String, + }, + + /// Marking the verified staged file executable failed. + #[snafu(display("could not mark staged file '{}' executable", path.display()))] + MakeExecutable { + /// The staged file. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + + /// The upgrade lock file could not be opened or locked. + #[snafu(display("could not take the upgrade lock at '{}'", path.display()))] + Lock { + /// The lock file. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + + /// Another upgrade holds the pipeline lock right now. + #[snafu(display( + "another tapesctl upgrade is already running (lock '{}' is held); let it finish and retry", + path.display() + ))] + AnotherUpgradeRunning { + /// The held lock file. + path: PathBuf, + }, + + /// The staged binary could not be spawned for the sanity probe (the + /// signature of a wrong-architecture artifact). + #[snafu(display("staged binary '{}' would not execute", path.display()))] + ProbeSpawn { + /// The staged file that failed to exec. + path: PathBuf, + /// Underlying spawn failure. + source: std::io::Error, + }, + + /// The staged binary was spawned but did not finish within the probe + /// bound. The installed binary is untouched and the staged file removed — + /// an artifact that will not answer `version` promptly is not one to + /// install. + #[snafu(display( + "staged binary '{}' did not answer `version` within {timeout:?}", + path.display() + ))] + ProbeTimedOut { + /// The staged file that hung. + path: PathBuf, + /// The bound it exceeded. + timeout: std::time::Duration, + }, + + /// The staged binary ran but did not behave like `tapesctl version`. + #[snafu(display( + "staged binary '{}' failed its sanity probe (exit {status:?}): {stderr}", + path.display() + ))] + ProbeFailed { + /// The staged file that failed the probe. + path: PathBuf, + /// The probe process's exit code, when it exited at all. + status: Option, + /// The probe process's captured stderr. + stderr: String, + }, + + /// The staged binary ran but reported a version other than the one the + /// pipeline resolved — the artifact behind the prefix is not the build it + /// claims to be. The installed binary is untouched. + #[snafu(display( + "staged binary '{}' reports version {probed:?}, expected {expected}", + path.display() + ))] + ProbeVersionMismatch { + /// The staged file that mis-reported its version. + path: PathBuf, + /// The version the pipeline resolved for the target. + expected: String, + /// The probe's actual (trimmed) stdout. + probed: String, + }, + + /// Flushing the staged file to disk before the swap failed. + #[snafu(display("could not fsync staging file '{}'", path.display()))] + Fsync { + /// The staging file being flushed. + path: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, + + /// The atomic rename over the installed binary failed. The installed binary + /// is untouched — the rename either happened completely or not at all. + #[snafu(display("could not rename '{}' over '{}'", from.display(), to.display()))] + Rename { + /// The staged file being renamed. + from: PathBuf, + /// The installed binary being replaced. + to: PathBuf, + /// Underlying I/O failure. + source: std::io::Error, + }, +} diff --git a/crates/tapesctl/tests/self_upgrade.rs b/crates/tapesctl/tests/self_upgrade.rs new file mode 100644 index 0000000..98c9fc3 --- /dev/null +++ b/crates/tapesctl/tests/self_upgrade.rs @@ -0,0 +1,547 @@ +//! Self-upgrade end to end against the real compiled binary. +//! +//! The unit tests in `upgrade::artifact` and `upgrade::http` drive the pipeline +//! piece by piece; this suite is where the REAL `tapesctl` binary upgrades +//! itself. The compiled binary (`CARGO_BIN_EXE_tapesctl`) is copied into a +//! tempdir install layout, a localhost bucket serves the published objects, and +//! `tapesctl upgrade` runs as a child process with the hidden +//! `TAPESCTL_DOWNLOAD_BASE_URL` seam pointed at the bucket. Assertions observe +//! what a user would: the bytes on disk change and the swapped-in binary still +//! answers `tapesctl version`. +//! +//! Two properties keep the suite hermetic and host-safe: +//! +//! * The served artifact is the compiled binary itself with distinguishing +//! bytes appended (Unix executables ignore trailing data), so the post-verify +//! sanity probe passes while the swap stays observable on disk. +//! * The `nightly` target is the natural fit: it downloads unconditionally, so +//! nothing depends on how the dev build's stamped version compares to a fake +//! "latest". +//! +//! HOME points into a tempdir for every child, so a run can never read or write +//! the developer's real `~/.tapes`, and no request ever leaves localhost. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Output; + +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +/// Env var the production entry reads as the bucket base URL override. +/// +/// Duplicated from `upgrade` on purpose: this suite observes the binary's +/// contract from the outside, so a rename in the library must break this string +/// — and this suite — loudly. +const DOWNLOAD_BASE_URL_ENV: &str = "TAPESCTL_DOWNLOAD_BASE_URL"; + +/// Basename of the staging dotfile the pipeline writes beside the install. +/// Every abort must leave none behind. +const STAGING_FILE_NAME: &str = ".tapesctl.new"; + +/// Trailing bytes appended to the compiled binary to build the served artifact +/// — present on disk after the swap, absent before it. +const ARTIFACT_MARKER: &[u8] = b"\n#tapesctl-self-upgrade-e2e-marker\n"; + +/// Absolute path of the compiled `tapesctl` binary under test, as cargo stamps +/// it for integration tests. +fn compiled_tapesctl() -> &'static Path { + Path::new(env!("CARGO_BIN_EXE_tapesctl")) +} + +/// A tempdir "install": a copy of the real compiled binary named `tapesctl`, +/// executable, alone in its own directory — the shape `InstallLayout` derives +/// every other path from. +struct TestInstall { + /// Owns the directory for the test's duration. + dir: tempfile::TempDir, + /// The installed copy that child processes execute. + tapesctl: PathBuf, +} + +impl TestInstall { + /// Copy [`compiled_tapesctl`] into a fresh tempdir, mode 0o755. + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let tapesctl = dir.path().join("tapesctl"); + fs::copy(compiled_tapesctl(), &tapesctl).unwrap(); + fs::set_permissions(&tapesctl, fs::Permissions::from_mode(0o755)).unwrap(); + Self { dir, tapesctl } + } + + /// The staging dotfile's path beside the install. + fn staging(&self) -> PathBuf { + self.dir.path().join(STAGING_FILE_NAME) + } +} + +/// The compiled binary's bytes with a distinguishing suffix appended: still +/// executable (trailing data is ignored on Unix), byte-distinct from the +/// installed copy, and honestly probe-able — no stand-ins. +fn distinguishable_artifact() -> Vec { + let mut bytes = fs::read(compiled_tapesctl()).unwrap(); + bytes.extend_from_slice(ARTIFACT_MARKER); + bytes +} + +/// The bucket's normalized `/` directory names for the host running +/// the tests. +fn host_platform() -> (&'static str, &'static str) { + let os = match std::env::consts::OS { + "macos" => "darwin", + "linux" => "linux", + other => panic!("tests only run on published platforms, not {other}"), + }; + let arch = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + other => panic!("tests only run on published architectures, not {other}"), + }; + (os, arch) +} + +/// Lowercase-hex SHA-256 of `bytes`, matching the digest field of the published +/// sha256sum-format objects. +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest as _, Sha256}; + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +/// What the bucket serves for the `.sha256` sidecar. +enum Sidecar { + /// The digest of the served bytes — the healthy case. + Correct, + /// A syntactically valid digest of something else — a corrupted or + /// tampered download. + Wrong, + /// No object at all: 404, the case that must abort rather than degrade to + /// an unverified install. + Missing, +} + +/// Serve a release bucket over localhost: `///tapesctl` plus +/// its sha256sum-format `.sha256` beside it, for the host's normalized +/// platform. +async fn serve_bucket(prefix: &str, binary: &[u8], sidecar: Sidecar) -> MockServer { + let (os, arch) = host_platform(); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(format!("/{prefix}/{os}/{arch}/tapesctl"))) + .respond_with(ResponseTemplate::new(200).set_body_bytes(binary.to_vec())) + .mount(&server) + .await; + let digest_response = match sidecar { + Sidecar::Correct => ResponseTemplate::new(200) + .set_body_string(format!("{} tapesctl\n", sha256_hex(binary))), + Sidecar::Wrong => ResponseTemplate::new(200) + .set_body_string(format!("{} tapesctl\n", sha256_hex(b"other bytes"))), + Sidecar::Missing => ResponseTemplate::new(404), + }; + Mock::given(method("GET")) + .and(path(format!("/{prefix}/{os}/{arch}/tapesctl.sha256"))) + .respond_with(digest_response) + .mount(&server) + .await; + server +} + +/// Run ` ` to completion as a child process with the bucket +/// override set to `base_url` and HOME isolated into `home`. +fn run_tapesctl(tapesctl: &Path, base_url: &str, home: &Path, args: &[&str]) -> Output { + std::process::Command::new(tapesctl) + .args(args) + .env("HOME", home) + // Linux resolves config under XDG_CONFIG_HOME when set; pin it inside + // the isolated HOME so no host value leaks through. + .env("XDG_CONFIG_HOME", home.join(".config")) + // The cassette cache is the other thing a run can write; keep it in the + // tempdir too rather than in the developer's real cache directory. + .env("TAPESCTL_CACHE_DIR", home.join("cache")) + .env(DOWNLOAD_BASE_URL_ENV, base_url) + .output() + .unwrap() +} + +/// The blocking child-process waits in the test bodies must not park the same +/// thread the wiremock bucket serves from, hence the multi-thread runtime. +#[tokio::test(flavor = "multi_thread")] +async fn the_binary_replaces_itself_end_to_end() { + // Given a tempdir install of the real compiled binary + let install = TestInstall::new(); + let original = fs::read(&install.tapesctl).unwrap(); + + // Given a localhost bucket publishing a byte-distinct nightly artifact with + // its digest beside it + let artifact = distinguishable_artifact(); + assert_ne!( + artifact, original, + "the served artifact must be distinguishable from the install" + ); + let server = serve_bucket("nightly", &artifact, Sidecar::Correct).await; + let home = tempfile::tempdir().unwrap(); + + // When `tapesctl upgrade --nightly` runs as a child pointed at the bucket + let output = run_tapesctl( + &install.tapesctl, + &server.uri(), + home.path(), + &["upgrade", "--nightly"], + ); + + // Then it exits 0 and says what it did + assert!( + output.status.success(), + "upgrade --nightly failed ({:?})\nstdout: {}\nstderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("upgraded"), "stdout: {stdout}"); + + // Then the on-disk binary is exactly the served artifact + let swapped = fs::read(&install.tapesctl).unwrap(); + assert_ne!(swapped, original, "binary on disk was not replaced"); + assert_eq!( + swapped, artifact, + "swapped binary must be exactly the served artifact" + ); + + // Then the swap kept it executable, and left no staging file behind + let mode = fs::metadata(&install.tapesctl) + .unwrap() + .permissions() + .mode(); + assert_ne!(mode & 0o111, 0, "swapped binary must stay executable"); + assert!(!install.staging().exists(), "staging file should be gone"); + + // Then the machine is left with a working tool — the whole point of the + // crash-safe pipeline + let version = run_tapesctl(&install.tapesctl, &server.uri(), home.path(), &["version"]); + assert!( + version.status.success(), + "swapped binary failed `tapesctl version`\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&version.stdout), + String::from_utf8_lossy(&version.stderr), + ); + assert!( + String::from_utf8_lossy(&version.stdout).contains("tapesctl"), + "swapped binary printed no version" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_checksum_mismatch_leaves_the_install_untouched() { + // Given an install, and a bucket whose sidecar disagrees with the bytes it + // serves — a corrupted or tampered download + let install = TestInstall::new(); + let original = fs::read(&install.tapesctl).unwrap(); + let server = serve_bucket("nightly", &distinguishable_artifact(), Sidecar::Wrong).await; + let home = tempfile::tempdir().unwrap(); + + // When the upgrade runs + let output = run_tapesctl( + &install.tapesctl, + &server.uri(), + home.path(), + &["upgrade", "--nightly"], + ); + + // Then it fails, names the mismatch, and the installed bytes are identical + assert!(!output.status.success(), "a bad digest must not succeed"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("sha256 mismatch"), "stderr: {stderr}"); + assert_eq!( + fs::read(&install.tapesctl).unwrap(), + original, + "the installed binary must be byte-identical after an abort" + ); + // And the unverified bytes are not left lying around next to it. + assert!( + !install.staging().exists(), + "the staging file must be cleaned up on abort" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_missing_checksum_aborts_rather_than_installing_unverified() { + // Given a bucket serving a binary with no `.sha256` beside it + let install = TestInstall::new(); + let original = fs::read(&install.tapesctl).unwrap(); + let server = serve_bucket("nightly", &distinguishable_artifact(), Sidecar::Missing).await; + let home = tempfile::tempdir().unwrap(); + + // When the upgrade runs + let output = run_tapesctl( + &install.tapesctl, + &server.uri(), + home.path(), + &["upgrade", "--nightly"], + ); + + // Then it refuses — there is no unverified-download fallback — and the + // install is untouched + assert!( + !output.status.success(), + "a missing checksum must not succeed" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("unverifiable"), "stderr: {stderr}"); + assert_eq!(fs::read(&install.tapesctl).unwrap(), original); + assert!(!install.staging().exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_install_already_at_the_published_version_downloads_nothing() { + // Given a bucket whose `latest/version` names exactly what this build + // reports. The binary under test is a dev build, so the object is written + // to match whatever it says it is — the property under test is the + // comparison, not any particular version string. + let install = TestInstall::new(); + let original = fs::read(&install.tapesctl).unwrap(); + let home = tempfile::tempdir().unwrap(); + + let server = MockServer::start().await; + let reported = { + let out = run_tapesctl(&install.tapesctl, &server.uri(), home.path(), &["version"]); + let stdout = String::from_utf8(out.stdout).unwrap(); + // First line is `tapesctl `; the version is the second token. + stdout + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap() + .to_owned() + }; + Mock::given(method("GET")) + .and(path("/latest/version")) + .respond_with(ResponseTemplate::new(200).set_body_string(format!("{reported}\n"))) + .mount(&server) + .await; + // Deliberately no artifact mounts: reaching for one would 404, so this test + // fails loudly if the short-circuit ever stops short-circuiting. + + // When a bare `tapesctl upgrade` runs + let output = run_tapesctl(&install.tapesctl, &server.uri(), home.path(), &["upgrade"]); + + // Then it exits 0, says so, and touches nothing + assert!( + output.status.success(), + "already-current must exit 0\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("already up to date"), "stdout: {stdout}"); + assert_eq!(fs::read(&install.tapesctl).unwrap(), original); + assert!(!install.staging().exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_stale_staging_file_from_a_crashed_run_is_swept() { + // Given debris beside the install: a partial download a previous crashed + // run left behind. Repeated failures have to converge, not accumulate. + let install = TestInstall::new(); + fs::write(install.staging(), b"half a download from last time").unwrap(); + let artifact = distinguishable_artifact(); + let server = serve_bucket("nightly", &artifact, Sidecar::Correct).await; + let home = tempfile::tempdir().unwrap(); + + // When an upgrade runs + let output = run_tapesctl( + &install.tapesctl, + &server.uri(), + home.path(), + &["upgrade", "--nightly"], + ); + + // Then it succeeds — the stale file did not get in the way of the fresh + // download — and nothing is left over + assert!( + output.status.success(), + "stale staging file blocked the upgrade\nstderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!(fs::read(&install.tapesctl).unwrap(), artifact); + assert!(!install.staging().exists()); +} + +/// A stand-in artifact that reports `version` as `label`. +/// +/// The compiled binary under test reports whatever *it* was stamped with, so a +/// pinned-version upgrade — where the point is that the artifact's version and +/// the requested one must agree — cannot be exercised with a copy of it. The +/// probe runs ` version` and reads stdout, which a script satisfies +/// honestly. +fn artifact_reporting(label: &str) -> Vec { + format!("#!/bin/sh\n[ \"$1\" = version ] && {{ echo 'tapesctl {label}'; exit 0; }}\nexit 2\n") + .into_bytes() +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_pinned_version_installs_that_exact_release() { + // The half of the target surface no other end-to-end test covers: an + // explicit `--version`, resolved without a `latest/version` lookup, whose + // artifact must satisfy the probed-version check to land. + let install = TestInstall::new(); + let artifact = artifact_reporting("v9.9.9+abc1234"); + let server = serve_bucket("v9.9.9", &artifact, Sidecar::Correct).await; + let home = tempfile::tempdir().unwrap(); + + let output = run_tapesctl( + &install.tapesctl, + &server.uri(), + home.path(), + &["upgrade", "--version", "v9.9.9"], + ); + + assert!( + output.status.success(), + "pinned upgrade failed\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("9.9.9"), "stdout: {stdout}"); + assert_eq!(fs::read(&install.tapesctl).unwrap(), artifact); + assert!(!install.staging().exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_prefix_serving_the_wrong_build_aborts_before_the_swap() { + // The failure the digest structurally cannot see: the bytes are exactly + // what the bucket published and hash correctly, but the bucket published + // the wrong build behind this prefix — a stale `latest/`, a mispublished + // tag. Only the probed-version check catches it, and until now nothing + // exercised that check end to end. + let install = TestInstall::new(); + let original = fs::read(&install.tapesctl).unwrap(); + let artifact = artifact_reporting("v0.1.0+0000000"); + let server = serve_bucket("v9.9.9", &artifact, Sidecar::Correct).await; + let home = tempfile::tempdir().unwrap(); + + let output = run_tapesctl( + &install.tapesctl, + &server.uri(), + home.path(), + &["upgrade", "--version", "v9.9.9"], + ); + + assert!( + !output.status.success(), + "a mispublished prefix must not land" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("reports version"), "stderr: {stderr}"); + assert_eq!( + fs::read(&install.tapesctl).unwrap(), + original, + "the installed binary must survive a probe-version mismatch" + ); + assert!(!install.staging().exists()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_second_upgrade_refuses_while_the_first_holds_the_lock() { + // Two overlapping upgrades sharing one staging path must never interleave + // — the loser refuses up front, before any network I/O, with the binary + // untouched. The lock here is held exactly the way a concurrent run would + // hold it: an exclusive flock on the lock file in the install directory. + let outer = tempfile::tempdir().unwrap(); + let bin_dir = outer.path().join("bin"); + fs::create_dir(&bin_dir).unwrap(); + let installed = bin_dir.join("tapesctl"); + fs::copy(compiled_tapesctl(), &installed).unwrap(); + fs::set_permissions(&installed, fs::Permissions::from_mode(0o755)).unwrap(); + let before = fs::read(&installed).unwrap(); + + let lock_path = bin_dir.join(".tapesctl.upgrade.lock"); + let lock = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock_path) + .unwrap(); + let rc = unsafe { + libc::flock( + std::os::fd::AsRawFd::as_raw_fd(&lock), + libc::LOCK_EX | libc::LOCK_NB, + ) + }; + assert_eq!(rc, 0, "test could not take the lock it means to hold"); + + let server = MockServer::start().await; + let home = tempfile::tempdir().unwrap(); + let output = run_tapesctl( + &installed, + &server.uri(), + home.path(), + &["upgrade", "--nightly"], + ); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("already running"), "stderr: {stderr}"); + assert_eq!( + server.received_requests().await.unwrap().len(), + 0, + "the refusal must happen before any network I/O" + ); + assert_eq!(fs::read(&installed).unwrap(), before, "binary untouched"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_unwritable_install_directory_refuses_before_any_download() { + // Given the unmigrated root-owned shape: a directory the invoking user + // cannot write. The bucket is mounted with NO routes at all, so any network + // request the pipeline made would fail differently than the refusal we + // expect — the assertion below is what proves the refusal came first. + let outer = tempfile::tempdir().unwrap(); + let bin_dir = outer.path().join("bin"); + fs::create_dir(&bin_dir).unwrap(); + let installed = bin_dir.join("tapesctl"); + fs::copy(compiled_tapesctl(), &installed).unwrap(); + fs::set_permissions(&installed, fs::Permissions::from_mode(0o755)).unwrap(); + fs::set_permissions(&bin_dir, fs::Permissions::from_mode(0o555)).unwrap(); + + // Root writes through 0o555 (CAP_DAC_OVERRIDE), so the unwritable + // precondition cannot be constructed from mode bits alone. Skip + // rather than assert a refusal the kernel will never produce — + // containerized CI runs as root. + if fs::write(bin_dir.join(".root-probe"), b"").is_ok() { + let _ = fs::remove_file(bin_dir.join(".root-probe")); + return; + } + + let server = MockServer::start().await; + let home = tempfile::tempdir().unwrap(); + + // When an upgrade runs + let output = run_tapesctl( + &installed, + &server.uri(), + home.path(), + &["upgrade", "--nightly"], + ); + + // Then it refuses with the installer named as the way out, and never + // escalates + fs::set_permissions(&bin_dir, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("not writable"), "stderr: {stderr}"); + assert!( + stderr.contains("download.tapes.dev/tapesctl/install"), + "the refusal must name the installer: {stderr}" + ); + assert_eq!( + server.received_requests().await.unwrap().len(), + 0, + "the refusal must happen before any network I/O" + ); +} diff --git a/docs/commands.md b/docs/commands.md index 0663571..6ff1596 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -34,7 +34,8 @@ cannot steer `tapesctl`. **`--api-url` appears in the help of commands that never make an HTTP call** — `config set`, `config get`, `config path`, -`version`, and `plugin uninstall` — because the global flag propagates into +`version`, `upgrade`, `uninstall`, and `plugin uninstall` — because the global +flag propagates into every leaf's help. It is inert there. Its presence in `config`'s help is actively misleading, since the point of `config set api-url` is that you do not have a server configured yet. @@ -46,7 +47,7 @@ Three values, and only three. | code | meaning | |---|---| | `0` | success | -| `1` | a runtime error — one line on stderr, prefixed `tapesctl: ` | +| `1` | a runtime error — a `tapesctl: ` line on stderr, plus a `caused by:` line per underlying cause | | `2` | an argument-parsing error, or help printed because a subcommand was missing | **A bare `tapesctl` prints help and exits `2`.** So does `tapesctl sessions`, @@ -527,19 +528,96 @@ tapesctl version ``` ``` -tapesctl 0.1.0 +tapesctl v0.7.0+3f2a1b9 +Sha: 3f2a1b9c0d4e5f60718293a4b5c6d7e8f9012345 +Built at: 2026-08-13T18:22:04Z All in all, just another tape in the stereo ``` -Both lines are expected; the second is the release smoke test's canary and is -pinned as an exact string. `--version` prints only the first line. +Four lines, and all four are expected; the last is the release smoke test's +canary and is pinned as an exact string. `--version` prints the same block +without it. -**The number is not a release identifier.** It comes from the crate version, -which has never been bumped, while releases are tagged independently — so a -binary from any release reports `0.1.0`. Do not tell anyone to "check your -version with `tapesctl --version`", do not pin documentation to a version the -binary can confirm, and treat `0.1.0` in a bug report as version-less. To -identify a build, record where you got it. +The identity comes from the build that produced the artifact, not from +`Cargo.toml` — the manifest holds a placeholder no release bumps, because a +release is cut by tagging a commit that has already merged, so the source +cannot know its own tag. A release reports `v0.7.0+`, a nightly +`nightly+`, and a local `cargo build` `0.0.0-dev+`. The commit +rides along as semver build metadata, which comparison ignores — that is what +lets `upgrade` recognize a stamped `v0.7.0+3f2a1b9` as the published `v0.7.0`. + +A `0.0.0-dev` in a bug report means a build from source, not a release. + +## upgrade + +```bash +tapesctl upgrade # newest published release +tapesctl upgrade --version v0.6.0 # an exact release, older included +tapesctl upgrade --nightly # the rolling nightly build +``` + +Replaces this binary in place. With no flags it compares the installed version +against the published `latest`, prints `tapesctl is already up to +date.` and exits `0` when they match, and otherwise prints +`tapesctl upgraded: `. + +| flag | default | notes | +|---|---|---| +| `--version ` | the newest release | `v0.6.0` and `0.6.0` are the same target. Downgrades are allowed — a bad release needs an escape hatch | +| `--nightly` | off | always downloads; a nightly carries no orderable version to compare. Conflicts with `--version` | + +**Every failure leaves the binary you had still working.** The order is fixed +and nothing is skipped: the published `.sha256` is compared *before* the +downloaded file is made executable or run; the staged file is then probed with +`version`, which catches a faithfully published wrong-architecture artifact +that a correct digest cannot; its answer is checked against the resolved +version, which catches a prefix serving the wrong build; only then is the file +renamed over the installed binary, atomically. A missing `.sha256` aborts — +there is no unverified-download fallback. Debris from a crashed run is swept at +the start of the next attempt, so repeated failures converge. + +An install in a directory you cannot write — the pre-`$HOME/.local/bin` layout +— refuses before any network request and names the installer as the way to +migrate. `upgrade` never escalates. + +| error | means | +|---|---| +| `install directory '' is not writable; re-run the installer to migrate: …` | an unmigrated root-owned install; refused before downloading | +| `no published checksum at '' (answered ) — refusing to install an unverifiable binary` | no `.sha256` sidecar was served. Any non-success status counts: a bucket without `ListBucket` answers 403 for a missing object | +| `sha256 mismatch: expected , downloaded file hashes to ` | a corrupted or tampered download; nothing was executed | +| `staged binary '' would not execute` | a wrong-architecture artifact | +| `staged binary '' reports version , expected ` | the bucket prefix is serving the wrong build | + +## uninstall + +```bash +tapesctl uninstall # confirms first +tapesctl uninstall -y # no prompt +``` + +Removes, in this order: `~/.tapes`, the cassette cache, the installer's `PATH` +block from `.bashrc` / `.zshrc` / `config.fish`, and finally the binary itself. +The prompt lists every one of those paths before you answer, because two of +them are recursive deletes. + +The cassette cache it removes is the one tapesctl derived for itself, under the +platform cache directory. If `TAPESCTL_CACHE_DIR` is set, that location is +**named in the output and left alone** — the variable points at a directory you +chose, which may hold more than our cache, and an uninstall does not get to +recursively delete it on your behalf. + +Each step warns and continues rather than aborting, so an interruption always +leaves a `tapesctl` that can be run again to finish. The rc-file edit removes +exactly the lines between the installer's sentinel markers and preserves every +byte outside them; a block whose end marker is missing is reported and left +alone, because rewriting would drop whatever follows it. A `paperctl` block in +the same file is never touched. + +Harness-side capture plugins are **not** removed — those are registrations in a +config file the harness owns. Use `tapesctl plugin uninstall `. + +A prompt that reaches end-of-input counts as a decline, so a piped or +``. + ## Two minutes: capture, then read Capture a Claude session. The harness behaves as it would unproxied — its diff --git a/install.sh b/install.sh index 6a6f9b7..74f7f45 100755 --- a/install.sh +++ b/install.sh @@ -1,39 +1,102 @@ #!/bin/bash - -# tapesctl install script for Linux and macOS. +# +# tapesctl install script — downloads a pre-built `tapesctl` binary from +# https://download.tapes.dev and drops it in a user-owned directory. +# This is the "curl | bash" entry point uploaded to the bucket by the +# release pipeline's UploadInstallSh step. +# +# curl -sSfL https://download.tapes.dev/tapesctl/install | bash +# +# Installs to $HOME/.local/bin by default — plain user file I/O end to +# end, no sudo. Because that directory is not on every default PATH, the +# installer writes a guarded PATH export into the detected shell's rc +# file, inside a `[|o=o|]` sentinel block. The one place sudo can still +# appear: when an old root-owned install is found in /usr/local/bin, it +# is removed with a single announced sudo — declining is fine, the +# install still succeeds and the manual removal command is printed. +# # Requirements: # * curl # * uname -# * install -# * sudo (when the install directory is not writable) # * /tmp directory +# * sudo (only to clear an old root-owned install) +# +# Env knobs: +# TAPESCTL_VERSION — "latest" (default), "nightly", or "vX.Y.Z" +# TAPESCTL_INSTALL_DIR — install target (default: $HOME/.local/bin) +# TAPESCTL_BASE_URL — release bucket base URL +# (default: https://download.tapes.dev) +# TAPESCTL_MIGRATE_FROM — old install dir to clear (default: /usr/local/bin) + +set -euo pipefail -set -e +# Everything below runs through main(), defined here and invoked on the very +# last line. bash executes a piped script incrementally, command by command, +# as bytes arrive — without this wrapper a transfer dying between the old +# binary's `rm -f` and the new one's `install` would leave no tapesctl at +# all. A truncated stream now fails to parse the unterminated function and +# executes nothing instead of half an install. +main() { VERSION="${TAPESCTL_VERSION:-latest}" BASE_URL="${TAPESCTL_BASE_URL:-https://download.tapes.dev}" -# Detect OS +# Detect OS — `linux` or `darwin`. Anything else is unsupported; the +# release pipeline only emits these two. OS="$(uname -s | tr '[:upper:]' '[:lower:]')" case "$OS" in linux*) OS="linux" ;; darwin*) OS="darwin" ;; - *) echo "Unsupported OS: $OS"; exit 1 ;; + *) echo "Unsupported OS: $OS" >&2; exit 1 ;; esac -# Detect architecture +# Detect arch and normalize to the bucket layout names (amd64 / arm64). +# On macOS Apple Silicon `uname -m` returns "arm64"; on Linux ARM hosts +# it is usually "aarch64" (some distros still emit "arm64") — both map to +# the same bucket directory. ARCH="$(uname -m)" case "$ARCH" in - x86_64) ARCH="amd64" ;; + x86_64|amd64) ARCH="amd64" ;; aarch64|arm64) ARCH="arm64" ;; - *) echo "Unsupported architecture: $ARCH"; exit 1 ;; + *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; esac -INSTALL_DIR="${TAPESCTL_INSTALL_DIR:-/usr/local/bin}" +# $HOME is dereferenced under `set -u`, and a login shell is not the only +# thing that runs this script — systemd units, minimal containers, and some CI +# runners have no HOME at all. Refuse here, before anything is downloaded, +# rather than aborting after a successful install with an "unbound variable" +# that reads like a bug in the installer. +if [ -z "${TAPESCTL_INSTALL_DIR:-}" ] && [ -z "${HOME:-}" ]; then + echo "Neither TAPESCTL_INSTALL_DIR nor HOME is set; nowhere to install." >&2 + echo "Set one and re-run, e.g. TAPESCTL_INSTALL_DIR=/opt/bin" >&2 + exit 1 +fi +INSTALL_DIR="${TAPESCTL_INSTALL_DIR:-${HOME:-}/.local/bin}" TMP_DIR="$(mktemp -d)" trap 'rm -rf "$TMP_DIR"' EXIT DOWNLOAD_URL="$BASE_URL/tapesctl/$VERSION/$OS/$ARCH/tapesctl" +# Pick a sudo prefix only if the install dir isn't user-writable — which +# the $HOME/.local/bin default never is, so a stock install creates its +# directory with a plain mkdir and no privilege at all. The check walks +# to the nearest *existing* ancestor because the target (and its parent) +# may not exist yet; `mkdir -p` succeeds exactly when that ancestor is +# writable. +needs_privilege_for() { + local dir="$1" + while [ ! -d "$dir" ]; do + dir="$(dirname "$dir")" + done + [ ! -w "$dir" ] +} + +SUDO="" +if needs_privilege_for "$INSTALL_DIR"; then + if command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + fi +fi + echo "Downloading tapesctl $VERSION for $OS/$ARCH ..." curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DIR/tapesctl" @@ -61,13 +124,356 @@ if [ -n "$SHA_TOOL" ]; then fi echo "Installing to $INSTALL_DIR ..." -if [ -w "$INSTALL_DIR" ]; then - install -m 0755 "$TMP_DIR/tapesctl" "$INSTALL_DIR/tapesctl" -else - sudo install -m 0755 "$TMP_DIR/tapesctl" "$INSTALL_DIR/tapesctl" -fi +$SUDO mkdir -p "$INSTALL_DIR" +# Unlink before install: writing onto a live binary's inode fails with +# ETXTBSY on Linux while a tapesctl process is running. Removing the old +# entry first makes the install create a fresh inode; the running process +# keeps its old one until it exits. +$SUDO rm -f "$INSTALL_DIR/tapesctl" +$SUDO install -m 0755 "$TMP_DIR/tapesctl" "$INSTALL_DIR/tapesctl" + +############################################################################### +# PATH block — the sentinel-guarded rc-file write +############################################################################### +# +# $HOME/.local/bin is not on the default PATH (never on macOS, sometimes +# on Linux), so without this block the install would be unreachable. +# Everything written to the user's rc file lives between the `[|o=o|]` +# sentinel markers, and the block is rewritten in place on re-install, +# never duplicated. The markers must match the Rust rc-block remover +# byte-for-byte; a test in crates/tapesctl reads this script to pin them +# together. +# +# The marker text names tapesctl. Both this script and the Rust remover +# compare whole lines, so a paperctl block in the same rc file — same +# glyph, different text — is neither matched nor disturbed. + +RC_BLOCK_BEGIN='# > [|o=o|] > tapesctl path > [|o=o|] >' +RC_BLOCK_END='# < [|o=o|] < tapesctl path < [|o=o|] <' + +detect_shell() { + local shell_name + shell_name="$(basename "${SHELL:-}")" + case "$shell_name" in + bash|zsh|fish) echo "$shell_name" ;; + *) return 1 ;; + esac +} + +# The rc file for shell $1, or failure when there is no home directory to +# resolve one under — reachable when TAPESCTL_INSTALL_DIR was given but HOME +# was not. With no HOME there is no rc file to edit either, so the caller +# degrades to printing the PATH line instead of failing the install. +shell_rc_file() { + local shell_name="$1" + [ -n "${HOME:-}" ] || return 1 + case "$shell_name" in + bash) echo "$HOME/.bashrc" ;; + zsh) echo "$HOME/.zshrc" ;; + fish) echo "${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish" ;; + *) return 1 ;; + esac +} + +# The PATH lines are double-guarded so the block is inert in every state +# it can be encountered in: the `case ":$PATH:"` membership check makes +# re-sourcing (nested shells, repeated logins) a no-op, and the -x +# binary-existence check keeps a block that outlives an uninstall silent. +posix_path_block_body() { + local dir_expr="$1" + printf '%s\n' \ + "if [ -x \"$dir_expr/tapesctl\" ]; then" \ + " case \":\$PATH:\" in" \ + " *\":$dir_expr:\"*) ;;" \ + " *) export PATH=\"$dir_expr:\$PATH\" ;;" \ + " esac" \ + "fi" +} + +fish_path_block_body() { + local dir_expr="$1" + printf '%s\n' \ + "if test -x \"$dir_expr/tapesctl\"" \ + " if not contains -- \"$dir_expr\" \$PATH" \ + " set -gx PATH \"$dir_expr\" \$PATH" \ + " end" \ + "end" +} + +# Classify rc file $1: `none`, `ok`, or `malformed`. +# +# The three answers the Rust remover gives (`rc_block.rs`), computed the same +# way — exact whole-line comparison against both markers. This has to be its +# own step rather than a `grep` for the begin marker: grep matches substrings, +# while the stripper below matches whole lines, and that disagreement is how a +# marker line carrying a trailing space or a CRLF ending ends up "found" by the +# guard, stripped by nothing, and then appended a second time on every install. +rc_block_state() { + awk -v b="$RC_BLOCK_BEGIN" -v e="$RC_BLOCK_END" ' + $0 == b { seen = 1; inblock = 1; next } + inblock && $0 == e { inblock = 0; next } + END { + if (!seen) { print "none" } + else if (inblock) { print "malformed" } + else { print "ok" } + } + ' "$1" +} + +# Print rc file $1 with the sentinel block (markers included) removed. +# +# Only ever called on a file `rc_block_state` called `ok`. A begin marker with +# no matching end would make this discard every line after it — the caller +# refuses that case rather than rewriting, exactly as the Rust remover does. +rc_block_strip() { + awk -v b="$RC_BLOCK_BEGIN" -v e="$RC_BLOCK_END" ' + $0 == b { inblock = 1; next } + inblock { if ($0 == e) inblock = 0; next } + { print } + ' "$1" +} + +# Write body $2 into the sentinel block of rc file $1: an existing block +# is removed first (rewrite, never a second copy). Lines outside the +# markers are preserved (awk normalizes a missing trailing newline on the +# final line — content is untouched, only that byte can appear). +write_rc_block() { + local rc_file="$1" body="$2" tmp state + mkdir -p "$(dirname "$rc_file")" + touch "$rc_file" + state="$(rc_block_state "$rc_file")" + if [ "$state" = "malformed" ]; then + # A begin marker with no matching end. Rewriting would drop everything + # after it — someone's aliases, exports, and whatever else lives below — + # so the file is left exactly as it is and the user is told what to fix. + # Refusing here is what keeps the promise that this script only ever + # touches the lines between its own markers. + echo + echo "warning: $rc_file has a tapesctl begin marker with no matching end." >&2 + echo "Left it untouched rather than risk dropping what follows it." >&2 + echo "Remove the stray line and re-run, or add this to your shell config:" >&2 + echo >&2 + echo " $(manual_path_line "$3" "$4")" >&2 + return 1 + fi + if [ "$state" = "ok" ]; then + tmp="$rc_file.tapesctl-tmp.$$" + rc_block_strip "$rc_file" >"$tmp" + # cat-over rather than mv: an rc file that is a symlink (dotfiles + # managers) must stay a symlink — the redirection writes through it + # to the target and keeps the file's owner/mode, where mv would + # replace the symlink with a plain file. + cat "$tmp" >"$rc_file" + rm -f "$tmp" + fi + # Separate the block from existing content without stacking blank + # lines across re-installs. + if [ -s "$rc_file" ] && [ -n "$(tail -c 1 "$rc_file")" ]; then + echo >>"$rc_file" + fi + if [ -s "$rc_file" ] && [ -n "$(tail -n 1 "$rc_file")" ]; then + echo >>"$rc_file" + fi + { + printf '%s\n' "$RC_BLOCK_BEGIN" + printf '%s\n' "$body" + printf '%s\n' "$RC_BLOCK_END" + } >>"$rc_file" +} + +# Whether the rc file can be written (or created). A file managed by nix +# or a dotfiles manager is typically a symlink to a read-only store path; +# `-w` follows the symlink and reports the target unwritable, so we can +# fall back to printing manual instructions instead of failing. +rc_file_writable() { + local rc_file="$1" + if [ -e "$rc_file" ] || [ -L "$rc_file" ]; then + [ -w "$rc_file" ] + else + mkdir -p "$(dirname "$rc_file")" 2>/dev/null && [ -w "$(dirname "$rc_file")" ] + fi +} +# One line the user can paste to put $1 on PATH in shell $2. The `$PATH` +# stays literal on purpose — the user pastes the line and their own shell +# expands it. +manual_path_line() { + local dir_expr="$1" shell_name="$2" + # shellcheck disable=SC2016 + case "$shell_name" in + fish) printf 'fish_add_path %s\n' "$dir_expr" ;; + *) printf 'export PATH="%s:$PATH"\n' "$dir_expr" ;; + esac +} + +install_path_block() { + local shell_name rc_file dir_expr body + if ! shell_name="$(detect_shell)"; then + echo "Could not detect a supported shell (bash/zsh/fish)." + echo "Add $INSTALL_DIR to your PATH manually to use tapesctl." + return 0 + fi + # Checked, not bare: under `set -e` an unchecked assignment from a failing + # command substitution aborts the script — and this one fails whenever there + # is no HOME to resolve an rc path under, which is precisely the case that + # must degrade to printed instructions rather than a failed install. + if ! rc_file="$(shell_rc_file "$shell_name")"; then + echo + echo "No home directory, so there is no shell config to update." + echo "To use tapesctl, add $INSTALL_DIR to your PATH:" + echo + echo " $(manual_path_line "$INSTALL_DIR" "$shell_name")" + return 0 + fi + # Keep the block portable when installing to the default location: + # reference $HOME symbolically instead of baking in today's value. + if [ "$INSTALL_DIR" = "$HOME/.local/bin" ]; then + # Unexpanded by design: the rc block should track $HOME at source + # time, not bake in today's value. + # shellcheck disable=SC2016 + dir_expr='$HOME/.local/bin' + else + dir_expr="$INSTALL_DIR" + fi + + # A read-only rc file (nix/home-manager, a dotfiles manager) must not + # fail the install: tapesctl is already on disk, it just is not on PATH + # yet. Tell the user exactly what to add, the way Homebrew does, and + # keep going. + if ! rc_file_writable "$rc_file"; then + echo + echo "Could not update $rc_file — it looks read-only (managed by nix or a dotfiles manager?)." + echo "To put tapesctl on your PATH, add this to your shell config:" + echo + echo " $(manual_path_line "$dir_expr" "$shell_name")" + echo + echo "Then restart your shell." + return 0 + fi + + case "$shell_name" in + fish) body="$(fish_path_block_body "$dir_expr")" ;; + *) body="$(posix_path_block_body "$dir_expr")" ;; + esac + if write_rc_block "$rc_file" "$body" "$dir_expr" "$shell_name"; then + echo "Ensured $dir_expr is on PATH via $rc_file" + echo "(restart your shell or source $rc_file to pick it up)" + fi +} + +# Point out any OTHER `tapesctl` on PATH that this install now takes over. +# `~/.local/bin` is prepended, so this install wins once the shell +# reloads; the user should know we quietly shadowed something rather than +# be surprised by a stray `tapesctl` from a different location. `-ef` +# compares by inode, so the just-installed binary (reachable under any +# PATH spelling, e.g. a trailing-slash duplicate) is never flagged as a +# collision. +warn_shadowing_tapesctl() { + local installed="$INSTALL_DIR/tapesctl" dir cand found="" + local IFS=: + for dir in $PATH; do + [ -n "$dir" ] || continue + cand="$dir/tapesctl" + if [ -x "$cand" ] && ! [ "$cand" -ef "$installed" ]; then + case "$found" in + *"$cand"*) ;; + *) found="${found:+$found }$cand" ;; + esac + fi + done + [ -n "$found" ] || return 0 + echo + echo "Note: another 'tapesctl' is already on your PATH:" + local p + for p in $found; do echo " $p"; done + echo "tapesctl installed to $INSTALL_DIR and put it first on your PATH, so" + echo "'tapesctl' runs this build once your shell reloads. The other command" + echo "stays available at its full path." +} + +############################################################################### +# Migration — remove an old root-owned install +############################################################################### +# +# A binary left in /usr/local/bin would shadow the new install in every +# context that still has the default PATH order — scripts, CI, +# GUI-launched tools — forking behavior by context forever. Removing it +# needs root (the directory is root-owned), so this is the one announced +# sudo left in tapesctl; declining degrades to a printed manual command, +# never a failed install. + +# Resolve $1 to its physical path when the directory exists (following +# symlinks, collapsing `..` and duplicate slashes); otherwise just strip +# trailing slashes. Keeps the same-directory guard below a real +# comparison instead of a defeatable string match. +canonical_dir() { + if [ -d "$1" ]; then + (cd "$1" >/dev/null 2>&1 && pwd -P) + else + printf '%s\n' "$1" | sed 's:/*$::' + fi +} + +migrate_old_install() { + local old_dir="${TAPESCTL_MIGRATE_FROM:-/usr/local/bin}" + # Normalized comparison: TAPESCTL_INSTALL_DIR=/usr/local/bin/ (trailing + # slash, symlinked spelling, …) must still be recognized as the old + # directory itself — a string mismatch here would sudo-rm the binary + # this very run just installed. + if [ "$(canonical_dir "$old_dir")" = "$(canonical_dir "$INSTALL_DIR")" ]; then + return 0 + fi + if [ ! -e "$old_dir/tapesctl" ] && [ ! -L "$old_dir/tapesctl" ]; then + return 0 + fi + + echo + echo "Found an old tapesctl install in $old_dir." + + # Only escalate when the directory actually requires it. /usr/local/bin is + # root-owned on stock macOS and most Linux, but Homebrew on Intel macOS owns + # it as the user — prompting for a password there would be exactly the + # gratuitous sudo this whole change set exists to remove. unlink(2) needs + # write permission on the containing directory, not on the file. + if [ -w "$old_dir" ] && rm -f "$old_dir/tapesctl"; then + echo "Removed the old install from $old_dir." + return 0 + fi + + echo "Removing it needs root — this is the last sudo tapesctl will ever request." + if command -v sudo >/dev/null 2>&1 && sudo rm -f "$old_dir/tapesctl"; then + echo "Removed the old install from $old_dir." + else + echo "warning: could not remove the old install. Remove it manually with:" >&2 + echo " sudo rm -f $old_dir/tapesctl" >&2 + fi + return 0 +} + +install_path_block +migrate_old_install + +echo echo "Installed tapesctl:" # `version`, not a bare invocation: bare tapesctl prints help and exits 2, # which under `set -e` would report every successful install as a failure. -"$INSTALL_DIR/tapesctl" version +# +# Advisory, hence `|| true`: the bytes are on disk and checksum-verified by +# now, so a binary that installs correctly but cannot *run* — an older glibc +# than the release was built against, a noexec install dir — must still reach +# the shadow warning and the next-steps below. Aborting here would report a +# successful install as a failure. +"$INSTALL_DIR/tapesctl" version || \ + echo "warning: the installed binary did not run here; see the note above." >&2 +warn_shadowing_tapesctl +echo +cat <<'EOF' +Next: + + tapesctl config set api-url # name your server once + tapesctl start claude # capture your first session + tapesctl upgrade # replace this binary with the newest release +EOF +} + +main "$@"