diff --git a/.cargo/config.toml b/.cargo/config.toml index b4a6b08..fb6aa41 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -24,3 +24,11 @@ rustflags = ["-C", "link-args=-Wl,--stack,8388608"] # falsy value, e.g. `SOCKET_NO_CONFIG=0`. [env] SOCKET_NO_CONFIG = "1" + +# The passive update-check notifier must never hit the network from a test +# or a dev `cargo run`: ~90 e2e suites spawn the real binary, and the PTY +# suites (interactive_prompts_e2e.rs) hand it a real terminal, defeating +# the stderr-TTY guard that protects ordinary piped test runs. Notifier +# tests opt back in with SOCKET_NO_UPDATE_CHECK=0 plus a wiremock +# SOCKET_UPDATE_BASE_URL. +SOCKET_NO_UPDATE_CHECK = "1" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25805c1..9c9fb00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,12 @@ jobs: php -l composer/socket-patch/bin/socket-patch ( cd composer/socket-patch && composer validate --no-check-publish ) + - name: Shell — shellcheck the curl|sh installer + # install.sh is the third distribution artifact this job lints; it + # had no coverage anywhere before the self-update work touched the + # same surface. shellcheck is pre-installed on ubuntu-latest. + run: shellcheck --shell=sh scripts/install.sh + test: strategy: fail-fast: false @@ -485,6 +491,13 @@ jobs: # cargo test -p socket-patch-cli --test e2e_gem -- --ignored # cargo test -p socket-patch-cli --test e2e_scan -- --ignored # + # Same policy for the self-update live smoke (hits real + # github.com releases; catches asset-naming/redirect/SUMS + # drift against the published pipeline — most useful right + # after a release): + # + # cargo test -p socket-patch-cli --test self_update_e2e -- --ignored + # # PR-time coverage for the same code paths comes from the # `e2e-docker` matrix below, which runs the same flow # against a hermetic wiremock fixture. diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f8aba..fdee818 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,29 @@ in this file — see `.github/workflows/release.yml` (`version` job). ### Added +- **`socket-patch --update` — self-update.** Downloads the release for the + compiled target from GitHub Releases, verifies it against the published + `SHA256SUMS` before extraction, sanity-execs the staged binary, and + atomically swaps it in place (Windows uses the rename-dance via + `self-replace`; a setuid/setgid install is refused). `--update 3.4.0` + (or `SOCKET_PATCH_VERSION`) pins a version, up or down; bare `--update` + never downgrades; `--force` reinstalls. `--dry-run` is a check-only + probe (zero downloads, `updateAvailable` in the `--json` details). + Package-manager-managed installs (npm, pip, cargo, the gem/composer + launcher cache, Homebrew) are detected from the canonicalized executable + path and refused with that manager's own upgrade command; `--force` + overrides. `--offline` refuses up front and `--force` cannot bypass it. + Concurrent updates are single-flighted via an advisory lock; every + failure path leaves the installed binary untouched. +- **Passive update notice.** Interactive runs mention a newer release at + most once a day, on stderr only, after the command's own output: + suppressed under `--json`/`--silent`/`--offline`, in CI, when stderr is + not a terminal, or with `SOCKET_NO_UPDATE_CHECK=1` (suppressed means + zero network I/O). The background check can never alter a command's + exit code, stdout, or add more than ~500 ms; state corruption degrades + to "never checked". An explicit `--update` refreshes the notice's cache. +- **`shellcheck scripts/install.sh` in CI** (and a fix for the SC2144 + glob-with-`-e` musl-loader probe it found). - **`socket login` now configures socket-patch.** The JS Socket CLI's persisted config (`/socket/settings/config.json`) is read — never written — as a fallback layer below env vars for `apiToken`, diff --git a/Cargo.lock b/Cargo.lock index d0d2062..c208f11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2199,6 +2199,17 @@ dependencies = [ "libc", ] +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + [[package]] name = "semver" version = "1.0.27" @@ -2428,6 +2439,7 @@ dependencies = [ "portable-pty", "regex", "reqwest", + "semver", "serde", "serde_json", "serial_test", @@ -2440,6 +2452,7 @@ dependencies = [ "tokio", "uuid", "wiremock", + "zip", ] [[package]] @@ -2455,6 +2468,8 @@ dependencies = [ "qbsdiff", "regex", "reqwest", + "self-replace", + "semver", "serde", "serde_json", "serial_test", diff --git a/Cargo.toml b/Cargo.toml index 245a6b2..7633c1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,8 @@ flate2 = "=1.1.9" zip = { version = "=8.6.0", default-features = false, features = ["deflate"] } fs2 = "=0.4.3" libc = "=0.2.182" +semver = "=1.0.27" +self-replace = "=1.5.0" wiremock = "=0.6.5" portable-pty = "=0.9.0" testcontainers = "=0.27.3" diff --git a/README.md b/README.md index ff92517..07cfa18 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,21 @@ The full list of prebuilt targets (Windows, 32-bit ARM, i686, Android) is in +### Updating + +If you installed via the one-liner or a manual download, the CLI updates itself: + +```bash +socket-patch --update # latest release (--update 3.4.0 pins a version) +``` + +It downloads the release for your platform, verifies its SHA-256 against the +published `SHA256SUMS`, and atomically swaps the binary in place. Package-manager +installs are detected and pointed at their own upgrade command instead (e.g. +`npm update -g @socketsecurity/socket-patch`). When a newer release exists, +interactive runs print a once-a-day reminder on stderr — set +`SOCKET_NO_UPDATE_CHECK=1` to turn that off. + ## Five-minute tutorial No account or token is needed to follow along — without an API token `socket-patch` diff --git a/crates/socket-patch-cli/CLI_CONTRACT.md b/crates/socket-patch-cli/CLI_CONTRACT.md index 0ee9cc4..cf0df2e 100644 --- a/crates/socket-patch-cli/CLI_CONTRACT.md +++ b/crates/socket-patch-cli/CLI_CONTRACT.md @@ -23,6 +23,8 @@ This document defines the **public surface** of the `socket-patch` binary. Anyth **Bare-UUID fallback.** `socket-patch ` is rewritten to `socket-patch get `. The UUID shape checked is the standard 8-4-4-4-12 hex pattern (case-insensitive). See [`src/lib.rs::looks_like_uuid`](src/lib.rs). +**Root `--update` flag.** `socket-patch --update [VERSION]` updates the binary itself from GitHub Releases. It is a root flag, not a subcommand: argv is rewritten (the same mechanism as the bare-UUID fallback) onto an internal hidden subcommand whose name carries no stability guarantee — script the flag, never the internal name. Combining the flag with a subcommand (`socket-patch --update scan`) is a usage error (exit 2). Full contract: [Self-update contract](#self-update-contract-socket-patch---update). + ## Global arguments In v3.0 every subcommand accepts the same set of "global" flags via a single shared `GlobalArgs` struct that's `#[command(flatten)]`-ed into each per-command struct (`crates/socket-patch-cli/src/args.rs`). Subcommands that don't actually consume a given flag accept it silently — e.g. `list --global` parses fine and is a no-op. Every flag also has an environment-variable binding; precedence is **CLI arg > env var > default** — and for exactly three keys (`--api-token`, `--org`, `--api-url`) the JS socket-cli's persisted login sits between env var and default: **CLI arg > env var (canonical, then `SOCKET_CLI_*` alias) > socket-cli `config.json` > default**. See "Persisted configuration" under Environment variables. @@ -594,6 +596,57 @@ worse, lets a warm cache silently serve unpatched bytes): * `vendor` exits like `apply`: 0 on success (benign skips included), 1 on any refusal/failure (`partialFailure`), 2 on usage errors. `--dry-run` verifies and writes nothing. +## Self-update contract (`socket-patch --update`) + +`socket-patch --update [VERSION]` replaces the running binary with a release from `https://github.com/SocketDev/socket-patch/releases` — the same artifacts, `SHA256SUMS` verification, and asset naming `install.sh` uses. It is for **standalone installs** (install.sh, manual tarball copy); every other channel is refused with that channel's own upgrade command. + +Synopsis and behavior: + +| Invocation | Behavior | +|---|---| +| `--update` | Resolve the latest release; install it if newer than the running version. Already-newest (including a dev build newer than any release): informational no-op, exit 0. `latest` never downgrades. | +| `--update 3.4.0` | Install exactly that version, **up or down** — an explicit pin is explicit intent, no `--force` needed. Pin == current: no-op, exit 0. Also settable via `SOCKET_PATCH_VERSION` (the same pin env `install.sh` and the gem/composer launchers honor); a malformed version is a usage error (exit 2). | +| `--update --force` | Reinstall/downgrade even when already at the target version, and proceed past a managed-install refusal (with a warning that the owning manager's next upgrade will overwrite the binary). Env: `SOCKET_FORCE`. | +| `--update --dry-run` | **Check-only**: one metadata request, zero downloads, zero mutation, exit 0 — and always the `verified`/`update_check` event shape, whether or not an update exists. `--json` details carry `{current, latest, updateAvailable, target, asset, path}` — the cheap scriptable "is an update available" probe. | +| `--update --offline` | Refused up front (strict airgap, before any client exists), exit 1. `--force` does **not** bypass it. | + +Honored global flags: `--json`, `--silent` (errors only), `--yes` (skip the confirm prompt; `--json` also auto-confirms), `--dry-run`, `--offline`, `--verbose`, `--debug`, `--no-telemetry`. Other global flags parse and are ignored (the `list --global` precedent). + +**Managed-install refusal.** The canonicalized executable path (symlinked invocations resolve to the real file) is classified before any network I/O; non-standalone channels exit 1 with `errorCode: managed_install` and the owning manager's command: + +| Detected channel | Hint | +|---|---| +| npm (`node_modules` path component) | `npm update -g @socketsecurity/socket-patch` | +| PyPI wheel (`site-packages`/`dist-packages`) | `pip install --upgrade socket-patch` | +| `cargo install` (`$CARGO_HOME/bin`, `~/.cargo/bin`) | `cargo install socket-patch-cli` | +| gem/composer launcher cache (`/socket-patch/bin/…` — the two share one layout) | `gem update socket-patch` or `composer update socketsecurity/socket-patch` | +| Homebrew (`Cellar`, `/opt/homebrew`) | `brew upgrade socket-patch` | + +**Pipeline order** (each step gates the next; a failure at any point leaves the installed binary untouched): fetch `SHA256SUMS` → fetch the archive (`socket-patch-.tar.gz`/`.zip`, explicit timeouts, size caps) → verify the SHA-256 **before** extraction → extract the single expected member → stage as an executable sibling **in the install directory** (`EACCES` here is the permissions preflight → exit 1 with a sudo hint; system temp is never used, so `noexec` mounts don't matter) → run the staged binary's `--version` self-check (against real GitHub the reported version must equal the release tag; under a `SOCKET_UPDATE_BASE_URL` override a mismatch only warns) → one atomic rename over the install path (mode-preserving; a **setuid/setgid** target — or, on Linux, one carrying **file capabilities** (`setcap`) — is refused, since an unprivileged swap cannot restore those grants; Windows uses the rename-dance via `self-replace`). Concurrent updates are single-flighted per environment by an advisory lock at `/update.lock` (`errorCode: update_in_progress`; the OS releases a dead holder's lock, so there is no stale-lock state). Two updaters whose state dirs diverge (e.g. different `$HOME`s targeting one shared `/usr/local/bin`) are not serialized, but every path to the destination is a whole-file rename and stage cleanup is age-gated — the worst case is duplicated work, never a torn binary. + +**Envelope.** `command: "update"`. Success events: `downloaded` (`details: {asset, bytes, sha256}`) then `updated` (`details: {from, to, path, target}`). No-op: `skipped` with reason `already_latest`. Dry-run: `verified` with reason `update_check`. Top-level `errorCode` values (stable): `offline`, `managed_install`, `check_failed`, `asset_not_found`, `download_failed`, `checksum_mismatch`, `verify_failed`, `swap_failed`, `permission_denied`, `update_in_progress`. Exit codes: 0 success / no-op / dry-run; 1 operational failure; 2 usage. + +**Trust model.** Checksum-only, rooted in HTTPS + GitHub (identical to install.sh and the launcher wrappers): `SHA256SUMS` is served from the same origin as the archives, there are no signatures yet. Downloads are credential-free — the Socket API bearer is never sent to the release host — and non-HTTPS redirect hops are refused when talking to the default endpoints. + +### Passive update notice + +Commands other than `--update` itself may print, on **stderr only**, after all command output: + +``` +[socket-patch] Update available: 3.3.0 → 3.4.0 +[socket-patch] Run `socket-patch --update` to upgrade (set SOCKET_NO_UPDATE_CHECK=1 to hide) +``` + +The second line is channel-aware (an npm-managed install is pointed at `npm update -g …`, not at `--update`). Contract promises: + +- At most one release-metadata fetch per 24 h (cached in the state file below; a failed fetch also counts), and at most one notice per 24 h while an update is pending. +- Never under `--json`, `--silent`, `--offline`/`SOCKET_OFFLINE`, in CI (`CI`/`GITHUB_ACTIONS` env), when stderr is not a terminal, or when `SOCKET_NO_UPDATE_CHECK` is truthy. Silenced means **zero network I/O**, not just no output. +- Never changes a command's exit code or stdout; adds at most ~500 ms to a run (the background check is abandoned past that grace budget and retried on a later run). +- State-file corruption, clock skew, or an unwritable cache dir degrade to "never checked" — they can never break a command. +- Independent of telemetry: `--no-telemetry` does not affect the update check (it fetches public release metadata with no identifying payload beyond the CLI User-Agent); `SOCKET_OFFLINE` kills both. + +State lives at `$XDG_CACHE_HOME`|`~/.cache` (Unix/macOS) or `%LOCALAPPDATA%` (Windows) + `/socket-patch/update-check.json` (camelCase JSON: `schemaVersion`, `lastCheckAt`, `latestSeen`, `lastNotifiedAt`; unix seconds). A completed `--update` refreshes `latestSeen`, so the notifier never nags about a version the user just installed. + ## Environment variables All v3.0 env vars use the `SOCKET_*` prefix. Three legacy `SOCKET_PATCH_*` names are still honored at runtime for compatibility: on first read of any of the three the binary emits a one-shot deprecation warning to stderr (the warning fires unconditionally — even under `--silent` / `--json` — because it's a transition signal users need to see). The legacy names will be removed in the next major release. @@ -627,7 +680,8 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are | `SOCKET_LOCK_TIMEOUT` | `--lock-timeout` | (none) | Seconds to wait for `apply.lock`; unset/`0` = single non-blocking try. | | `SOCKET_DEBUG` | `--debug` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_DEBUG`). | | `SOCKET_TELEMETRY_DISABLED` | `--no-telemetry` | `false` | **Renamed in v3.0** (was `SOCKET_PATCH_TELEMETRY_DISABLED`). | -| `SOCKET_FORCE` | `apply --force` / `-f` | `false` | Local to `apply`. | +| `SOCKET_FORCE` | `apply --force` / `-f`, `--update --force` | `false` | Local to `apply` and `--update`. | +| `SOCKET_PATCH_VERSION` | `--update ` | (latest) | Local to `--update`; the same pin `install.sh` and the gem/composer launchers honor. Not one of the deprecated legacy `SOCKET_PATCH_*` trio. | | `SOCKET_BATCH_SIZE` | `scan --batch-size` | `100` | Local to `scan`. | | `SOCKET_SAVE_ONLY` | `get --save-only` | `false` | Local to `get`. | | `SOCKET_ONE_OFF` | `get --one-off` / `rollback --one-off` | `false` | Local to `get`/`rollback`. Both are **not yet implemented**: the flag parses (boolishly, empty-tolerant) and the command fails up front with a "not yet implemented" error, before any network or disk activity. | @@ -644,6 +698,7 @@ Empty string means unset at every layer: exported-but-empty flag-bound vars are |---|---|---| | `SOCKET_NO_CONFIG` | `false` | Truthy (`1`/`true`/`yes`/`on`): disable the socket-cli persisted-config fallback layer entirely — pure flag+env behavior. Also the test-hermeticity switch (the workspace `.cargo/config.toml` exports it as `1` for every cargo-run process). | | `SOCKET_NO_API_TOKEN` | `false` | Truthy: ignore **ambient** API tokens (the `SOCKET_API_TOKEN` env var and the socket-cli config token); only an explicit `--api-token` flag authenticates. Peer alias: `SOCKET_CLI_NO_API_TOKEN`. | +| `SOCKET_NO_UPDATE_CHECK` | `false` | Truthy: disable the passive update notice entirely (see "Passive update notice"). Explicit `--update` still works. Also a test-hermeticity switch (the workspace `.cargo/config.toml` exports it as `1` for every cargo-run process). No `SOCKET_CLI_*` alias (socket-cli has no equivalent today). | ### Persisted configuration (socket-cli `config.json`) @@ -694,6 +749,10 @@ These exist for staged rollouts and the launcher wrappers. They are **internal** | `SOCKET_EXPERIMENTAL_MAVEN` | Opt-in gate (`=1`) for the maven installed-package crawl behind `scan`/`apply`/`vendor` — agent-mode in-place jar patching corrupts the `~/.m2` checksum sidecars, so discovery stays off by default (`src/ecosystem_dispatch.rs`). | | `SOCKET_EXPERIMENTAL_NUGET` | Same gate for nuget — in-place patching breaks the `.nupkg.sha512` tamper-evidence sidecar. | | `SOCKET_PATCH_BIN` | Points the RubyGems / Composer launcher wrappers and the gem Bundler plugin at an existing `socket-patch` binary (skips the download-on-first-run); also the escape hatch `apply` names when a golang-featureless binary is asked to audit Go redirects. | +| `SOCKET_UPDATE_BASE_URL` | Points BOTH the release-metadata and asset-download routes of `--update`/the update notice at one base (mirror or test fixture) instead of `github.com` + `api.github.com`. Overriding it relaxes the downloaded binary's version self-check from hard-fail to warning. | +| `SOCKET_UPDATE_STATE_DIR` | Overrides the per-user dir holding `update-check.json` + `update.lock` (tests point it into a tempdir). | +| `SOCKET_UPDATE_TIMEOUT_MS` | Caps the update fetches' connect/metadata/download budgets (defaults 10 s / 30 s / 300 s; the notice's fetch defaults to 2 s). Doubles as the slow-network escape hatch. | +| `SOCKET_UPDATE_NOTIFIER_FORCE` | Test hook: bypasses the update notice's stderr-TTY guard — and nothing else (opt-out, offline, `--silent`, `--json`, CI all still win). | ### Deprecated env vars @@ -837,6 +896,7 @@ Every `--json` invocation emits a single JSON object that follows the **unified | `list` | `Discovered` (with `details.vulnerabilities`, `details.tier`, `details.license`, `details.description`, `details.exportedAt`) | | `repair`/`gc`| `Downloaded` (or `Verified` on dry-run) · `Rebuilt` (vendored artifacts; `Verified` previews on dry-run) · `Skipped` (vendor_uuid_mismatch) · `Removed` (or `Verified`) · `Failed` events | | `remove` | `Removed` (per purl; `Verified` on dry-run) · artifact-level `Removed`/`Verified` event (with `details.blobsRemoved`, `details.rolledBack`) | +| `--update` | `Downloaded` → `Updated` (success) · `Skipped` (already_latest) · `Verified` (dry-run check, reason update_check) — see the Self-update contract section for details fields and top-level error codes | ### Migration status (v3.0) diff --git a/crates/socket-patch-cli/Cargo.toml b/crates/socket-patch-cli/Cargo.toml index 52905b1..acea76b 100644 --- a/crates/socket-patch-cli/Cargo.toml +++ b/crates/socket-patch-cli/Cargo.toml @@ -17,6 +17,7 @@ path = "src/main.rs" [dependencies] socket-patch-core = { workspace = true } +semver = { workspace = true } clap = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -61,6 +62,8 @@ sha1 = { workspace = true } # scan_vendor_e2e builds pristine registry tarballs for the auto-fetch tests. tar = { workspace = true } flate2 = { workspace = true } +# update_fixture builds the Windows-shaped release archive for self-update e2e. +zip = { workspace = true } hex = { workspace = true } wiremock = { workspace = true } portable-pty = { workspace = true } diff --git a/crates/socket-patch-cli/build.rs b/crates/socket-patch-cli/build.rs new file mode 100644 index 0000000..4e2b514 --- /dev/null +++ b/crates/socket-patch-cli/build.rs @@ -0,0 +1,11 @@ +fn main() { + // Embed the exact compile target so `--update` downloads the right + // release asset. Compiled-in beats runtime `uname` probing: the binary + // *is* gnu or musl (install.sh's ldd heuristic can only guess), and + // the Windows arches fall out for free. + println!( + "cargo:rustc-env=SOCKET_PATCH_TARGET={}", + std::env::var("TARGET").expect("cargo always sets TARGET for build scripts") + ); + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/crates/socket-patch-cli/src/args.rs b/crates/socket-patch-cli/src/args.rs index c3fb0ba..8772678 100644 --- a/crates/socket-patch-cli/src/args.rs +++ b/crates/socket-patch-cli/src/args.rs @@ -371,6 +371,7 @@ pub const GLOBAL_ARG_ENV_VARS: &[&str] = &[ /// parse every entry against its owning subcommand to keep this honest. pub const LOCAL_ARG_ENV_VARS: &[&str] = &[ "SOCKET_FORCE", + "SOCKET_PATCH_VERSION", "SOCKET_SAVE_ONLY", "SOCKET_ONE_OFF", "SOCKET_ALL_RELEASES", @@ -1093,6 +1094,7 @@ mod tests { const BOOL_BINDINGS: &[(&str, &[&str])] = &[ ("SOCKET_FORCE", &["socket-patch", "apply"]), ("SOCKET_FORCE", &["socket-patch", "vendor"]), + ("SOCKET_FORCE", &["socket-patch", "self-update"]), ("SOCKET_SAVE_ONLY", &["socket-patch", "get", "x"]), ("SOCKET_ONE_OFF", &["socket-patch", "get", "x"]), ("SOCKET_ONE_OFF", &["socket-patch", "rollback"]), @@ -1140,6 +1142,7 @@ mod tests { const VALUE_BINDINGS: &[(&str, &[&str])] = &[ ("SOCKET_BATCH_SIZE", &["socket-patch", "scan"]), + ("SOCKET_PATCH_VERSION", &["socket-patch", "self-update"]), ("SOCKET_SETUP_EXCLUDE", &["socket-patch", "setup"]), ("SOCKET_VEX", &["socket-patch", "apply"]), ("SOCKET_VEX_OUTPUT", &["socket-patch", "vex"]), diff --git a/crates/socket-patch-cli/src/commands/mod.rs b/crates/socket-patch-cli/src/commands/mod.rs index 4df9b91..b6a6853 100644 --- a/crates/socket-patch-cli/src/commands/mod.rs +++ b/crates/socket-patch-cli/src/commands/mod.rs @@ -9,5 +9,6 @@ pub(crate) mod repair_vendor; pub mod rollback; pub mod scan; pub mod setup; +pub mod update; pub mod vendor; pub mod vex; diff --git a/crates/socket-patch-cli/src/commands/update.rs b/crates/socket-patch-cli/src/commands/update.rs new file mode 100644 index 0000000..ea20a19 --- /dev/null +++ b/crates/socket-patch-cli/src/commands/update.rs @@ -0,0 +1,335 @@ +//! `socket-patch --update` — self-update from GitHub Releases. +//! +//! The public surface is the root `--update` flag; a first-class-looking +//! but hidden `self-update` subcommand is the parse target the argv +//! rewrite in `lib.rs` forwards to (same mechanism as the bare-UUID→`get` +//! shortcut). Policy lives here — offline gate, managed-channel refusal, +//! confirmation, envelope, exit codes — while the download/verify/swap +//! machinery lives in `socket_patch_core::update`. + +use clap::Args; +use socket_patch_core::update::{ + self as core_update, asset_name_for_target, channel_label, current_version, detect_channel, + fetch_latest_version, is_newer, upgrade_hint, ChannelEnv, InstallChannel, UpdateEndpoints, + UpdateError, UpdateRequest, UpdateTimeouts, +}; + +use crate::args::{apply_env_toggles, parse_bool_flag, GlobalArgs}; +use crate::commands::lock_cli::error_envelope; +use crate::json_envelope::{Command, Envelope, PatchAction, PatchEvent}; +use crate::output; + +/// The target triple this binary was compiled for, embedded by `build.rs`. +/// Passed into core as a parameter so core stays testable with arbitrary +/// triples. +pub const UPDATE_TARGET: &str = env!("SOCKET_PATCH_TARGET"); + +#[derive(Args)] +pub struct UpdateArgs { + #[command(flatten)] + pub common: GlobalArgs, + + /// Exact version to install instead of the latest release (e.g. + /// `socket-patch --update 3.4.0`). An explicit pin installs that + /// version even if it is older than the current one. Also settable via + /// SOCKET_PATCH_VERSION — the same pin install.sh and the gem/composer + /// launchers honor. + /// + /// Not named `version`: under `propagate_version` clap already owns a + /// `--version` arg id on every subcommand, and the collision panics at + /// parser construction. + #[arg( + value_name = "VERSION", + env = "SOCKET_PATCH_VERSION", + value_parser = parse_version_pin, + )] + pub pin_version: Option, + + /// Proceed even when this install looks package-manager-managed + /// (npm/pip/cargo/Homebrew/launcher), and reinstall even when already + /// on the requested version. + #[arg( + long, + env = "SOCKET_FORCE", + default_value_t = false, + value_parser = parse_bool_flag, + )] + pub force: bool, +} + +/// Validate a version pin at parse time (typos become clap usage errors, +/// exit 2). Tolerates a leading `v` like install.sh; stores the bare form. +fn parse_version_pin(raw: &str) -> Result { + let bare = raw.trim().trim_start_matches('v'); + semver::Version::parse(bare) + .map(|v| v.to_string()) + .map_err(|e| format!("not a valid version: {e}")) +} + +/// Emit an error in the mode-appropriate shape and return the exit code. +fn fail(args: &UpdateArgs, code: &str, message: &str) -> i32 { + if args.common.json { + let env = error_envelope(Command::Update, args.common.dry_run, code, message); + println!("{}", env.to_pretty_json()); + } else { + eprintln!("Error: {message}"); + } + 1 +} + +pub async fn run(args: UpdateArgs) -> i32 { + apply_env_toggles(&args.common); + let quiet = args.common.json || args.common.silent; + + // 1. Offline gate first — strict airgap refuses before any client + // exists, and --force does not bypass it (matching scan/get). + if args.common.offline { + return fail( + &args, + "offline", + "update requires network access to check releases and cannot run with \ + --offline/SOCKET_OFFLINE (strict airgap)", + ); + } + + // 2. Where is this binary, and who manages it? Zero network so far. + let install_path = match core_update::resolve_install_path() { + Ok(p) => p, + Err(e) => return fail(&args, e.error_code(), &e.to_string()), + }; + let channel = detect_channel(&install_path, &ChannelEnv::from_env()); + if channel != InstallChannel::Standalone { + if args.force { + if !quiet { + eprintln!( + "Warning: this install is managed by {} — its next upgrade will overwrite \ + the updated binary.", + channel_label(channel) + ); + } + } else { + return fail( + &args, + "managed_install", + &format!( + "this socket-patch binary ({}) is managed by {}; update it with `{}` \ + instead, or pass --force to replace it in place", + install_path.display(), + channel_label(channel), + upgrade_hint(channel) + ), + ); + } + } + + // 3. Resolve what to install. + let endpoints = UpdateEndpoints::from_env(); + let timeouts = UpdateTimeouts::from_env(); + let current = current_version(); + let (target_version, pinned) = match &args.pin_version { + Some(pin) => match semver::Version::parse(pin) { + Ok(v) => (v, true), + // Unreachable via clap (value_parser validates), but the env + // path deserves a real error over a panic. + Err(e) => return fail(&args, "check_failed", &format!("invalid version pin: {e}")), + }, + None => match fetch_latest_version(&endpoints, &timeouts).await { + Ok(v) => (v, false), + Err(e) => return fail(&args, e.error_code(), &e.to_string()), + }, + }; + + // Whatever we just learned, remember it for the passive notifier + // (best-effort; an explicit check refreshes the once-a-day cache). + if !pinned { + let mut state = core_update::load_state(); + state.last_check_at = Some(core_update::unix_now()); + state.latest_seen = Some(target_version.to_string()); + let _ = core_update::save_state(&state).await; + } + + let asset = asset_name_for_target(UPDATE_TARGET); + + let update_available = if pinned { + target_version != current + } else { + is_newer(&target_version, ¤t) + }; + + // 4. --dry-run is check-only, and it reports FIRST — whether or not an + // update is available, the probe's contract is one metadata request, + // zero downloads, zero mutation, exit 0, with `updateAvailable` in + // the details (scripts branch on it). + if args.common.dry_run { + let msg = if update_available { + format!("Update available: socket-patch {current} → {target_version} (dry run; not installed)") + } else if args.force { + format!("Would reinstall socket-patch {target_version} (dry run; --force)") + } else { + format!("socket-patch {current} is already the latest version.") + }; + if args.common.json { + let mut env = Envelope::new(Command::Update); + env.dry_run = true; + env.record( + PatchEvent::artifact(PatchAction::Verified) + .with_reason("update_check", &msg) + .with_details(serde_json::json!({ + "current": current.to_string(), + "latest": target_version.to_string(), + "updateAvailable": update_available, + "target": UPDATE_TARGET, + "asset": asset, + "path": install_path.display().to_string(), + })), + ); + println!("{}", env.to_pretty_json()); + } else if !args.common.silent { + println!("{msg}"); + } + return 0; + } + + // 5. Already there? (An explicit pin may go up OR down; `latest` never + // downgrades — a dev build newer than the newest release is left + // alone.) --force reinstalls regardless. + if !update_available && !args.force { + let msg = if pinned { + format!("socket-patch is already version {current}.") + } else { + format!("socket-patch {current} is already the latest version.") + }; + if args.common.json { + let mut env = Envelope::new(Command::Update); + env.dry_run = args.common.dry_run; + env.record( + PatchEvent::artifact(PatchAction::Skipped) + .with_reason("already_latest", &msg) + .with_details(serde_json::json!({ + "current": current.to_string(), + "latest": target_version.to_string(), + })), + ); + println!("{}", env.to_pretty_json()); + } else if !args.common.silent { + println!("{msg}"); + } + return 0; + } + + // 6. Confirm (auto-proceeds under --yes/--json; declines default-yes + // only on an explicit "n"). + let prompt = format!("Update socket-patch {current} → {target_version}?"); + if !output::confirm(&prompt, true, args.common.yes, args.common.json) { + if !quiet { + eprintln!("Update cancelled."); + } + return 1; + } + + // 7. Lock → download → verify → stage → sanity → swap (core). + let outcome = match core_update::perform_update(UpdateRequest { + target_triple: UPDATE_TARGET, + version: &target_version, + install_path: &install_path, + endpoints: &endpoints, + timeouts: &timeouts, + }) + .await + { + Ok(outcome) => outcome, + Err(e) => { + let mut message = e.to_string(); + if let UpdateError::PermissionDenied { .. } = e { + message.push_str( + "; re-run with elevated privileges (e.g. `sudo socket-patch --update`) \ + or re-run the installer", + ); + } + return fail(&args, e.error_code(), &message); + } + }; + + if !quiet { + for warning in &outcome.warnings { + eprintln!("Warning: {warning}"); + } + } + + if args.common.json { + let mut env = Envelope::new(Command::Update); + env.record( + PatchEvent::artifact(PatchAction::Downloaded).with_details(serde_json::json!({ + "asset": outcome.asset, + "bytes": outcome.archive_bytes, + "sha256": outcome.archive_sha256, + })), + ); + env.record( + PatchEvent::artifact(PatchAction::Updated).with_details(serde_json::json!({ + "from": current.to_string(), + "to": target_version.to_string(), + "path": outcome.installed_path.display().to_string(), + "target": UPDATE_TARGET, + })), + ); + println!("{}", env.to_pretty_json()); + } else if !args.common.silent { + println!( + "Updated socket-patch {current} → {target_version} ({})", + outcome.installed_path.display() + ); + } + 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_pin_parses_and_normalizes() { + assert_eq!(parse_version_pin("3.4.0").unwrap(), "3.4.0"); + assert_eq!(parse_version_pin("v3.4.0").unwrap(), "3.4.0"); + assert_eq!(parse_version_pin(" v3.4.0 ").unwrap(), "3.4.0"); + assert!(parse_version_pin("latest").is_err()); + assert!(parse_version_pin("3.4").is_err()); + assert!(parse_version_pin("").is_err()); + } + + // The 3 CI platforms plus the common dev hosts must map onto real + // release assets; an exotic self-built target legitimately won't, so + // the pin is gated to the platforms release.yml actually builds. + #[cfg(any( + target_os = "macos", + target_os = "windows", + all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + ) + ))] + #[test] + fn compiled_target_is_a_release_triple() { + const RELEASE_TRIPLES: &[&str] = &[ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", + "x86_64-pc-windows-msvc", + "i686-pc-windows-msvc", + "aarch64-pc-windows-msvc", + "aarch64-linux-android", + "arm-unknown-linux-gnueabihf", + "arm-unknown-linux-musleabihf", + "i686-unknown-linux-gnu", + "i686-unknown-linux-musl", + ]; + assert!( + RELEASE_TRIPLES.contains(&UPDATE_TARGET), + "compiled target {UPDATE_TARGET} has no release asset — update \ + release.yml (and this list) or the asset mapping" + ); + } +} diff --git a/crates/socket-patch-cli/src/json_envelope.rs b/crates/socket-patch-cli/src/json_envelope.rs index 034c6be..8a90647 100644 --- a/crates/socket-patch-cli/src/json_envelope.rs +++ b/crates/socket-patch-cli/src/json_envelope.rs @@ -353,6 +353,8 @@ pub enum Command { List, Remove, Repair, + /// `--update` (the hidden `self-update` subcommand). + Update, } /// Top-level status. Serializes camelCase. diff --git a/crates/socket-patch-cli/src/lib.rs b/crates/socket-patch-cli/src/lib.rs index 8d73b44..c843d6a 100644 --- a/crates/socket-patch-cli/src/lib.rs +++ b/crates/socket-patch-cli/src/lib.rs @@ -10,6 +10,7 @@ pub mod commands; pub(crate) mod ecosystem_dispatch; pub mod json_envelope; pub mod output; +pub mod update_notifier; use clap::{Parser, Subcommand}; @@ -27,6 +28,22 @@ use clap::{Parser, Subcommand}; pub struct Cli { #[command(subcommand)] pub command: Commands, + + /// Update socket-patch itself to the latest release (or + /// `--update ` for a specific one). Standalone installs + /// only; package-manager installs are pointed at their own + /// upgrade command. + // + // This root flag is the public surface; parsing-wise it is rewritten + // to the hidden `self-update` subcommand by `parse_with_uuid_fallback` + // (`command` stays required, so `--update` alone never parses `Ok` + // here). The field itself exists for `--help` discoverability and to + // reject the contradictory `socket-patch --update ` form + // in `main`. Deliberately no env binding: an ambient "always + // self-update" toggle would poison every parse. (Plain `//` comments: + // doc comments here would leak internals into `--help`.) + #[arg(long)] + pub update: bool, } #[derive(Subcommand)] @@ -72,6 +89,34 @@ pub enum Commands { /// their own when the user wants to clean up without an apply pass. #[command(visible_alias = "gc")] Repair(commands::repair::RepairArgs), + + /// Internal parse target of the root `--update` flag (see the rewrite + /// in [`parse_with_uuid_fallback`]). Hidden: the public contract + /// surface is `socket-patch --update`, and this name carries no + /// stability guarantee (documented as internal in CLI_CONTRACT.md). + #[command(hide = true, name = "self-update")] + SelfUpdate(commands::update::UpdateArgs), +} + +impl Commands { + /// The flattened [`args::GlobalArgs`] every subcommand carries. Lets + /// cross-cutting hooks (the update notifier) read `--json`/`--silent`/ + /// `--offline`/`--debug` before the dispatch match consumes `self`. + pub fn global_args(&self) -> &args::GlobalArgs { + match self { + Commands::Scan(a) => &a.common, + Commands::Apply(a) => &a.common, + Commands::Vex(a) => &a.common, + Commands::Vendor(a) => &a.common, + Commands::Setup(a) => &a.common, + Commands::Rollback(a) => &a.common, + Commands::Get(a) => &a.common, + Commands::List(a) => &a.common, + Commands::Remove(a) => &a.common, + Commands::Repair(a) => &a.common, + Commands::SelfUpdate(a) => &a.common, + } + } } /// Check whether `s` looks like a UUID (8-4-4-4-12 hex pattern). @@ -90,15 +135,38 @@ fn looks_like_uuid(s: &str) -> bool { .all(|(p, &len)| p.len() == len && p.chars().all(|c| c.is_ascii_hexdigit())) } -/// Parse a full argv vector, falling back to `get ` when the user -/// invoked `socket-patch [...]` directly. Returns the original clap -/// error if the fallback also fails or if the first arg isn't a UUID. +/// Parse a full argv vector with two convenience rewrites on failure: +/// `--update [...]` becomes the hidden `self-update` subcommand, and a +/// bare `` becomes `get `. Returns the original clap error if +/// no rewrite applies or the applicable rewrite also genuinely fails. /// -/// Pulled out of `main.rs` so the fallback path is unit-testable. +/// Pulled out of `main.rs` so the fallback paths are unit-testable. pub fn parse_with_uuid_fallback(argv: Vec) -> Result { match Cli::try_parse_from(&argv) { Ok(cli) => Ok(cli), Err(err) => { + // Root `--update` never parses Ok on its own (the subcommand + // is required), so rewrite it to `self-update`, dropping the + // flag token and keeping every other arg in order — this way + // `--update 3.4.0`, `--json --update`, and `--update --help` + // all reach the real parser. When `--update` is the FIRST + // argument the intent is unambiguous, so the rewrite's outcome + // (including its errors) is surfaced; anywhere else a genuine + // rewrite failure falls back to the original error, mirroring + // the UUID shortcut below. + if let Some(pos) = argv.iter().skip(1).position(|a| a == "--update") { + let pos = pos + 1; // undo the skip(1) offset + let mut new_args = Vec::with_capacity(argv.len() + 1); + new_args.push(argv[0].clone()); + new_args.push("self-update".to_string()); + new_args.extend_from_slice(&argv[1..pos]); + new_args.extend_from_slice(&argv[pos + 1..]); + return match Cli::try_parse_from(&new_args) { + Ok(cli) => Ok(cli), + Err(rewrite_err) if pos == 1 || !rewrite_err.use_stderr() => Err(rewrite_err), + Err(_) => Err(err), + }; + } if argv.len() >= 2 && looks_like_uuid(&argv[1]) { let mut new_args = vec![argv[0].clone(), "get".into()]; new_args.extend_from_slice(&argv[1..]); @@ -409,6 +477,130 @@ mod tests { assert_eq!(err.exit_code(), 0); } + // ---------- --update rewrite ---------- + + #[test] + fn update_flag_alone_rewrites_to_self_update() { + let cli = parse_with_uuid_fallback(argv(&["socket-patch", "--update"])).unwrap(); + match cli.command { + Commands::SelfUpdate(args) => { + assert_eq!(args.pin_version, None); + assert!(!args.force); + } + _ => panic!("expected Commands::SelfUpdate"), + } + } + + #[test] + fn update_flag_takes_a_version_pin() { + let cli = parse_with_uuid_fallback(argv(&["socket-patch", "--update", "3.4.0"])).unwrap(); + match cli.command { + Commands::SelfUpdate(args) => assert_eq!(args.pin_version.as_deref(), Some("3.4.0")), + _ => panic!("expected Commands::SelfUpdate"), + } + } + + #[test] + fn update_version_pin_normalizes_v_prefix() { + let cli = parse_with_uuid_fallback(argv(&["socket-patch", "--update", "v3.4.0"])).unwrap(); + match cli.command { + Commands::SelfUpdate(args) => assert_eq!(args.pin_version.as_deref(), Some("3.4.0")), + _ => panic!("expected Commands::SelfUpdate"), + } + } + + #[test] + fn update_flag_is_position_independent() { + // The flag needn't come first: every other arg is preserved in + // order around the dropped `--update` token. + let cli = + parse_with_uuid_fallback(argv(&["socket-patch", "--json", "--update"])).unwrap(); + match cli.command { + Commands::SelfUpdate(args) => assert!(args.common.json), + _ => panic!("expected Commands::SelfUpdate"), + } + let cli = parse_with_uuid_fallback(argv(&[ + "socket-patch", + "--update", + "--force", + "--silent", + ])) + .unwrap(); + match cli.command { + Commands::SelfUpdate(args) => { + assert!(args.force); + assert!(args.common.silent); + } + _ => panic!("expected Commands::SelfUpdate"), + } + } + + #[test] + fn update_with_garbage_version_is_a_usage_error() { + let err = match parse_with_uuid_fallback(argv(&["socket-patch", "--update", "latest"])) { + Ok(_) => panic!("expected parse to fail"), + Err(e) => e, + }; + // --update first ⇒ the rewrite's error surfaces (a value-validation + // usage error, exit 2), not the original missing-subcommand help. + assert!(err.use_stderr()); + assert_eq!(err.exit_code(), 2); + assert!(err.to_string().contains("not a valid version"), "{err}"); + } + + #[test] + fn update_before_subcommand_parses_as_root_flag() { + // `socket-patch --update scan` parses Ok at the clap layer (root + // flag + subcommand); main.rs rejects the combination with exit 2. + // Pinned here so the rewrite never fires for it. + let cli = parse_with_uuid_fallback(argv(&["socket-patch", "--update", "scan"])); + // "scan" is not valid semver, so if the rewrite HAD fired this + // would be an error — instead the plain parse wins. + let cli = cli.unwrap(); + assert!(cli.update); + assert!(matches!(cli.command, Commands::Scan(_))); + } + + #[test] + fn update_after_subcommand_surfaces_the_original_error() { + // `socket-patch scan --update`: scan owns no --update flag, and the + // rewrite (`self-update scan`) also fails on the VERSION value. The + // flag was not argv[1], so the ORIGINAL unknown-argument error must + // surface — pointing at scan, not at self-update. + let err = match parse_with_uuid_fallback(argv(&["socket-patch", "scan", "--update"])) { + Ok(_) => panic!("expected parse to fail"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument); + } + + #[test] + fn update_help_shows_self_update_help() { + let err = match parse_with_uuid_fallback(argv(&["socket-patch", "--update", "--help"])) { + Ok(_) => panic!("clap surfaces --help as an Err"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); + assert!(!err.use_stderr()); + assert_eq!(err.exit_code(), 0); + assert!(err.to_string().contains("self-update"), "{err}"); + } + + #[test] + fn root_help_documents_the_update_flag() { + let err = match parse_with_uuid_fallback(argv(&["socket-patch", "--help"])) { + Ok(_) => panic!("clap surfaces --help as an Err"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp); + let help = err.to_string(); + assert!(help.contains("--update"), "root help must advertise --update"); + assert!( + !help.contains("self-update"), + "the internal subcommand stays hidden from root help" + ); + } + #[test] fn fallback_genuine_rewrite_failure_still_uses_original_error() { // Regression guard for the fix: a *real* rewrite failure (one clap diff --git a/crates/socket-patch-cli/src/main.rs b/crates/socket-patch-cli/src/main.rs index 825b524..a423038 100644 --- a/crates/socket-patch-cli/src/main.rs +++ b/crates/socket-patch-cli/src/main.rs @@ -70,6 +70,27 @@ async fn main() { Err(err) => err.exit(), }; + // A successful parse with `update == true` means a subcommand was also + // given (`socket-patch --update scan`) — bare `--update` is rewritten + // to the hidden `self-update` subcommand before it can parse Ok. The + // combination is contradictory; refuse with the contract's usage exit. + if cli.update { + eprintln!( + "error: --update cannot be combined with a subcommand; run `socket-patch --update` on its own" + ); + std::process::exit(2); + } + + // Passive update notifier: guards + (maybe) a background check kicked + // off before dispatch, joined with a short grace budget after it. + // Structurally skipped for `--update` itself — an explicit update IS + // the check, and it refreshes the notifier's cache on its own. + let notifier = if matches!(cli.command, Commands::SelfUpdate(_)) { + None + } else { + socket_patch_cli::update_notifier::spawn_if_due(cli.command.global_args()) + }; + let exit_code = match cli.command { Commands::Scan(args) => commands::scan::run(args).await, Commands::Apply(args) => commands::apply::run(args).await, @@ -81,7 +102,13 @@ async fn main() { Commands::List(args) => commands::list::run(args).await, Commands::Remove(args) => commands::remove::run(args).await, Commands::Repair(args) => commands::repair::run(args).await, + Commands::SelfUpdate(args) => commands::update::run(args).await, }; + // Never delays exit beyond its 500 ms grace budget; never changes the + // exit code; prints (at most) its notice to stderr after all command + // output. + socket_patch_cli::update_notifier::finish(notifier).await; + std::process::exit(exit_code); } diff --git a/crates/socket-patch-cli/src/output.rs b/crates/socket-patch-cli/src/output.rs index b53a63d..d3337df 100644 --- a/crates/socket-patch-cli/src/output.rs +++ b/crates/socket-patch-cli/src/output.rs @@ -5,6 +5,13 @@ pub(crate) fn stdin_is_tty() -> bool { std::io::stdin().is_terminal() } +/// The update notifier's TTY gate reads *stderr*, not stdin: the notice +/// prints there, and stdout may be legitimately piped (`list | jq`) in a +/// perfectly interactive session. +pub(crate) fn stderr_is_tty() -> bool { + std::io::stderr().is_terminal() +} + /// Format a severity string with optional ANSI colors. pub fn format_severity(s: &str, use_color: bool) -> String { if !use_color { diff --git a/crates/socket-patch-cli/src/update_notifier.rs b/crates/socket-patch-cli/src/update_notifier.rs new file mode 100644 index 0000000..1eb06a0 --- /dev/null +++ b/crates/socket-patch-cli/src/update_notifier.rs @@ -0,0 +1,417 @@ +//! Passive update-check notifier: at most once a day, on interactive +//! human-facing runs only, mention on stderr that a newer release exists. +//! +//! Model: after clap parses (and never for `--update` itself — `main` +//! skips the hook structurally), a guard stack decides whether a check may +//! run at all. If one is due, a spawned tokio task first records the +//! attempt (so "once a day" holds even if the process exits mid-fetch), +//! then fetches while the real command does its work; at the end of the +//! run the task is joined with a 500 ms grace budget. A fetch that misses +//! the budget is abandoned — a completed result surfaces as a zero-latency +//! cached notice on the NEXT run, a killed one waits for tomorrow's +//! attempt. +//! +//! Invariants (enforced by `update_notifier_e2e.rs`): +//! - a silenced run performs **zero network I/O**, not just zero output; +//! - the notifier can never change a command's exit code or stdout; +//! - it can never delay a command beyond the grace budget; +//! - state corruption/unwritability is silently absorbed. + +use std::time::Duration; + +use socket_patch_core::update::{ + self as core_update, detect_channel, is_newer, upgrade_hint, ChannelEnv, InstallChannel, + UpdateEndpoints, UpdateTimeouts, +}; + +use crate::args::GlobalArgs; +use crate::output; + +/// Everything the guard stack looks at, captured up front so the decision +/// logic is a pure, table-testable function. +#[derive(Debug, Clone)] +pub struct GuardCtx { + /// `SOCKET_NO_UPDATE_CHECK` truthy — the kill switch. Wins over + /// everything, including the force knob. + pub opted_out: bool, + pub offline: bool, + pub silent: bool, + pub json: bool, + /// `CI`/`GITHUB_ACTIONS` say a robot is watching. Always silences — + /// the force knob does NOT bypass it (tests neutralize with `CI=""`). + pub ci: bool, + pub stderr_tty: bool, + /// `SOCKET_UPDATE_NOTIFIER_FORCE` truthy — undocumented test hook that + /// bypasses ONLY the stderr-TTY guard (e2e children write to pipes). + pub forced: bool, + pub state_dir_resolvable: bool, +} + +/// Why the notifier stayed quiet (debug-logged under `--debug`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SkipReason { + OptedOut, + Offline, + Silent, + Json, + Ci, + NotATty, + NoStateDir, +} + +impl SkipReason { + fn as_str(self) -> &'static str { + match self { + SkipReason::OptedOut => "SOCKET_NO_UPDATE_CHECK is set", + SkipReason::Offline => "offline mode", + SkipReason::Silent => "--silent", + SkipReason::Json => "--json", + SkipReason::Ci => "CI environment", + SkipReason::NotATty => "stderr is not a terminal", + SkipReason::NoStateDir => "no resolvable state directory", + } + } +} + +/// The single place notifier-guard precedence is defined: +/// opt-out, offline, `--silent`, `--json`, and CI always silence; +/// the force knob bypasses the TTY guard alone. +pub fn should_check(ctx: &GuardCtx) -> Result<(), SkipReason> { + if ctx.opted_out { + return Err(SkipReason::OptedOut); + } + if ctx.offline { + return Err(SkipReason::Offline); + } + if ctx.silent { + return Err(SkipReason::Silent); + } + if ctx.json { + return Err(SkipReason::Json); + } + if ctx.ci { + return Err(SkipReason::Ci); + } + if !ctx.stderr_tty && !ctx.forced { + return Err(SkipReason::NotATty); + } + if !ctx.state_dir_resolvable { + return Err(SkipReason::NoStateDir); + } + Ok(()) +} + +fn env_flag(name: &str) -> bool { + matches!( + std::env::var(name) + .unwrap_or_default() + .trim() + .to_ascii_lowercase() + .as_str(), + "1" | "true" | "yes" | "on" | "y" | "t" + ) +} + +/// `CI` set to anything non-empty except an explicit falsy counts; +/// `GITHUB_ACTIONS` counts whenever non-empty. Deliberately short list — +/// the TTY guard covers other vendors' runners anyway. +fn in_ci() -> bool { + let ci = std::env::var("CI").unwrap_or_default(); + if !ci.is_empty() && !matches!(ci.trim().to_ascii_lowercase().as_str(), "0" | "false") { + return true; + } + !std::env::var("GITHUB_ACTIONS").unwrap_or_default().is_empty() +} + +impl GuardCtx { + /// Capture the real environment + the parsed global flags. + pub fn capture(common: &GlobalArgs) -> Self { + GuardCtx { + opted_out: env_flag("SOCKET_NO_UPDATE_CHECK"), + offline: common.offline, + silent: common.silent, + json: common.json, + ci: in_ci(), + stderr_tty: output::stderr_is_tty(), + forced: env_flag("SOCKET_UPDATE_NOTIFIER_FORCE"), + state_dir_resolvable: core_update::state::state_dir().is_some(), + } + } +} + +/// Handle carried across the command run. +pub struct Notifier { + /// Running fetch, present only when a check was due this run. + task: Option>>, + /// `latestSeen` loaded at spawn time — the cached fallback the notice + /// uses when the in-run fetch misses the grace budget (or none ran). + cached_latest: Option, + last_notified_at: Option, + debug: bool, +} + +fn debug_log(debug: bool, message: &str) { + if debug { + eprintln!("[socket-patch update] {message}"); + } +} + +/// Evaluate the guards and, when a check is due, start the background +/// fetch. Cheap on every path: env reads plus one tiny state-file read. +/// Returns `None` when the notifier is fully silenced for this run. +pub fn spawn_if_due(common: &GlobalArgs) -> Option { + let ctx = GuardCtx::capture(common); + let debug = common.debug; + if let Err(reason) = should_check(&ctx) { + debug_log(debug, &format!("skipped: {}", reason.as_str())); + return None; + } + + let state = core_update::load_state(); + let cached_latest = state + .latest_seen + .as_deref() + .and_then(|v| semver::Version::parse(v).ok()); + let now = core_update::unix_now(); + + let task = if core_update::check_is_due(state.last_check_at, now) { + debug_log(debug, "checking for updates in the background"); + Some(tokio::spawn(refresh_latest(debug))) + } else { + debug_log(debug, "check not due; using cached state"); + None + }; + + Some(Notifier { + task, + cached_latest, + last_notified_at: state.last_notified_at, + debug, + }) +} + +/// The background fetch, bounded hard at 2 s (or the test override). +/// +/// The ATTEMPT is persisted before the fetch, not after: the process may +/// exit (and kill this task) as soon as the carrier command finishes, and +/// on some platforms even a dead endpoint takes seconds to fail (Windows +/// retries SYNs to a closed port) — recording afterwards would let every +/// sub-grace command on a broken network burn a fresh fetch attempt. +/// Writing first makes "at most one attempt per day" hold unconditionally; +/// the cost is that a killed fetch's result waits for tomorrow's retry. +/// All errors are swallowed into debug logs. +async fn refresh_latest(debug: bool) -> Option { + let mut state = core_update::load_state(); + state.last_check_at = Some(core_update::unix_now()); + if let Err(e) = core_update::save_state(&state).await { + debug_log(debug, &format!("could not persist update state: {e}")); + } + + let endpoints = UpdateEndpoints::from_env(); + let override_ms = std::env::var("SOCKET_UPDATE_TIMEOUT_MS") + .ok() + .filter(|v| !v.is_empty()) + .and_then(|v| v.parse::().ok()); + let budget = Duration::from_millis(override_ms.unwrap_or(2000)); + let timeouts = UpdateTimeouts { + connect: budget, + metadata: budget, + download: budget, + }; + + let fetched = match core_update::fetch_latest_version(&endpoints, &timeouts).await { + Ok(v) => Some(v), + Err(e) => { + debug_log(debug, &format!("check failed: {e}")); + None + } + }; + + if let Some(v) = &fetched { + let mut state = core_update::load_state(); + state.last_check_at = Some(core_update::unix_now()); + state.latest_seen = Some(v.to_string()); + if let Err(e) = core_update::save_state(&state).await { + debug_log(debug, &format!("could not persist update state: {e}")); + } + } + fetched +} + +/// The channel-aware upgrade command for the notice's second line — +/// pointing an npm-installed user at `--update` would only route them into +/// its managed-install refusal. +fn upgrade_command() -> &'static str { + let channel = core_update::resolve_install_path() + .map(|p| detect_channel(&p, &ChannelEnv::from_env())) + .unwrap_or(InstallChannel::Standalone); + upgrade_hint(channel) +} + +/// Render the two-line notice. Pure for unit tests. +fn format_notice( + current: &semver::Version, + latest: &semver::Version, + hint: &str, + use_color: bool, +) -> String { + let new_version = output::color(&latest.to_string(), "32", use_color); + format!( + "[socket-patch] Update available: {current} \u{2192} {new_version}\n\ + [socket-patch] Run `{hint}` to upgrade (set SOCKET_NO_UPDATE_CHECK=1 to hide)" + ) +} + +/// Join the background fetch within the grace budget and print the notice +/// if one is warranted. Runs after all command output; never touches +/// stdout or the exit code. +pub async fn finish(notifier: Option) { + let Some(notifier) = notifier else { + return; + }; + let fetched = match notifier.task { + Some(handle) => { + match tokio::time::timeout(Duration::from_millis(500), handle).await { + Ok(Ok(result)) => result, + // Timed out (the task keeps running until process exit — + // its own state write may still land) or panicked; either + // way fall back to the cached value. + Ok(Err(_)) | Err(_) => { + debug_log(notifier.debug, "check missed the grace budget; will retry"); + None + } + } + } + None => None, + }; + + let latest_known = fetched.or(notifier.cached_latest); + let Some(latest) = latest_known else { + return; + }; + let current = core_update::current_version(); + if !is_newer(&latest, ¤t) { + return; + } + let now = core_update::unix_now(); + if !core_update::notice_is_due(notifier.last_notified_at, now) { + debug_log(notifier.debug, "update pending but notice already shown today"); + return; + } + + eprintln!( + "{}", + format_notice(¤t, &latest, upgrade_command(), output::stderr_is_tty()) + ); + + let mut state = core_update::load_state(); + state.last_notified_at = Some(now); + if let Err(e) = core_update::save_state(&state).await { + debug_log(notifier.debug, &format!("could not persist notice time: {e}")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn open_ctx() -> GuardCtx { + GuardCtx { + opted_out: false, + offline: false, + silent: false, + json: false, + ci: false, + stderr_tty: true, + forced: false, + state_dir_resolvable: true, + } + } + + #[test] + fn guard_precedence_table() { + // (mutation, expected outcome) — the full precedence contract in + // one table. e2e spot-checks a subset of rows end-to-end. + let cases: &[(&str, fn(&mut GuardCtx), Result<(), SkipReason>)] = &[ + ("all open", |_| {}, Ok(())), + ("opt-out", |c| c.opted_out = true, Err(SkipReason::OptedOut)), + ( + "opt-out beats force", + |c| { + c.opted_out = true; + c.forced = true; + }, + Err(SkipReason::OptedOut), + ), + ("offline", |c| c.offline = true, Err(SkipReason::Offline)), + ( + "offline beats force", + |c| { + c.offline = true; + c.forced = true; + }, + Err(SkipReason::Offline), + ), + ("silent", |c| c.silent = true, Err(SkipReason::Silent)), + ("json", |c| c.json = true, Err(SkipReason::Json)), + ( + "json beats force", + |c| { + c.json = true; + c.forced = true; + }, + Err(SkipReason::Json), + ), + ("ci", |c| c.ci = true, Err(SkipReason::Ci)), + ( + "ci beats force — force bypasses ONLY the TTY guard", + |c| { + c.ci = true; + c.forced = true; + }, + Err(SkipReason::Ci), + ), + ("no tty", |c| c.stderr_tty = false, Err(SkipReason::NotATty)), + ( + "force bypasses the tty guard", + |c| { + c.stderr_tty = false; + c.forced = true; + }, + Ok(()), + ), + ( + "no state dir", + |c| c.state_dir_resolvable = false, + Err(SkipReason::NoStateDir), + ), + ]; + for (name, mutate, expected) in cases { + let mut ctx = open_ctx(); + mutate(&mut ctx); + assert_eq!(&should_check(&ctx), expected, "case: {name}"); + } + } + + #[test] + fn notice_names_versions_hint_and_optout() { + let current = semver::Version::new(3, 3, 0); + let latest = semver::Version::new(3, 4, 0); + let plain = format_notice(¤t, &latest, "socket-patch --update", false); + assert!(plain.contains("3.3.0"), "{plain}"); + assert!(plain.contains("3.4.0"), "{plain}"); + assert!(plain.contains("socket-patch --update"), "{plain}"); + assert!(plain.contains("SOCKET_NO_UPDATE_CHECK=1"), "{plain}"); + assert!( + !plain.contains("\u{1b}["), + "no ANSI codes without a terminal: {plain}" + ); + let colored = format_notice(¤t, &latest, "socket-patch --update", true); + assert!(colored.contains("\u{1b}["), "{colored}"); + // Two lines, both stderr-prefixed for grep-ability. + for line in plain.lines() { + assert!(line.starts_with("[socket-patch]"), "{line}"); + } + assert_eq!(plain.lines().count(), 2); + } +} diff --git a/crates/socket-patch-cli/tests/cli_global_args.rs b/crates/socket-patch-cli/tests/cli_global_args.rs index 907af1d..c5c3cd9 100644 --- a/crates/socket-patch-cli/tests/cli_global_args.rs +++ b/crates/socket-patch-cli/tests/cli_global_args.rs @@ -31,7 +31,17 @@ use socket_patch_cli::Cli; /// being listed here — closing the "someone forgot the flatten on a new /// command and nobody noticed" gap this file claims to guard. const SUBCOMMANDS_NO_POSITIONAL: &[&str] = &[ - "apply", "list", "scan", "setup", "repair", "rollback", "vendor", "vex", + "apply", + "list", + "scan", + "setup", + "repair", + "rollback", + "vendor", + "vex", + // Hidden parse target of the root `--update` flag; its VERSION + // positional is optional, so the no-positional variant covers it. + "self-update", ]; /// Subcommands that require a positional identifier. @@ -120,6 +130,7 @@ fn common_of(cli: &Cli) -> &GlobalArgs { Repair(a) => &a.common, Vendor(a) => &a.common, Vex(a) => &a.common, + SelfUpdate(a) => &a.common, } } diff --git a/crates/socket-patch-cli/tests/common/mod.rs b/crates/socket-patch-cli/tests/common/mod.rs index 7b39000..564aab5 100644 --- a/crates/socket-patch-cli/tests/common/mod.rs +++ b/crates/socket-patch-cli/tests/common/mod.rs @@ -57,7 +57,21 @@ pub fn run(cwd: &Path, args: &[&str]) -> (i32, String, String) { /// or override discovery roots (`NUGET_PACKAGES`, `GOMODCACHE`) without /// touching the parent process's environment — keeps tests parallel-safe. pub fn run_with_env(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, String, String) { - let mut cmd = Command::new(binary()); + run_bin_with_env(&binary(), cwd, args, env) +} + +/// The scrub-and-run core of [`run_with_env`], parameterized over the +/// binary path so the self-update suites can spawn a *copy* of the built +/// binary (staged into a tempdir) under the exact same hermetic env as +/// every other e2e test. `CARGO_BIN_EXE_socket-patch` itself must never +/// be the target of an `--update` swap. +pub fn run_bin_with_env( + bin: &Path, + cwd: &Path, + args: &[&str], + env: &[(&str, &str)], +) -> (i32, String, String) { + let mut cmd = Command::new(bin); cmd.args(args).current_dir(cwd); // The binary binds a wide `SOCKET_*` env surface (SOCKET_CWD, // SOCKET_DRY_RUN, SOCKET_STRICT, SOCKET_GLOBAL, SOCKET_MANIFEST_PATH, @@ -78,6 +92,8 @@ pub fn run_with_env(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, St .env("SOCKET_JSON", "true") .env("SOCKET_SILENT", "true") .env("SOCKET_VERBOSE", "true") + .env("SOCKET_UPDATE_BASE_URL", "http://127.0.0.1:1") + .env("SOCKET_UPDATE_STATE_DIR", "/nonexistent") .env_remove("SOCKET_GLOBAL") .env_remove("SOCKET_GLOBAL_PREFIX") .env_remove("SOCKET_DRY_RUN") @@ -85,6 +101,8 @@ pub fn run_with_env(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, St .env_remove("SOCKET_JSON") .env_remove("SOCKET_SILENT") .env_remove("SOCKET_VERBOSE") + .env_remove("SOCKET_UPDATE_BASE_URL") + .env_remove("SOCKET_UPDATE_STATE_DIR") .env_remove("SOCKET_API_TOKEN"); // Prefix-scrub whatever else the ambient shell carries; removing // SOCKET_API_TOKEN also forces the public proxy (free-tier). @@ -92,7 +110,10 @@ pub fn run_with_env(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, St // stays opted out. for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" + if name.starts_with("SOCKET_") + && !name.contains("TELEMETRY") + && name != "SOCKET_NO_CONFIG" + && name != "SOCKET_NO_UPDATE_CHECK" { cmd.env_remove(&key); } @@ -102,6 +123,12 @@ pub fn run_with_env(cwd: &Path, args: &[&str], env: &[(&str, &str)]) -> (i32, St // fallback) must never authenticate a test child — it would flip every // "no token → public proxy" assertion onto the authed path. cmd.env("SOCKET_NO_CONFIG", "1"); + // Same posture for the passive update notifier: no test child may ever + // fetch release metadata from real GitHub. The stderr-TTY guard covers + // piped children, but the PTY suites hand the binary a real terminal — + // this force-set is the layer that holds there. Notifier tests opt back + // in via caller env (which lands last). + cmd.env("SOCKET_NO_UPDATE_CHECK", "1"); // Caller-supplied env lands last so explicit injections (runtime // gates, discovery roots) survive the scrub. for (k, v) in env { diff --git a/crates/socket-patch-cli/tests/common/update_fixture.rs b/crates/socket-patch-cli/tests/common/update_fixture.rs new file mode 100644 index 0000000..3ba4de9 --- /dev/null +++ b/crates/socket-patch-cli/tests/common/update_fixture.rs @@ -0,0 +1,586 @@ +//! Shared fixture for the self-update e2e suites: a staged copy of the +//! real binary (so `--update` never aims at the `CARGO_BIN_EXE` build +//! artifact) plus a wiremock fake of the GitHub release surface. +//! +//! Consumers pull this in alongside the main helpers: +//! ```ignore +//! #[path = "common/mod.rs"] +//! mod common; +//! #[path = "common/update_fixture.rs"] +//! mod update_fixture; +//! ``` + +#![allow(dead_code)] + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use sha2::{Digest, Sha256}; +use wiremock::matchers::{method, path as urlpath}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +use socket_patch_cli::commands::update::UPDATE_TARGET; +use socket_patch_core::update::asset_name_for_target; + +/// Release asset filename for the target this test binary was built for. +pub fn asset_name_for_current_target() -> String { + asset_name_for_target(UPDATE_TARGET) +} + +/// Run the staged install's binary under the standard hermetic scrub +/// (`common::run_bin_with_env`), plus the update kit: the state dir points +/// into the tempdir and the notifier stays off unless the caller's env — +/// which lands last and wins — flips it back on. +/// +/// NOTE: resolves `crate::common`, so consumers must declare +/// `#[path = "common/mod.rs"] mod common;` BEFORE this module. +pub fn run_installed( + install: &StagedInstall, + args: &[&str], + env: &[(&str, &str)], +) -> (i32, String, String) { + let state_dir = install.state_dir.display().to_string(); + let mut merged: Vec<(&str, &str)> = vec![ + ("SOCKET_UPDATE_STATE_DIR", state_dir.as_str()), + ("SOCKET_NO_UPDATE_CHECK", "1"), + ]; + merged.extend_from_slice(env); + crate::common::run_bin_with_env(&install.bin, &install.workdir, args, &merged) +} + +// ── Staged install ───────────────────────────────────────────────────── + +/// A copy of the built binary living in its own tempdir "install", with +/// enough recorded state to prove (or disprove) a swap afterwards. +pub struct StagedInstall { + pub root: tempfile::TempDir, + /// `/bin/socket-patch[.exe]` — the copy tests run and update. + pub bin: PathBuf, + /// `/state` — SOCKET_UPDATE_STATE_DIR for the child. + pub state_dir: PathBuf, + /// `/work` — the child's cwd; update must never create + /// `.socket/` here. + pub workdir: PathBuf, + /// SHA-256 of the binary at staging time. + pub pre_hash: String, + /// Inode at staging time (a rename-based swap always changes it; an + /// in-place overwrite of the running binary keeps it). + #[cfg(unix)] + pub pre_ino: u64, +} + +pub fn sha256_file(p: &Path) -> String { + hex::encode(Sha256::digest(std::fs::read(p).expect("read file for hashing"))) +} + +fn real_binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_socket-patch")) +} + +fn bin_file_name() -> &'static str { + if cfg!(windows) { + "socket-patch.exe" + } else { + "socket-patch" + } +} + +/// Copy the built binary into a fresh tempdir install layout. +pub fn staged_install() -> StagedInstall { + staged_install_at("bin") +} + +/// Like [`staged_install`], but places the binary under an arbitrary +/// relative directory — the channel-detection suites craft shapes like +/// `node_modules/@socketsecurity/socket-patch-x/bin`. +pub fn staged_install_at(rel_bin_dir: &str) -> StagedInstall { + let root = tempfile::tempdir().expect("create install tempdir"); + let bin_dir = root.path().join(rel_bin_dir); + std::fs::create_dir_all(&bin_dir).expect("create bin dir"); + let bin = bin_dir.join(bin_file_name()); + std::fs::copy(real_binary(), &bin).expect("copy binary into staged install"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) + .expect("chmod staged binary"); + } + let state_dir = root.path().join("state"); + std::fs::create_dir_all(&state_dir).expect("create state dir"); + let workdir = root.path().join("work"); + std::fs::create_dir_all(&workdir).expect("create workdir"); + let pre_hash = sha256_file(&bin); + #[cfg(unix)] + let pre_ino = { + use std::os::unix::fs::MetadataExt; + std::fs::metadata(&bin).expect("stat staged binary").ino() + }; + StagedInstall { + root, + bin, + state_dir, + workdir, + pre_hash, + #[cfg(unix)] + pre_ino, + } +} + +impl StagedInstall { + /// The binary is byte-identical to staging time and still executes. + pub fn assert_binary_intact(&self) { + assert_eq!( + sha256_file(&self.bin), + self.pre_hash, + "installed binary must be untouched" + ); + let out = std::process::Command::new(&self.bin) + .arg("--version") + .output() + .expect("spawn staged binary"); + assert!(out.status.success(), "staged binary must still run"); + } + + /// `bin/` contains exactly the binary — no `.old` parked exes, no + /// stage droppings. Retries briefly for Windows delete-pending files. + pub fn assert_only_binary_present(&self) { + let dir = self.bin.parent().unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + loop { + let extras: Vec = std::fs::read_dir(dir) + .expect("read bin dir") + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n != bin_file_name()) + .collect(); + if extras.is_empty() { + return; + } + if std::time::Instant::now() > deadline { + panic!("unexpected files next to the binary: {extras:?}"); + } + std::thread::sleep(Duration::from_millis(100)); + } + } + + /// Update never touches project scope. + pub fn assert_workdir_untouched(&self) { + assert!( + !self.workdir.join(".socket").exists(), + "update must not create .socket/ in the working directory" + ); + } + + /// The real build artifact was never the swap target. + pub fn assert_build_artifact_untouched(pre_hash_of_real: &str) { + assert_eq!( + sha256_file(&real_binary()), + pre_hash_of_real, + "CARGO_BIN_EXE binary must never be modified by update tests" + ); + } +} + +/// Hash of the real build artifact — capture once at test start, compare +/// via [`StagedInstall::assert_build_artifact_untouched`] at the end. +pub fn real_binary_hash() -> String { + sha256_file(&real_binary()) +} + +// ── The served "new binary" ──────────────────────────────────────────── + +/// Bytes to serve as the release's binary, plus whether they are +/// byte-distinct from the current binary (drives which swap assertion the +/// crux test can make). +/// +/// Linux (ELF) and Windows (PE) loaders ignore trailing bytes, so the real +/// binary plus a marker trailer is an executable that (a) runs, (b) +/// reports the real version, and (c) differs byte-wise from the original. +/// macOS arm64 mandates a valid code signature that trailing garbage +/// breaks — there we serve pristine bytes and rely on inode-change +/// evidence instead. The `make_served_binary_output_execs` self-test below +/// is the canary that fails loudly if a platform stops tolerating this. +pub fn make_served_binary() -> (Vec, bool) { + let mut bytes = std::fs::read(real_binary()).expect("read real binary"); + if cfg!(target_os = "macos") { + (bytes, false) + } else { + bytes.extend_from_slice(b"\nSOCKET-PATCH-E2E-TRAILER-0123456789abcdef0123456789abcdef\n"); + (bytes, true) + } +} + +// ── Archive + SHA256SUMS builders ────────────────────────────────────── + +/// Wrap `binary_bytes` the way release CI does: tar.gz with a single +/// `socket-patch` (mode 0755) entry, or a zip with `socket-patch.exe`. +pub fn archive_for_current_target(binary_bytes: &[u8]) -> Vec { + if cfg!(windows) { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut buf); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + use std::io::Write; + writer + .start_file("socket-patch.exe", opts) + .expect("zip start_file"); + writer.write_all(binary_bytes).expect("zip write"); + writer.finish().expect("zip finish"); + } + buf.into_inner() + } else { + let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + let mut builder = tar::Builder::new(gz); + let mut header = tar::Header::new_gnu(); + header.set_size(binary_bytes.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder + .append_data(&mut header, "socket-patch", binary_bytes) + .expect("tar append"); + builder + .into_inner() + .expect("tar finish") + .finish() + .expect("gzip finish") + } +} + +/// `sha256sum`-format body: ` ` per line, sorted like +/// release.yml's `sha256sum * | sort`. +pub fn sha256sums_for(assets: &[(String, Vec)]) -> String { + let mut lines: Vec = assets + .iter() + .map(|(name, bytes)| format!("{} {name}", hex::encode(Sha256::digest(bytes)))) + .collect(); + lines.sort(); + lines.join("\n") + "\n" +} + +// ── Fake release server ──────────────────────────────────────────────── + +pub struct FakeRelease { + pub server: MockServer, + /// Value for the child's SOCKET_UPDATE_BASE_URL. + pub base_url: String, + pub version: String, +} + +impl FakeRelease { + /// Cross-cutting request hygiene: the updater must never send the + /// Socket bearer to a release host, and must identify itself. + pub async fn verify_request_hygiene(&self) { + for req in self.server.received_requests().await.unwrap_or_default() { + assert!( + !req.headers.contains_key("authorization"), + "no request to the release host may carry an Authorization header: {} {}", + req.method, + req.url + ); + let ua = req + .headers + .get("user-agent") + .map(|v| v.to_str().unwrap_or("").to_string()) + .unwrap_or_default(); + assert!( + ua.starts_with("SocketPatchCLI/"), + "User-Agent must identify the CLI, got {ua:?} on {} {}", + req.method, + req.url + ); + } + } + + pub async fn received_request_count(&self) -> usize { + self.server.received_requests().await.unwrap_or_default().len() + } +} + +#[derive(Default)] +pub struct FakeReleaseBuilder { + version: String, + assets: Vec<(String, Vec)>, + corrupt_sums_for: Vec, + omit_sums_for: Vec, + omit_sums_file: bool, + omit_assets: Vec, + truncate: Vec<(String, usize)>, + metadata_delay: Option, + asset_delay: Option, + expect_resolves: Option, + expect_sums: Option, + expect_asset_downloads: Option, +} + +impl FakeReleaseBuilder { + pub fn new(version: &str) -> Self { + FakeReleaseBuilder { + version: version.to_string(), + ..Default::default() + } + } + + /// Add `binary_bytes`, wrapped as the archive for the current target. + pub fn asset_for_current_target(mut self, binary_bytes: &[u8]) -> Self { + self.assets.push(( + asset_name_for_current_target(), + archive_for_current_target(binary_bytes), + )); + self + } + + /// Add a raw pre-built asset (exotic shapes: garbage archives, other + /// targets). + pub fn raw_asset(mut self, filename: &str, bytes: Vec) -> Self { + self.assets.push((filename.to_string(), bytes)); + self + } + + /// Flip a nibble in this asset's SHA256SUMS entry. + pub fn corrupt_sums_entry_for(mut self, filename: &str) -> Self { + self.corrupt_sums_for.push(filename.to_string()); + self + } + + /// Leave this asset out of SHA256SUMS entirely. + pub fn omit_sums_entry_for(mut self, filename: &str) -> Self { + self.omit_sums_for.push(filename.to_string()); + self + } + + /// SHA256SUMS itself 404s. + pub fn omit_sums_file(mut self) -> Self { + self.omit_sums_file = true; + self + } + + /// Keep the asset in SHA256SUMS but 404 its download. + pub fn omit_asset(mut self, filename: &str) -> Self { + self.omit_assets.push(filename.to_string()); + self + } + + /// Serve only the first `keep` bytes (SHA256SUMS covers the full + /// bytes, so this manifests as a checksum mismatch). + pub fn truncate_asset(mut self, filename: &str, keep: usize) -> Self { + self.truncate.push((filename.to_string(), keep)); + self + } + + pub fn delay_metadata(mut self, d: Duration) -> Self { + self.metadata_delay = Some(d); + self + } + + pub fn delay_asset(mut self, d: Duration) -> Self { + self.asset_delay = Some(d); + self + } + + /// Pin exact hit counts (verified when the MockServer drops). + pub fn expect_resolves(mut self, n: u64) -> Self { + self.expect_resolves = Some(n); + self + } + + pub fn expect_sums_fetches(mut self, n: u64) -> Self { + self.expect_sums = Some(n); + self + } + + pub fn expect_asset_downloads(mut self, n: u64) -> Self { + self.expect_asset_downloads = Some(n); + self + } + + pub async fn mount(self) -> FakeRelease { + let server = MockServer::start().await; + let base = server.uri(); + let ver = &self.version; + + // Latest resolution, redirect style (the primary path). + let mut resolve = Mock::given(method("GET")) + .and(urlpath("/SocketDev/socket-patch/releases/latest")) + .respond_with({ + let mut resp = ResponseTemplate::new(302).insert_header( + "Location", + format!("{base}/SocketDev/socket-patch/releases/tag/v{ver}").as_str(), + ); + if let Some(d) = self.metadata_delay { + resp = resp.set_delay(d); + } + resp + }); + if let Some(n) = self.expect_resolves { + resolve = resolve.expect(n); + } + resolve.mount(&server).await; + + // Latest resolution, API style (the fallback path) — mounted too so + // the fixture survives either resolution choice. + Mock::given(method("GET")) + .and(urlpath("/repos/SocketDev/socket-patch/releases/latest")) + .respond_with({ + let mut resp = ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "tag_name": format!("v{ver}"), + "assets": self.assets.iter().map(|(name, _)| serde_json::json!({ + "name": name, + "browser_download_url": format!( + "{base}/SocketDev/socket-patch/releases/download/v{ver}/{name}" + ), + })).collect::>(), + })); + if let Some(d) = self.metadata_delay { + resp = resp.set_delay(d); + } + resp + }) + .mount(&server) + .await; + + // SHA256SUMS (unless withheld), with requested corruptions. + if !self.omit_sums_file { + let mut sums = sha256sums_for( + &self + .assets + .iter() + .filter(|(name, _)| !self.omit_sums_for.contains(name)) + .cloned() + .collect::>(), + ); + for name in &self.corrupt_sums_for { + // Flip the first hex nibble of the matching line. Match on + // the full " " suffix, not a bare ends_with — a + // bare match would also corrupt a DIFFERENT asset whose + // name merely ends with this one ("a.tar.gz" vs + // "socket-patch-a.tar.gz"). + let suffix = format!(" {name}"); + sums = sums + .lines() + .map(|line| { + if line.ends_with(&suffix) { + let flipped = if line.starts_with('0') { "f" } else { "0" }; + format!("{flipped}{}", &line[1..]) + } else { + line.to_string() + } + }) + .collect::>() + .join("\n") + + "\n"; + } + let mut sums_mock = Mock::given(method("GET")) + .and(urlpath(format!( + "/SocketDev/socket-patch/releases/download/v{ver}/SHA256SUMS" + ))) + .respond_with({ + let mut resp = ResponseTemplate::new(200).set_body_string(sums); + if let Some(d) = self.metadata_delay { + resp = resp.set_delay(d); + } + resp + }); + if let Some(n) = self.expect_sums { + sums_mock = sums_mock.expect(n); + } + sums_mock.mount(&server).await; + } + + // The assets themselves. + for (name, bytes) in &self.assets { + if self.omit_assets.contains(name) { + continue; + } + let body = match self.truncate.iter().find(|(n, _)| n == name) { + Some((_, keep)) => bytes[..(*keep).min(bytes.len())].to_vec(), + None => bytes.clone(), + }; + let mut asset_mock = Mock::given(method("GET")) + .and(urlpath(format!( + "/SocketDev/socket-patch/releases/download/v{ver}/{name}" + ))) + .respond_with({ + let mut resp = ResponseTemplate::new(200).set_body_bytes(body); + if let Some(d) = self.asset_delay { + resp = resp.set_delay(d); + } + resp + }); + if let Some(n) = self.expect_asset_downloads { + asset_mock = asset_mock.expect(n); + } + asset_mock.mount(&server).await; + } + + FakeRelease { + base_url: base, + server, + version: self.version, + } + } +} + +// ── Fixture self-tests (house style: run in every consuming binary) ──── + +#[cfg(test)] +mod fixture_selftests { + use super::*; + + /// THE canary: if a platform ever stops tolerating trailer bytes on + /// its executables, this fails here — loudly — instead of the crux + /// test silently degrading. + #[test] + fn make_served_binary_output_execs() { + let (bytes, byte_distinct) = make_served_binary(); + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join(bin_file_name()); + std::fs::write(&path, &bytes).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + let out = std::process::Command::new(&path) + .arg("--version") + .output() + .expect("spawn served binary"); + assert!( + out.status.success(), + "served binary must exec --version cleanly (trailer tolerance)" + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(stdout.starts_with("socket-patch"), "{stdout}"); + if byte_distinct { + assert_ne!( + sha256_file(&path), + real_binary_hash(), + "trailered bytes must differ from the original" + ); + } + } + + #[test] + fn staged_install_copies_not_links() { + let install = staged_install(); + assert_eq!(sha256_file(&install.bin), real_binary_hash()); + assert_ne!(install.bin, real_binary()); + assert!( + !std::fs::symlink_metadata(&install.bin) + .unwrap() + .file_type() + .is_symlink(), + "staged install must be a real copy" + ); + install.assert_binary_intact(); + install.assert_only_binary_present(); + } + + #[test] + fn sums_builder_matches_sha256sum_format() { + let assets = vec![("a.tar.gz".to_string(), b"hello".to_vec())]; + let sums = sha256sums_for(&assets); + let expected = hex::encode(Sha256::digest(b"hello")); + assert_eq!(sums, format!("{expected} a.tar.gz\n")); + } +} diff --git a/crates/socket-patch-cli/tests/docker_e2e_composer.rs b/crates/socket-patch-cli/tests/docker_e2e_composer.rs index 0fbacd8..96680bf 100644 --- a/crates/socket-patch-cli/tests/docker_e2e_composer.rs +++ b/crates/socket-patch-cli/tests/docker_e2e_composer.rs @@ -221,9 +221,15 @@ mkdir -p /workspace/proj && cd /workspace/proj cat > composer.json <<'EOF' {{ "name": "test/e2e", "type": "project", "require": {{}} }} EOF -composer require --quiet --no-interaction monolog/monolog:3.5.0 > /tmp/install.log 2>&1 || {{ - cat /tmp/install.log >&2; exit 1 -}} +# monolog arrives over the real network (packagist metadata + the GitHub +# zipball); transient stream/connection errors are the dominant flake in +# this suite — retry with backoff before declaring the fixture broken. +for attempt in 1 2 3; do + composer require --quiet --no-interaction monolog/monolog:3.5.0 > /tmp/install.log 2>&1 && break + if [ "$attempt" = 3 ]; then cat /tmp/install.log >&2; exit 1; fi + echo "composer require attempt $attempt failed; retrying" >&2 + sleep $((attempt * 5)) +done PHP_FILE="vendor/monolog/monolog/src/Monolog/Logger.php" [ -f "$PHP_FILE" ] || {{ echo "FAIL: $PHP_FILE missing" >&2; ls vendor/monolog/monolog/src/Monolog/ >&2 || true; exit 1; }} @@ -290,9 +296,13 @@ set -uo pipefail EXPECTED_SHA='{expected_sha}' # composer global require installs into $COMPOSER_HOME/vendor/. -composer global require --quiet --no-interaction monolog/monolog:3.5.0 > /tmp/install.log 2>&1 || {{ - cat /tmp/install.log >&2; exit 1 -}} +# Same transient-network retry as the local leg (packagist + zipball). +for attempt in 1 2 3; do + composer global require --quiet --no-interaction monolog/monolog:3.5.0 > /tmp/install.log 2>&1 && break + if [ "$attempt" = 3 ]; then cat /tmp/install.log >&2; exit 1; fi + echo "composer global require attempt $attempt failed; retrying" >&2 + sleep $((attempt * 5)) +done COMPOSER_DIR=$(composer config --global home) PHP_FILE="$COMPOSER_DIR/vendor/monolog/monolog/src/Monolog/Logger.php" diff --git a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs index 8ded6cf..9c771e4 100644 --- a/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs +++ b/crates/socket-patch-cli/tests/interactive_prompts_e2e.rs @@ -84,11 +84,20 @@ fn run_in_pty_bytes(args: &[&str], cwd: &Path, input: &[u8], timeout: Duration) // so an opted-out dev stays opted out. for (key, _) in std::env::vars_os() { let name = key.to_string_lossy(); - if name.starts_with("SOCKET_") && !name.contains("TELEMETRY") && name != "SOCKET_NO_CONFIG" + if name.starts_with("SOCKET_") + && !name.contains("TELEMETRY") + && name != "SOCKET_NO_CONFIG" + && name != "SOCKET_NO_UPDATE_CHECK" { cmd.env_remove(&key); } } + // This suite is the one place test children get a REAL terminal, so the + // update notifier's stderr-TTY guard does not protect it. Force the + // kill-switch (mirroring the `.cargo/config.toml` `[env]` default the + // prefix scrub would otherwise strip) so no PTY child ever fetches + // release metadata mid-prompt. + cmd.env("SOCKET_NO_UPDATE_CHECK", "1"); let mut child = pair .slave diff --git a/crates/socket-patch-cli/tests/self_update_channels_e2e.rs b/crates/socket-patch-cli/tests/self_update_channels_e2e.rs new file mode 100644 index 0000000..475821c --- /dev/null +++ b/crates/socket-patch-cli/tests/self_update_channels_e2e.rs @@ -0,0 +1,341 @@ +//! Install-channel detection e2e for `socket-patch --update`. +//! +//! The channel heuristics are pure functions unit-tested in core +//! (`update/channel.rs`); what only an e2e can pin is the wiring — that the +//! spawned binary classifies its OWN canonicalized `current_exe`, refuses +//! managed installs before any network I/O, prints the owning manager's +//! upgrade command, and that `--force` genuinely overrides. `current_exe` +//! can't be faked, so each test makes it real: `staged_install_at` copies +//! the built binary into a crafted directory shape and the test spawns that +//! copy. +//! +//! macOS gotcha baked into the env-rooted rows: tempdirs live under +//! `/var/folders/…`, a symlink to `/private/var/…`, and the updater +//! canonicalizes the exe path before matching it against CARGO_HOME / +//! XDG_CACHE_HOME. Those roots must therefore be passed canonicalized or +//! the prefix comparison never fires — which is exactly the behavior the +//! real cargo/launcher installers see, since they resolve real paths. + +#[path = "common/mod.rs"] +mod common; +#[path = "common/update_fixture.rs"] +mod update_fixture; + +use sha2::{Digest, Sha256}; +use update_fixture::{ + make_served_binary, run_installed, sha256_file, staged_install, staged_install_at, + FakeReleaseBuilder, +}; + +const CURRENT: &str = env!("CARGO_PKG_VERSION"); + +/// Base URL for refusal rows that don't need a mock: a dead port. The +/// refusal must precede all network traffic, so nothing should ever +/// connect — and if the gate regresses, the run fails fast against +/// 127.0.0.1 instead of leaking a request to real GitHub. +const DEAD_BASE_URL: &str = "http://127.0.0.1:1"; + +/// An npm-bundled binary (any `node_modules` component) refuses with the +/// npm upgrade command — and the refusal happens before ANY release +/// traffic: a fully valid, newer release is mounted and its routes must +/// never be hit. A wasted download before the refusal is the bug class. +#[tokio::test] +async fn npm_bundled_refuses_with_npm_hint() { + let install = staged_install_at("node_modules/@socketsecurity/socket-patch-x/bin"); + let (served, _) = make_served_binary(); + let release = FakeReleaseBuilder::new("9.9.9") + .asset_for_current_target(&served) + .mount() + .await; + + let (code, _stdout, stderr) = run_installed( + &install, + &["--update", "--yes"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 1, "managed install must refuse.\nstderr:\n{stderr}"); + assert!( + stderr.contains("npm update -g @socketsecurity/socket-patch"), + "refusal must route to npm's own upgrade command: {stderr}" + ); + assert!( + stderr.contains("--force"), + "refusal must mention the escape hatch: {stderr}" + ); + + // Same refusal in machine shape. + let (code, stdout, _) = run_installed( + &install, + &["--update", "--json"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 1); + let env = common::parse_json_envelope(&stdout); + assert_eq!(common::json_string(&env, "status"), Some("error")); + assert_eq!(common::envelope_error_code(&env), Some("managed_install")); + + // The crux: two refusals, zero requests — channel detection ran on + // path + env alone. + assert_eq!( + release.received_request_count().await, + 0, + "channel refusal must precede any release-host traffic" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); + release.verify_request_hygiene().await; +} + +/// A PyPI-wheel-bundled binary (`site-packages` component) refuses with +/// the pip upgrade command. +#[tokio::test] +async fn pip_bundled_refuses_with_pip_hint() { + let install = staged_install_at("venv/lib/python3.12/site-packages/socket_patch/bin"); + + let (code, _stdout, stderr) = run_installed( + &install, + &["--update", "--yes"], + &[("SOCKET_UPDATE_BASE_URL", DEAD_BASE_URL)], + ); + assert_eq!(code, 1, "pip-managed install must refuse.\nstderr:\n{stderr}"); + assert!( + stderr.contains("pip install --upgrade socket-patch"), + "refusal must route to pip's own upgrade command: {stderr}" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); +} + +/// A `cargo install`ed binary lives under `$CARGO_HOME/bin`; the shape is +/// only meaningful relative to the env var, so this is the row that pins +/// the env half of detection (CARGO_HOME is NOT part of the hermetic +/// scrub — the override must win over the developer's real one). +#[tokio::test] +async fn cargo_install_refuses_with_cargo_hint() { + let install = staged_install_at("cargo-home/bin"); + // Canonicalized so the prefix check survives the /var → /private/var + // tempdir symlink on macOS (see module docs). + let cargo_home = install + .bin + .parent() + .unwrap() + .parent() + .unwrap() + .canonicalize() + .expect("canonicalize crafted CARGO_HOME"); + let cargo_home = cargo_home.display().to_string(); + + let (code, _stdout, stderr) = run_installed( + &install, + &["--update", "--yes"], + &[ + ("SOCKET_UPDATE_BASE_URL", DEAD_BASE_URL), + ("CARGO_HOME", &cargo_home), + ], + ); + assert_eq!(code, 1, "cargo-managed install must refuse.\nstderr:\n{stderr}"); + assert!( + stderr.contains("cargo install socket-patch-cli"), + "refusal must route to cargo's own upgrade command: {stderr}" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); +} + +/// The gem/composer launchers exec a per-version cached binary under +/// `/socket-patch/bin///`; replacing the cache +/// entry is meaningless (the launcher re-resolves every run), so the +/// refusal points at BOTH managers — the path can't tell them apart. +/// Unix resolution goes through XDG_CACHE_HOME. +#[cfg(unix)] +#[tokio::test] +async fn launcher_cache_refuses_with_gem_composer_hint() { + let install = staged_install_at("cache/socket-patch/bin/3.3.0/x86_64-unknown-linux-gnu"); + let cache_root = install + .root + .path() + .join("cache") + .canonicalize() + .expect("canonicalize crafted cache root"); + let cache_root = cache_root.display().to_string(); + + let (code, _stdout, stderr) = run_installed( + &install, + &["--update", "--yes"], + &[ + ("SOCKET_UPDATE_BASE_URL", DEAD_BASE_URL), + ("XDG_CACHE_HOME", &cache_root), + ], + ); + assert_eq!( + code, 1, + "launcher-cache install must refuse.\nstderr:\n{stderr}" + ); + assert!( + stderr.contains("gem update") && stderr.contains("composer update"), + "the shared cache layout can't distinguish gem from composer, so \ + the hint must name both: {stderr}" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); +} + +/// Windows twin of the launcher-cache row: resolution goes through +/// %LOCALAPPDATA% there (no ~/.cache convention). +#[cfg(windows)] +#[tokio::test] +async fn launcher_cache_refuses_with_gem_composer_hint_windows() { + let install = staged_install_at("cache/socket-patch/bin/3.3.0/x86_64-pc-windows-msvc"); + // Canonicalized for the same reason as the unix rows: the exe path is + // canonicalized (verbatim \\?\ form on Windows), so the root must be + // in the same form for the prefix check to fire. + let cache_root = install + .root + .path() + .join("cache") + .canonicalize() + .expect("canonicalize crafted cache root"); + let cache_root = cache_root.display().to_string(); + + let (code, _stdout, stderr) = run_installed( + &install, + &["--update", "--yes"], + &[ + ("SOCKET_UPDATE_BASE_URL", DEAD_BASE_URL), + ("LOCALAPPDATA", &cache_root), + ], + ); + assert_eq!( + code, 1, + "launcher-cache install must refuse.\nstderr:\n{stderr}" + ); + assert!( + stderr.contains("gem update") && stderr.contains("composer update"), + "the shared cache layout can't distinguish gem from composer, so \ + the hint must name both: {stderr}" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); +} + +/// `--force` overrides the channel refusal: the npm-bundled copy really +/// gets replaced, but the "your package manager will revert this" warning +/// still lands — silent override is the bug class. +#[tokio::test] +async fn force_overrides_channel_refusal() { + let real_hash = update_fixture::real_binary_hash(); + let install = staged_install_at("node_modules/@socketsecurity/socket-patch-x/bin"); + let (served, _) = make_served_binary(); + let served_hash = hex::encode(Sha256::digest(&served)); + + let release = FakeReleaseBuilder::new(CURRENT) + .asset_for_current_target(&served) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--force", "--yes"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!( + code, 0, + "--force must proceed past the channel gate.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!( + stderr.contains("managed by npm"), + "override must still warn that npm owns this install: {stderr}" + ); + assert_eq!( + sha256_file(&install.bin), + served_hash, + "the copy inside node_modules must be the served payload" + ); + + install.assert_only_binary_present(); + release.verify_request_hygiene().await; + update_fixture::StagedInstall::assert_build_artifact_untouched(&real_hash); +} + +/// Canonicalization pin: the binary physically lives in the node_modules +/// shape but is invoked through a plain symlink elsewhere — exactly how +/// npm `.bin/` shims exec. Detection must classify the resolved target, +/// not the innocent-looking link path. +#[cfg(unix)] +#[tokio::test] +async fn symlinked_invocation_still_detected() { + let install = staged_install_at("node_modules/@socketsecurity/socket-patch-x/bin"); + let straight = install.root.path().join("straight"); + std::fs::create_dir_all(&straight).expect("create symlink dir"); + let link = straight.join("socket-patch"); + std::os::unix::fs::symlink(&install.bin, &link).expect("create symlink"); + + let state_dir = install.state_dir.display().to_string(); + let (code, _stdout, stderr) = common::run_bin_with_env( + &link, + &install.workdir, + &["--update", "--yes"], + &[ + ("SOCKET_UPDATE_STATE_DIR", &state_dir), + ("SOCKET_UPDATE_BASE_URL", DEAD_BASE_URL), + ], + ); + assert_eq!( + code, 1, + "symlinked invocation must still hit the npm refusal.\nstderr:\n{stderr}" + ); + assert!( + stderr.contains("npm update -g @socketsecurity/socket-patch"), + "detection must run on the canonicalized target: {stderr}" + ); + // The refusal names the REAL location, not the link — the actionable + // path for a user wondering where the managed copy lives. + assert!( + stderr.contains("node_modules"), + "refusal must name the resolved install path: {stderr}" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); + assert!( + std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), + "the invocation symlink must be left alone" + ); +} + +/// Positive control: the identical run against a plain `bin/` shape +/// proceeds — proving the refusals above come from the crafted shapes, +/// not something else in the fixture environment. (The full swap +/// semantics live in self_update_e2e.) +#[tokio::test] +async fn standalone_bin_dir_proceeds() { + let real_hash = update_fixture::real_binary_hash(); + let install = staged_install(); + let (served, _) = make_served_binary(); + let served_hash = hex::encode(Sha256::digest(&served)); + + let release = FakeReleaseBuilder::new(CURRENT) + .asset_for_current_target(&served) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--force", "--yes"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!( + code, 0, + "standalone install must update.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_eq!(sha256_file(&install.bin), served_hash); + + install.assert_only_binary_present(); + release.verify_request_hygiene().await; + update_fixture::StagedInstall::assert_build_artifact_untouched(&real_hash); +} diff --git a/crates/socket-patch-cli/tests/self_update_e2e.rs b/crates/socket-patch-cli/tests/self_update_e2e.rs new file mode 100644 index 0000000..98506aa --- /dev/null +++ b/crates/socket-patch-cli/tests/self_update_e2e.rs @@ -0,0 +1,299 @@ +//! Happy-path e2e for `socket-patch --update`: the self-replacement crux. +//! +//! Every test runs a COPY of the built binary staged into a tempdir +//! (`update_fixture::staged_install`) — `CARGO_BIN_EXE_socket-patch` +//! itself must never be a swap target, and each test re-verifies that at +//! the end. + +#[path = "common/mod.rs"] +mod common; +#[path = "common/update_fixture.rs"] +mod update_fixture; + +use sha2::{Digest, Sha256}; +use update_fixture::{ + make_served_binary, run_installed, sha256_file, staged_install, FakeReleaseBuilder, +}; + +const CURRENT: &str = env!("CARGO_PKG_VERSION"); + +/// THE crux: a full download→verify→stage→sanity→swap pass where the +/// running binary replaces itself, proven by byte-diff where the platform +/// allows a trailered binary (Linux/Windows) and by rename evidence +/// everywhere (inode change on Unix), without ever touching the real +/// build artifact. +#[tokio::test] +async fn update_force_swaps_binary_end_to_end() { + let real_hash = update_fixture::real_binary_hash(); + let install = staged_install(); + let (served, byte_distinct) = make_served_binary(); + let served_hash = hex::encode(Sha256::digest(&served)); + + // Advertise the binary's own version: the staged download genuinely + // reports it, so the strict version self-check semantics hold, and + // `--force` supplies the "reinstall even though up to date" intent. + let release = FakeReleaseBuilder::new(CURRENT) + .asset_for_current_target(&served) + .expect_resolves(1) + .expect_sums_fetches(1) + .expect_asset_downloads(1) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--force", "--yes"], + &[ + ("SOCKET_UPDATE_BASE_URL", &release.base_url), + // Canary: hygiene check below asserts this never reaches the + // release host. + ("SOCKET_API_TOKEN", "secret-canary"), + ], + ); + assert_eq!(code, 0, "update must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("Updated socket-patch"), + "human output must report the update: {stdout}" + ); + + // The installed file now IS the served payload… + assert_eq!( + sha256_file(&install.bin), + served_hash, + "installed binary must be exactly the served payload" + ); + // …and where the platform permits a byte-distinct payload, that is a + // real content change. + if byte_distinct { + assert_ne!(sha256_file(&install.bin), install.pre_hash); + } + // Rename evidence: a staged-sibling rename always allocates a new + // inode; the in-place-overwrite bug class this exists to catch keeps + // the old one. (macOS serves pristine bytes, so this is its only + // swap proof.) + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_ne!( + std::fs::metadata(&install.bin).unwrap().ino(), + install.pre_ino, + "swap must be a rename, not an in-place overwrite" + ); + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&install.bin).unwrap().permissions().mode() & 0o777, + 0o755, + "destination mode must be preserved" + ); + } + + // The new binary runs. + let out = std::process::Command::new(&install.bin) + .arg("--version") + .output() + .expect("spawn updated binary"); + assert!(out.status.success(), "updated binary must execute"); + + install.assert_only_binary_present(); + install.assert_workdir_untouched(); + + // The explicit update refreshed the notifier cache: no stale nag. + let state: serde_json::Value = serde_json::from_slice( + &std::fs::read(install.state_dir.join("update-check.json")) + .expect("update must write its state file"), + ) + .unwrap(); + assert_eq!(state["latestSeen"], CURRENT); + + release.verify_request_hygiene().await; + update_fixture::StagedInstall::assert_build_artifact_untouched(&real_hash); +} + +/// The genuine upgrade decision path: a strictly newer advertised version, +/// no --force. The served binary reports the real crate version (≠ 9.9.9), +/// which the sanity check tolerates as a warning because the base URL is +/// overridden — the strict-mode abort is pinned at the core unit level. +#[tokio::test] +async fn update_upgrade_branch_swaps() { + let real_hash = update_fixture::real_binary_hash(); + let install = staged_install(); + let (served, _) = make_served_binary(); + let served_hash = hex::encode(Sha256::digest(&served)); + + let release = FakeReleaseBuilder::new("9.9.9") + .asset_for_current_target(&served) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--yes"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!(stdout.contains("Updated socket-patch"), "{stdout}"); + assert!( + stderr.contains("Warning") && stderr.contains("9.9.9"), + "the relaxed version self-check must surface as a warning: {stderr}" + ); + assert_eq!(sha256_file(&install.bin), served_hash); + install.assert_only_binary_present(); + release.verify_request_hygiene().await; + update_fixture::StagedInstall::assert_build_artifact_untouched(&real_hash); +} + +/// `--dry-run` is check-only: one resolve, zero downloads, zero mutation, +/// exit 0 — the cheap scriptable "is an update available" probe. +#[tokio::test] +async fn update_dry_run_checks_without_downloading() { + let real_hash = update_fixture::real_binary_hash(); + let install = staged_install(); + let (served, _) = make_served_binary(); + + let release = FakeReleaseBuilder::new("9.9.9") + .asset_for_current_target(&served) + .expect_resolves(1) + .expect_sums_fetches(0) + .expect_asset_downloads(0) + .mount() + .await; + + let (code, stdout, _) = run_installed( + &install, + &["--update", "--dry-run", "--json"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 0); + let env = common::parse_json_envelope(&stdout); + assert_eq!(common::json_string(&env, "command").as_deref(), Some("update")); + assert_eq!(env["dryRun"], true); + let details = &env["events"][0]["details"]; + assert_eq!(details["updateAvailable"], true); + assert_eq!(details["current"], CURRENT); + assert_eq!(details["latest"], "9.9.9"); + assert_eq!( + details["asset"], + update_fixture::asset_name_for_current_target().as_str() + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); + release.verify_request_hygiene().await; + update_fixture::StagedInstall::assert_build_artifact_untouched(&real_hash); +} + +/// `--dry-run` on an ALREADY-CURRENT install still reports through the +/// verified/update_check probe shape (`updateAvailable: false`) — review +/// regression: it used to fall into the skipped/already_latest path, +/// breaking scripts that branch on the documented probe fields. +#[tokio::test] +async fn update_dry_run_up_to_date_still_reports_probe_shape() { + let install = staged_install(); + let (served, _) = make_served_binary(); + + let release = FakeReleaseBuilder::new(CURRENT) + .asset_for_current_target(&served) + .expect_sums_fetches(0) + .expect_asset_downloads(0) + .mount() + .await; + + let (code, stdout, _) = run_installed( + &install, + &["--update", "--dry-run", "--json"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 0); + let env = common::parse_json_envelope(&stdout); + assert_eq!(env["dryRun"], true); + assert_eq!(env["events"][0]["action"], "verified"); + let details = &env["events"][0]["details"]; + assert_eq!(details["updateAvailable"], false); + assert_eq!(details["current"], CURRENT); + assert_eq!(details["latest"], CURRENT); + install.assert_binary_intact(); +} + +/// Already on the latest release: informational no-op, exit 0, and the +/// sums/asset routes are never touched. +#[tokio::test] +async fn update_already_latest_is_a_noop() { + let install = staged_install(); + let (served, _) = make_served_binary(); + + let release = FakeReleaseBuilder::new(CURRENT) + .asset_for_current_target(&served) + .expect_sums_fetches(0) + .expect_asset_downloads(0) + .mount() + .await; + + let (code, stdout, _) = run_installed( + &install, + &["--update"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 0); + assert!( + stdout.contains("already the latest"), + "must say it is a no-op: {stdout}" + ); + install.assert_binary_intact(); +} + +/// `--json` success envelope: stable command tag, downloaded + updated +/// events, clean summary. +#[tokio::test] +async fn update_json_success_envelope_shape() { + let install = staged_install(); + let (served, _) = make_served_binary(); + + let release = FakeReleaseBuilder::new(CURRENT) + .asset_for_current_target(&served) + .mount() + .await; + + let (code, stdout, _) = run_installed( + &install, + &["--update", "--force", "--json"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 0, "{stdout}"); + let env = common::parse_json_envelope(&stdout); + assert_eq!(common::json_string(&env, "command").as_deref(), Some("update")); + assert_eq!(common::json_string(&env, "status").as_deref(), Some("success")); + let actions: Vec<&str> = env["events"] + .as_array() + .unwrap() + .iter() + .filter_map(|e| e["action"].as_str()) + .collect(); + assert_eq!(actions, vec!["downloaded", "updated"]); + assert_eq!(env["summary"]["downloaded"], 1); + assert_eq!(env["summary"]["updated"], 1); + assert_eq!( + env["events"][1]["details"]["to"], CURRENT, + "updated event names the installed version" + ); +} + +/// OPTIONAL live smoke against real GitHub (no base-URL override): +/// resolves the latest release and reports, download-free (`--dry-run`), +/// binary untouched. Catches asset-naming/redirect/SUMS drift against the +/// real distribution pipeline. `#[ignore]` — run explicitly: +/// `cargo test -p socket-patch-cli --test self_update_e2e -- --ignored`. +#[tokio::test] +#[ignore = "requires network access to github.com"] +async fn real_github_dry_run_smoke() { + let real_hash = update_fixture::real_binary_hash(); + let install = staged_install(); + // No SOCKET_UPDATE_BASE_URL: default endpoints (the one deliberate + // exception to hermeticity, which is why this is #[ignore]). + let (code, stdout, stderr) = run_installed(&install, &["--update", "--dry-run"], &[]); + assert_eq!( + code, 0, + "live dry-run must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + install.assert_binary_intact(); + update_fixture::StagedInstall::assert_build_artifact_untouched(&real_hash); +} diff --git a/crates/socket-patch-cli/tests/self_update_failures_e2e.rs b/crates/socket-patch-cli/tests/self_update_failures_e2e.rs new file mode 100644 index 0000000..e2d949f --- /dev/null +++ b/crates/socket-patch-cli/tests/self_update_failures_e2e.rs @@ -0,0 +1,573 @@ +//! Failure-path matrix for `socket-patch --update`: every way an update +//! run can refuse or abort, one test per failure class. +//! +//! The invariant every row shares: a failed (or refused) update leaves +//! the installed binary byte-identical and its directory free of stage +//! droppings — all mutation is supposed to happen on a staged sibling +//! until the single atomic rename, so any test here that finds a changed +//! hash or a stray file has caught a real torn-update bug. +//! +//! Like `self_update_e2e.rs`, every test runs a COPY of the built binary +//! (`update_fixture::staged_install`) — `CARGO_BIN_EXE_socket-patch` +//! itself must never be a swap target. + +#[path = "common/mod.rs"] +mod common; +#[path = "common/update_fixture.rs"] +mod update_fixture; + +use std::time::{Duration, Instant}; + +use sha2::{Digest, Sha256}; +use socket_patch_cli::commands::update::UPDATE_TARGET; +use update_fixture::{ + asset_name_for_current_target, make_served_binary, run_installed, sha256_file, staged_install, + FakeReleaseBuilder, +}; + +const CURRENT: &str = env!("CARGO_PKG_VERSION"); + +/// A SHA256SUMS entry that disagrees with the served bytes must abort +/// before extraction ever runs — a tampered CDN response may be hostile, +/// so nothing from the archive may touch disk (no stage droppings). +#[tokio::test] +async fn checksum_mismatch_aborts_pre_extraction() { + let install = staged_install(); + let (served, _) = make_served_binary(); + let asset = asset_name_for_current_target(); + + let release = FakeReleaseBuilder::new("9.9.9") + .asset_for_current_target(&served) + .corrupt_sums_entry_for(&asset) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--force", "--yes"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stderr.contains("checksum"), + "human error must name the checksum failure: {stderr}" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); + release.verify_request_hygiene().await; +} + +/// SHA256SUMS is fetched BEFORE the asset, so an asset the release cannot +/// vouch for is refused without wasting (or trusting) the download — the +/// zero-download expectation is what pins the ordering. +#[tokio::test] +async fn sums_missing_entry_refuses_before_download() { + let install = staged_install(); + let (served, _) = make_served_binary(); + let asset = asset_name_for_current_target(); + + let release = FakeReleaseBuilder::new("9.9.9") + .asset_for_current_target(&served) + .omit_sums_entry_for(&asset) + .expect_asset_downloads(0) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--yes"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stderr.contains("SHA256SUMS") && stderr.contains("no entry"), + "error must say the sums file has no entry for the asset: {stderr}" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); + release.verify_request_hygiene().await; +} + +/// A release with no SHA256SUMS at all cannot be verified, so nothing may +/// be downloaded — and the error must name the missing file so a release +/// engineer knows what broke (not a generic "download failed"). +#[tokio::test] +async fn sums_file_missing_is_actionable() { + let install = staged_install(); + let (served, _) = make_served_binary(); + + let release = FakeReleaseBuilder::new("9.9.9") + .asset_for_current_target(&served) + .omit_sums_file() + .expect_asset_downloads(0) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--yes"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stderr.contains("SHA256SUMS"), + "error must name the missing SHA256SUMS: {stderr}" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); + release.verify_request_hygiene().await; +} + +/// An advertised release whose platform asset 404s must say WHICH target +/// has no prebuilt binary — that message is the only clue a user on an +/// exotic platform gets about why they must build from source. +#[tokio::test] +async fn asset_404_names_the_target() { + let install = staged_install(); + let (served, _) = make_served_binary(); + let asset = asset_name_for_current_target(); + + // The asset stays listed in SHA256SUMS but its download route 404s: + // the release page lied, or CI half-published. + let release = FakeReleaseBuilder::new("9.9.9") + .asset_for_current_target(&served) + .omit_asset(&asset) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--json"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + let env = common::parse_json_envelope(&stdout); + assert_eq!(common::envelope_error_code(&env), Some("asset_not_found")); + let message = common::envelope_error_message(&env).unwrap_or_default(); + assert!( + message.contains(UPDATE_TARGET), + "asset_not_found must name the compiled target triple: {message}" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); + release.verify_request_hygiene().await; +} + +/// SOCKET_UPDATE_TIMEOUT_MS must actually bound the run: a hung release +/// host may not turn `--update` into an indefinite stall (a hung +/// self-update is strictly worse than a hung scan). +#[tokio::test] +async fn network_timeout_is_bounded() { + let install = staged_install(); + let (served, _) = make_served_binary(); + + let release = FakeReleaseBuilder::new("9.9.9") + .asset_for_current_target(&served) + .delay_metadata(Duration::from_secs(10)) + .mount() + .await; + + let start = Instant::now(); + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--yes"], + &[ + ("SOCKET_UPDATE_BASE_URL", &release.base_url), + ("SOCKET_UPDATE_TIMEOUT_MS", "500"), + ], + ); + let elapsed = start.elapsed(); + + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!(stderr.contains("Error:"), "{stderr}"); + // 8s is generous slack for debug-binary startup; the 10s server delay + // (×2 routes: probe + API fallback) guarantees an unbounded client + // would blow well past it. + assert!( + elapsed < Duration::from_secs(8), + "timeout must bound the run, took {elapsed:?}" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); + release.verify_request_hygiene().await; +} + +/// A connection cut mid-download yields fewer bytes than SHA256SUMS +/// vouched for — that must surface as a checksum failure, never as a +/// short-but-"successful" archive handed to the extractor. +#[tokio::test] +async fn truncated_download_is_a_checksum_mismatch() { + let install = staged_install(); + let (served, _) = make_served_binary(); + let asset = asset_name_for_current_target(); + let archive_len = update_fixture::archive_for_current_target(&served).len(); + + let release = FakeReleaseBuilder::new("9.9.9") + .asset_for_current_target(&served) + .truncate_asset(&asset, archive_len / 2) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--yes"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stderr.contains("checksum"), + "truncation must report as a checksum failure: {stderr}" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); + release.verify_request_hygiene().await; +} + +/// `latest` never downgrades: when the newest release is OLDER than the +/// installed binary (a dev build, or a yanked release rolled back), a bare +/// `--update` is an informational no-op that touches neither the sums nor +/// the asset routes. +#[tokio::test] +async fn downgrade_refused_without_force() { + let install = staged_install(); + let (served, _) = make_served_binary(); + + let release = FakeReleaseBuilder::new("0.0.1") + .asset_for_current_target(&served) + .expect_sums_fetches(0) + .expect_asset_downloads(0) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("already the latest"), + "an older latest must read as a no-op, not an error: {stdout}" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); + release.verify_request_hygiene().await; +} + +/// An explicit version pin is user intent: it installs that version up OR +/// down with no --force, and never hits the latest-resolution endpoint +/// (the pin alone decides the download URL). +#[tokio::test] +async fn explicit_pin_downgrades_without_force() { + let real_hash = update_fixture::real_binary_hash(); + let install = staged_install(); + let (served, _) = make_served_binary(); + let served_hash = hex::encode(Sha256::digest(&served)); + + let release = FakeReleaseBuilder::new("0.0.1") + .asset_for_current_target(&served) + .expect_resolves(0) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "0.0.1", "--yes"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!(stdout.contains("Updated socket-patch"), "{stdout}"); + assert_eq!( + sha256_file(&install.bin), + served_hash, + "pin must install the served payload" + ); + // The served binary genuinely reports the crate version, not 0.0.1 — + // under a base-URL override that mismatch is a warning, not an abort. + assert!( + stderr.contains("Warning") && stderr.contains("0.0.1"), + "relaxed version self-check must warn about the mismatch: {stderr}" + ); + + install.assert_only_binary_present(); + release.verify_request_hygiene().await; + update_fixture::StagedInstall::assert_build_artifact_untouched(&real_hash); +} + +/// A release host that redirects to a garbage tag (and whose API fallback +/// serves an equally garbage tag_name) must produce a clean check_failed — +/// a panic here would look like a crashed updater to every user the moment +/// GitHub changes a URL shape. +#[tokio::test] +async fn garbage_tag_is_error_not_panic() { + use wiremock::matchers::{method, path as urlpath}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let install = staged_install(); + + // Raw wiremock: the fixture always redirects to a well-formed tag, so + // this hostile shape is mounted by hand. + let server = MockServer::start().await; + let base = server.uri(); + Mock::given(method("GET")) + .and(urlpath("/SocketDev/socket-patch/releases/latest")) + .respond_with(ResponseTemplate::new(302).insert_header( + "Location", + format!("{base}/SocketDev/socket-patch/releases/tag/not-a-version").as_str(), + )) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(urlpath("/repos/SocketDev/socket-patch/releases/latest")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "tag_name": "also-garbage", + "assets": [], + }))) + .mount(&server) + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--json"], + &[("SOCKET_UPDATE_BASE_URL", &base)], + ); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + let env = common::parse_json_envelope(&stdout); + assert_eq!(common::envelope_error_code(&env), Some("check_failed")); + assert!( + !stderr.contains("panicked"), + "garbage tags must never panic the updater: {stderr}" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); +} + +/// Correctly-checksummed garbage (the sums vouch for bytes that simply +/// aren't a program) must die at the sanity exec, with the stage cleaned +/// up and the installed binary untouched — the last line of defense when +/// a release publishes a broken artifact with matching sums. +#[tokio::test] +async fn sanity_exec_failure_leaves_binary_untouched() { + let install = staged_install(); + + let release = FakeReleaseBuilder::new("9.9.9") + .asset_for_current_target(&[0u8; 4096]) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--force", "--yes", "--json"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + let env = common::parse_json_envelope(&stdout); + assert_eq!(common::envelope_error_code(&env), Some("verify_failed")); + + install.assert_binary_intact(); + // No `.old` parked exe, no `.socket-patch.stage-*` leftovers: the + // failed sanity exec must consume its own stage file. + install.assert_only_binary_present(); + release.verify_request_hygiene().await; +} + +/// An install dir the user cannot write to (the classic +/// `/usr/local/bin` without sudo) must fail as permission_denied with the +/// sudo hint — not as a generic swap failure, and not after half-staging. +#[cfg(unix)] +#[tokio::test] +async fn readonly_install_dir_fails_cleanly() { + use std::os::unix::fs::PermissionsExt; + + let install = staged_install(); + let (served, _) = make_served_binary(); + let release = FakeReleaseBuilder::new("9.9.9") + .asset_for_current_target(&served) + .mount() + .await; + + let bin_dir = install.bin.parent().unwrap().to_path_buf(); + std::fs::set_permissions(&bin_dir, std::fs::Permissions::from_mode(0o555)) + .expect("chmod bin dir read-only"); + // Root ignores mode bits (CI containers sometimes run as root): probe + // and skip rather than assert a denial that cannot happen. + if std::fs::File::create(bin_dir.join("probe")).is_ok() { + let _ = std::fs::remove_file(bin_dir.join("probe")); + let _ = std::fs::set_permissions(&bin_dir, std::fs::Permissions::from_mode(0o755)); + eprintln!("skipping: running as root, 0555 does not block writes"); + return; + } + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--force", "--yes", "--json"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + + // Restore writability BEFORE any assertion can panic, so TempDir + // cleanup never wedges on the read-only directory. + std::fs::set_permissions(&bin_dir, std::fs::Permissions::from_mode(0o755)) + .expect("restore bin dir mode"); + + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + let env = common::parse_json_envelope(&stdout); + assert_eq!(common::envelope_error_code(&env), Some("permission_denied")); + let message = common::envelope_error_message(&env).unwrap_or_default(); + assert!( + message.contains("sudo"), + "permission_denied must carry the sudo hint: {message}" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); + release.verify_request_hygiene().await; +} + +/// Strict airgap: SOCKET_OFFLINE refuses before ANY client exists, and +/// --force does not bypass it. Zero requests is the contract — one +/// metadata probe from an "offline" run is already a violation. +#[tokio::test] +async fn offline_refuses_up_front_and_beats_force() { + let install = staged_install(); + let (served, _) = make_served_binary(); + + let release = FakeReleaseBuilder::new("9.9.9") + .asset_for_current_target(&served) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--force", "--yes", "--json"], + &[ + ("SOCKET_UPDATE_BASE_URL", &release.base_url), + ("SOCKET_OFFLINE", "1"), + ], + ); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + let env = common::parse_json_envelope(&stdout); + assert_eq!(common::envelope_error_code(&env), Some("offline")); + assert_eq!( + release.received_request_count().await, + 0, + "offline must mean ZERO requests to the release host" + ); + + install.assert_binary_intact(); + install.assert_only_binary_present(); +} + +/// Two concurrent updates would race the same rename; the flock on +/// `/update.lock` makes them single-flight. The second half of +/// the test pins that the lock is advisory-per-holder, not sticky: once +/// released, the next run must succeed and actually swap. +#[tokio::test] +async fn concurrent_update_lock_held() { + use fs2::FileExt; + + let real_hash = update_fixture::real_binary_hash(); + let install = staged_install(); + let (served, _) = make_served_binary(); + let served_hash = hex::encode(Sha256::digest(&served)); + + let release = FakeReleaseBuilder::new(CURRENT) + .asset_for_current_target(&served) + .mount() + .await; + + // Externally hold the exact flock the updater takes (same idiom as + // e2e_safety_lock.rs) — keep the handle bound or the lock vanishes. + let lock_path = install.state_dir.join("update.lock"); + let lock_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .expect("open update.lock"); + lock_file + .try_lock_exclusive() + .expect("test could not take initial lock"); + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--force", "--yes", "--json"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + let env = common::parse_json_envelope(&stdout); + assert_eq!(common::envelope_error_code(&env), Some("update_in_progress")); + install.assert_binary_intact(); + + // Release the lock: the very next run must go all the way through. + drop(lock_file); + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--force", "--yes"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!( + code, 0, + "retry after lock release must succeed.\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert_eq!( + sha256_file(&install.bin), + served_hash, + "retry must install the served payload" + ); + // Rename evidence: a real swap allocates a new inode (macOS serves + // pristine bytes, so this is its only swap proof). + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_ne!( + std::fs::metadata(&install.bin).unwrap().ino(), + install.pre_ino, + "retry swap must be a rename, not an in-place overwrite" + ); + } + + install.assert_only_binary_present(); + release.verify_request_hygiene().await; + update_fixture::StagedInstall::assert_build_artifact_untouched(&real_hash); +} + +/// `--json` failure envelope: a single parseable object on stdout with the +/// stable command tag, error status, and machine-routable error code — +/// what CI wrappers key on to distinguish "verification failed" from +/// "network flake". +#[tokio::test] +async fn json_failure_envelope_shape() { + let install = staged_install(); + let (served, _) = make_served_binary(); + let asset = asset_name_for_current_target(); + + let release = FakeReleaseBuilder::new("9.9.9") + .asset_for_current_target(&served) + .corrupt_sums_entry_for(&asset) + .mount() + .await; + + let (code, stdout, stderr) = run_installed( + &install, + &["--update", "--json"], + &[("SOCKET_UPDATE_BASE_URL", &release.base_url)], + ); + assert_eq!(code, 1, "stdout:\n{stdout}\nstderr:\n{stderr}"); + let env = common::parse_json_envelope(&stdout); + assert_eq!(common::json_string(&env, "command"), Some("update")); + assert_eq!(common::json_string(&env, "status"), Some("error")); + assert_eq!(common::envelope_error_code(&env), Some("checksum_mismatch")); + + install.assert_binary_intact(); + install.assert_only_binary_present(); + release.verify_request_hygiene().await; +} diff --git a/crates/socket-patch-cli/tests/setup_matrix_common/mod.rs b/crates/socket-patch-cli/tests/setup_matrix_common/mod.rs index 2d57c36..61d44ce 100644 --- a/crates/socket-patch-cli/tests/setup_matrix_common/mod.rs +++ b/crates/socket-patch-cli/tests/setup_matrix_common/mod.rs @@ -512,16 +512,39 @@ fn run_cases(label: &str, cases: Vec) { } } + // In docker mode the binary under test is the one BAKED INTO the local + // socket-patch-test-* image, not the workspace build — a weeks-old + // image fails current expectations deterministically and looks like a + // mystery regression (both report the same crate version, so only the + // image age gives it away). Say so in the failure message instead of + // letting the next person rediscover it. + let mode_hint = if host_mode() { + String::new() + } else { + let image = cases + .first() + .map(|c| c.image.as_str()) + .unwrap_or(""); + format!( + "\nNOTE: docker mode ran the socket-patch binary baked into the \ + local image (check its age: `docker images socket-patch-test-*`). \ + A stale image fails current expectations without any real \ + regression — rebuild first:\n \ + docker build -f tests/docker/Dockerfile.base -t socket-patch-test-base:latest .\n \ + docker build -f tests/docker/Dockerfile.{image} -t socket-patch-test-{image}:latest ." + ) + }; assert!( failures.is_empty(), "{}: {} of {} setup-matrix case(s) did not meet the aspirational \ expectation. BASELINE GAP entries are the experimental TODO list \ (this suite is non-blocking in CI); REGRESSION / LEAK entries are \ - real problems:\n{}", + real problems:\n{}{}", label, failures.len(), cases.len(), - failures.join("\n") + failures.join("\n"), + mode_hint ); } diff --git a/crates/socket-patch-cli/tests/update_notifier_e2e.rs b/crates/socket-patch-cli/tests/update_notifier_e2e.rs new file mode 100644 index 0000000..18eea6c --- /dev/null +++ b/crates/socket-patch-cli/tests/update_notifier_e2e.rs @@ -0,0 +1,654 @@ +//! e2e for the passive update notifier: guard precedence observed through +//! a real spawned binary, the once-a-day cadence driven purely through the +//! on-disk state file (no clock mocking — "a day passed" is a +//! `lastCheckAt` written 25h in the past), and the two invariants that +//! only an e2e can prove: a silenced run performs ZERO network I/O, and +//! the notifier can never fail, slow, or pollute the carrier command. +//! +//! Carrier command: `apply` in an empty workdir — it flows through normal +//! dispatch (so the notifier hook runs), prints its friendly no-manifest +//! skip, exits 0, and touches nothing. `list`/`rollback`/`repair` exit 1 +//! without a manifest, and `--version`/`--help` never dispatch (clap +//! handles them before the hook), so none of those can carry the notifier. + +#[path = "common/mod.rs"] +mod common; +#[path = "common/update_fixture.rs"] +mod update_fixture; + +use std::path::Path; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use update_fixture::{run_installed, staged_install, FakeReleaseBuilder, StagedInstall}; + +const CURRENT: &str = env!("CARGO_PKG_VERSION"); +const HOUR: i64 = 60 * 60; +/// 25h vs the 24h TTL: slack against wall-clock drift between the write +/// and the child's own `unix_now()`. +const STALE: i64 = 25 * HOUR; +const FRESH: i64 = HOUR; + +fn now_secs() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("post-1970 clock") + .as_secs() as i64 +} + +/// Write `update-check.json` the way the binary would have after a check +/// `last_check_ago_secs` in the past (negative = a future timestamp, for +/// the clock-skew row). +fn write_state( + state_dir: &Path, + last_check_ago_secs: i64, + latest_seen: Option<&str>, + last_notified_ago_secs: Option, +) { + let now = now_secs(); + let mut obj = serde_json::json!({ + "schemaVersion": 1, + "lastCheckAt": now - last_check_ago_secs, + }); + if let Some(v) = latest_seen { + obj["latestSeen"] = serde_json::Value::from(v); + } + if let Some(ago) = last_notified_ago_secs { + obj["lastNotifiedAt"] = serde_json::Value::from(now - ago); + } + std::fs::write( + state_dir.join("update-check.json"), + serde_json::to_vec_pretty(&obj).unwrap(), + ) + .expect("write update-check.json"); +} + +fn read_state(state_dir: &Path) -> serde_json::Value { + let raw = std::fs::read(state_dir.join("update-check.json")) + .expect("update-check.json must exist"); + serde_json::from_slice(&raw).unwrap_or_else(|e| { + panic!( + "update-check.json must be valid JSON: {e}\nraw:\n{}", + String::from_utf8_lossy(&raw) + ) + }) +} + +/// Env for a run that SHOULD check: opt-out off, the test-only force knob +/// bypassing the stderr-TTY guard (children write to pipes), CI vars +/// neutralized (the test runner itself may be in CI), release endpoint +/// pointed at the fake. `run_installed` already injects the state dir. +fn eligible_kit(base_url: &str) -> Vec<(&str, &str)> { + vec![ + ("SOCKET_NO_UPDATE_CHECK", "0"), + ("SOCKET_UPDATE_NOTIFIER_FORCE", "1"), + ("CI", ""), + ("GITHUB_ACTIONS", ""), + ("SOCKET_UPDATE_BASE_URL", base_url), + ] +} + +/// The notifier must never mutate the install or the project dir, on any +/// path — every row re-proves it. +fn assert_install_pristine(install: &StagedInstall) { + install.assert_binary_intact(); + install.assert_only_binary_present(); + install.assert_workdir_untouched(); +} + +// ── The notice lifecycle ─────────────────────────────────────────────── + +/// Virgin install, newer release available: the first eligible run checks, +/// notices on stderr (never stdout — stdout belongs to the command), and +/// seeds the state file. +#[tokio::test] +async fn first_eligible_run_checks_and_notices() { + let install = staged_install(); + // No assets: the notifier only resolves the latest version. + let release = FakeReleaseBuilder::new("9.9.9") + .expect_resolves(1) + .mount() + .await; + + let (code, stdout, stderr) = + run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stderr.contains("Update available") && stderr.contains("9.9.9"), + "first eligible run must print the notice on stderr: {stderr}" + ); + assert!( + !stdout.contains("Update available"), + "the notice must never contaminate stdout: {stdout}" + ); + + let state = read_state(&install.state_dir); + assert_eq!(state["latestSeen"], "9.9.9", "check must persist what it saw"); + assert!( + state["lastCheckAt"].as_i64().is_some(), + "check must record when it ran: {state}" + ); + assert!( + state["lastNotifiedAt"].as_i64().is_some(), + "printing the notice must start the daily rate limit: {state}" + ); + + release.verify_request_hygiene().await; + assert_install_pristine(&install); +} + +/// A check ran an hour ago and saw a newer version: the notice comes from +/// the CACHE with zero network — the fetch cadence and the nag cadence are +/// independent. +#[tokio::test] +async fn fresh_state_notices_from_cache_with_zero_network() { + let install = staged_install(); + let release = FakeReleaseBuilder::new("9.9.9").mount().await; + write_state(&install.state_dir, FRESH, Some("9.9.9"), None); + + let (code, _, stderr) = + run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); + assert_eq!(code, 0); + assert!( + stderr.contains("Update available") && stderr.contains("9.9.9"), + "cached knowledge must still produce the notice: {stderr}" + ); + assert_eq!( + release.received_request_count().await, + 0, + "a fresh state file must suppress the fetch entirely" + ); + assert_install_pristine(&install); +} + +/// 25h-old state: the cadence has lapsed, so the run re-fetches and +/// rewrites the state with what it found. +#[tokio::test] +async fn stale_state_rechecks() { + let install = staged_install(); + let release = FakeReleaseBuilder::new("9.9.9") + .expect_resolves(1) + .mount() + .await; + write_state(&install.state_dir, STALE, Some(CURRENT), None); + + let (code, stdout, stderr) = + run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + + let state = read_state(&install.state_dir); + assert_eq!( + state["latestSeen"], "9.9.9", + "the re-check must overwrite the stale latestSeen: {state}" + ); + let last = state["lastCheckAt"].as_i64().expect("lastCheckAt set"); + assert!( + (now_secs() - last).abs() <= 60, + "lastCheckAt must advance to the new check time, got {last}" + ); + release.verify_request_hygiene().await; + assert_install_pristine(&install); +} + +/// Already on the latest release: the check still runs (cadence lapsed) +/// but no notice appears — the notifier only speaks when there is news. +#[tokio::test] +async fn up_to_date_prints_nothing() { + let install = staged_install(); + let release = FakeReleaseBuilder::new(CURRENT) + .expect_resolves(1) + .mount() + .await; + write_state(&install.state_dir, STALE, Some(CURRENT), None); + + let (code, _, stderr) = + run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); + assert_eq!(code, 0); + assert!( + !stderr.contains("Update available"), + "no notice when current == latest: {stderr}" + ); + assert_install_pristine(&install); +} + +/// The nag itself is rate-limited: an update is KNOWN (cached) but the +/// notice was already shown an hour ago, so this run stays quiet. +#[tokio::test] +async fn notice_rate_limited_to_daily() { + let install = staged_install(); + let release = FakeReleaseBuilder::new("9.9.9").mount().await; + write_state(&install.state_dir, FRESH, Some("9.9.9"), Some(FRESH)); + + let (code, _, stderr) = + run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); + assert_eq!(code, 0); + assert!( + !stderr.contains("Update available"), + "a notice shown within the last day must not repeat: {stderr}" + ); + assert_eq!(release.received_request_count().await, 0); + assert_install_pristine(&install); +} + +/// …and once a day has passed since the last notice, the nag returns +/// (still from cache — the check cadence is untouched). +#[tokio::test] +async fn notice_returns_after_a_day() { + let install = staged_install(); + let release = FakeReleaseBuilder::new("9.9.9").mount().await; + write_state(&install.state_dir, FRESH, Some("9.9.9"), Some(STALE)); + + let (code, _, stderr) = + run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); + assert_eq!(code, 0); + assert!( + stderr.contains("Update available"), + "a day after the last notice the nag must return: {stderr}" + ); + assert_eq!(release.received_request_count().await, 0); + assert_install_pristine(&install); +} + +// ── State-file resilience ────────────────────────────────────────────── + +/// Corrupt cache bytes must read as "never checked" — never crash, and the +/// next successful check heals the file back into valid JSON. +#[tokio::test] +async fn corrupt_state_recovers() { + let install = staged_install(); + let release = FakeReleaseBuilder::new("9.9.9") + .expect_resolves(1) + .mount() + .await; + std::fs::write( + install.state_dir.join("update-check.json"), + b"\x00garbage{{{", + ) + .unwrap(); + + let (code, stdout, stderr) = + run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + !stderr.contains("panicked"), + "corrupt state must never panic the CLI: {stderr}" + ); + // read_state panics on invalid JSON — this IS the heal assertion. + let state = read_state(&install.state_dir); + assert_eq!( + state["latestSeen"], "9.9.9", + "the recovery check must rewrite a valid state file: {state}" + ); + assert_install_pristine(&install); +} + +/// A `lastCheckAt` 48h in the FUTURE is clock skew, not a valid +/// suppression: it must count as due, so a wrong clock can never wedge the +/// notifier until the bogus timestamp passes. +#[tokio::test] +async fn future_timestamp_tolerated() { + let install = staged_install(); + let release = FakeReleaseBuilder::new("9.9.9") + .expect_resolves(1) + .mount() + .await; + write_state(&install.state_dir, -48 * HOUR, Some("9.9.9"), None); + + let (code, stdout, stderr) = + run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert_install_pristine(&install); +} + +/// State dir the child cannot write: the check runs, the persist fails, +/// and the carrier neither fails nor complains — cache trouble is never +/// the user's problem. +#[cfg(unix)] +#[tokio::test] +async fn unwritable_state_dir_is_harmless() { + use std::os::unix::fs::PermissionsExt; + + let install = staged_install(); + std::fs::set_permissions(&install.state_dir, std::fs::Permissions::from_mode(0o555)) + .expect("chmod state dir read-only"); + // Root ignores mode bits; probe and skip rather than assert a + // restriction that isn't in force. + if std::fs::write(install.state_dir.join("probe"), b"x").is_ok() { + let _ = std::fs::remove_file(install.state_dir.join("probe")); + eprintln!("running as root: read-only dir not enforceable, skipping"); + return; + } + + let release = FakeReleaseBuilder::new("9.9.9").mount().await; + let (code, stdout, stderr) = + run_installed(&install, &["apply"], &eligible_kit(&release.base_url)); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + !stderr.contains("Error"), + "an unwritable cache dir must be silently absorbed: {stderr}" + ); + + std::fs::set_permissions(&install.state_dir, std::fs::Permissions::from_mode(0o755)) + .expect("restore state dir perms"); + assert_install_pristine(&install); +} + +// ── Guards: silence means ZERO network, not just zero output ────────── +// +// Every guard row writes STALE state first — a check is genuinely due, so +// zero requests proves the guard suppressed the fetch itself, not that the +// cadence happened to be fresh. + +/// Piped stderr (no force knob): the TTY guard silences the fetch too. A +/// regression that only muted the print would still leak network I/O into +/// every scripted invocation. +#[tokio::test] +async fn guard_non_tty_silences_fetch_too() { + let install = staged_install(); + let release = FakeReleaseBuilder::new("9.9.9").mount().await; + write_state(&install.state_dir, STALE, Some("9.9.9"), None); + + let mut kit = eligible_kit(&release.base_url); + kit.retain(|(k, _)| *k != "SOCKET_UPDATE_NOTIFIER_FORCE"); + let (code, _, stderr) = run_installed(&install, &["apply"], &kit); + assert_eq!(code, 0); + assert_eq!( + release.received_request_count().await, + 0, + "the TTY guard must suppress the FETCH, not just the print" + ); + assert!(!stderr.contains("Update available"), "{stderr}"); + assert_install_pristine(&install); +} + +/// CI always silences — the force knob bypasses ONLY the TTY guard, so a +/// forced test env inside CI still stays quiet and offline. +#[tokio::test] +async fn guard_ci_silences_even_forced() { + let install = staged_install(); + let release = FakeReleaseBuilder::new("9.9.9").mount().await; + write_state(&install.state_dir, STALE, Some("9.9.9"), None); + + let mut kit = eligible_kit(&release.base_url); + kit.push(("CI", "true")); // lands after the kit's CI="" and wins + let (code, _, stderr) = run_installed(&install, &["apply"], &kit); + assert_eq!(code, 0); + assert_eq!(release.received_request_count().await, 0); + assert!(!stderr.contains("Update available"), "{stderr}"); + assert_install_pristine(&install); +} + +/// Offline mode is a promise of zero network — the notifier is bound by it +/// like everything else. +#[tokio::test] +async fn guard_offline_silences() { + let install = staged_install(); + let release = FakeReleaseBuilder::new("9.9.9").mount().await; + write_state(&install.state_dir, STALE, Some("9.9.9"), None); + + let mut kit = eligible_kit(&release.base_url); + kit.push(("SOCKET_OFFLINE", "1")); + let (code, _, stderr) = run_installed(&install, &["apply"], &kit); + assert_eq!(code, 0); + assert_eq!(release.received_request_count().await, 0); + assert!(!stderr.contains("Update available"), "{stderr}"); + assert_install_pristine(&install); +} + +/// `--silent` asked for nothing but the essentials — the notifier is not +/// essential, and its background fetch isn't either. +#[tokio::test] +async fn guard_silent_flag_silences() { + let install = staged_install(); + let release = FakeReleaseBuilder::new("9.9.9").mount().await; + write_state(&install.state_dir, STALE, Some("9.9.9"), None); + + let (code, _, stderr) = run_installed( + &install, + &["apply", "--silent"], + &eligible_kit(&release.base_url), + ); + assert_eq!(code, 0); + assert_eq!(release.received_request_count().await, 0); + assert!( + !stderr.contains("Update available") && !stderr.contains("[socket-patch]"), + "--silent must leave stderr free of notices: {stderr}" + ); + assert_install_pristine(&install); +} + +/// `--json` output is consumed by machines: the envelope must stay pure +/// and the run must stay network-clean. +#[tokio::test] +async fn guard_json_flag_silences() { + let install = staged_install(); + let release = FakeReleaseBuilder::new("9.9.9").mount().await; + write_state(&install.state_dir, STALE, Some("9.9.9"), None); + + let (code, stdout, stderr) = run_installed( + &install, + &["apply", "--json"], + &eligible_kit(&release.base_url), + ); + assert_eq!(code, 0); + // parse_json_envelope panics on trailing/leading garbage — this IS the + // purity assertion for stdout. + let env = common::parse_json_envelope(&stdout); + assert_eq!(common::json_string(&env, "command").as_deref(), Some("apply")); + assert_eq!(release.received_request_count().await, 0); + assert!( + !stderr.contains("Update available"), + "no notice may ride alongside a JSON run: {stderr}" + ); + assert_install_pristine(&install); +} + +/// The kill switch wins over everything, including the force knob — the +/// documented "make it stop" works unconditionally. +#[tokio::test] +async fn guard_opt_out_beats_force() { + let install = staged_install(); + let release = FakeReleaseBuilder::new("9.9.9").mount().await; + write_state(&install.state_dir, STALE, Some("9.9.9"), None); + + let mut kit = eligible_kit(&release.base_url); + kit.push(("SOCKET_NO_UPDATE_CHECK", "1")); // overrides the kit's "0" + let (code, _, stderr) = run_installed(&install, &["apply"], &kit); + assert_eq!(code, 0); + assert_eq!(release.received_request_count().await, 0); + assert!(!stderr.contains("Update available"), "{stderr}"); + assert_install_pristine(&install); +} + +// ── The notifier can never hurt the carrier ──────────────────────────── + +/// Unreachable release host: the carrier is untouched, and the FAILED +/// check still advances `lastCheckAt` — a broken network is retried at +/// most once a day, not on every command. +#[tokio::test] +async fn dead_endpoint_never_fails_the_command() { + let install = staged_install(); + write_state(&install.state_dir, STALE, Some(CURRENT), None); + + let (code, stdout, stderr) = run_installed( + &install, + &["apply"], + &eligible_kit("http://127.0.0.1:1"), + ); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!(!stderr.contains("Update available"), "{stderr}"); + + let state = read_state(&install.state_dir); + let last = state["lastCheckAt"].as_i64().expect("lastCheckAt set"); + assert!( + (now_secs() - last).abs() <= 60, + "a failed check must still rate-limit itself to once a day; \ + lastCheckAt={last} now={}", + now_secs() + ); + assert_install_pristine(&install); +} + +/// A slow release host must cost the user at most the 500ms grace budget. +/// The fetch budget is deliberately raised to 30s so only the join grace +/// can explain a fast exit. +#[tokio::test] +async fn grace_budget_bounds_command_latency() { + let install = staged_install(); + let release = FakeReleaseBuilder::new("9.9.9") + .delay_metadata(Duration::from_secs(30)) + .mount() + .await; + write_state(&install.state_dir, STALE, Some(CURRENT), None); + + let mut kit = eligible_kit(&release.base_url); + kit.push(("SOCKET_UPDATE_TIMEOUT_MS", "30000")); + let start = Instant::now(); + let (code, stdout, stderr) = run_installed(&install, &["apply"], &kit); + let wall = start.elapsed(); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + // 10s = 500ms grace + generous debug-binary startup slack; the 30s + // response delay proves the join gave up rather than the fetch winning. + assert!( + wall < Duration::from_secs(10), + "the notifier must never hold a command hostage: took {wall:?}" + ); + assert!(!stderr.contains("Update available"), "{stderr}"); + assert_install_pristine(&install); +} + +/// `--update` IS the check — the hook is skipped structurally for it. The +/// stale cache screams "9.9.9 available", the env is fully eligible, yet +/// no notice may ride on the update command's own output. +#[tokio::test] +async fn update_command_never_notifies() { + let install = staged_install(); + let (served, _) = update_fixture::make_served_binary(); + let release = FakeReleaseBuilder::new(CURRENT) + .asset_for_current_target(&served) + .mount() + .await; + write_state(&install.state_dir, STALE, Some("9.9.9"), None); + + let (code, stdout, stderr) = + run_installed(&install, &["--update"], &eligible_kit(&release.base_url)); + assert_eq!(code, 0, "stdout:\n{stdout}\nstderr:\n{stderr}"); + assert!( + stdout.contains("already the latest"), + "no --force: this must be the already-latest no-op: {stdout}" + ); + assert!( + !stderr.contains("Update available"), + "--update must never carry the passive notice: {stderr}" + ); + release.verify_request_hygiene().await; + assert_install_pristine(&install); +} + +// ── The one genuine-TTY row ──────────────────────────────────────────── + +/// Every other row bypasses the TTY guard with the force knob; this is the +/// proof the REAL guard passes on a real terminal — a regression inverting +/// the isatty check (notices everywhere except terminals) would slip past +/// the whole piped suite. +#[cfg(unix)] +mod pty { + use super::*; + use portable_pty::{native_pty_system, CommandBuilder, PtySize}; + use std::io::Read; + + /// Minimal PTY runner (same shape as `interactive_prompts_e2e.rs`): + /// reader thread to EOF, detached SIGKILL watchdog, no input. This + /// bypasses `run_installed`, so the hermetic scrub and the update kit + /// are reproduced by hand. + fn run_in_pty( + bin: &Path, + cwd: &Path, + env: &[(&str, &str)], + timeout: Duration, + ) -> (i32, String) { + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("openpty"); + + let mut cmd = CommandBuilder::new(bin); + cmd.arg("apply"); + cmd.cwd(cwd); + // Prefix-scrub the ambient SOCKET_* surface (keep telemetry + // opt-outs and the no-config hermeticity default), then land the + // caller's kit — mirrors run_bin_with_env for a PTY child. + for (key, _) in std::env::vars_os() { + let name = key.to_string_lossy().into_owned(); + if name.starts_with("SOCKET_") + && !name.contains("TELEMETRY") + && name != "SOCKET_NO_CONFIG" + { + cmd.env_remove(name); + } + } + cmd.env("SOCKET_NO_CONFIG", "1"); + for (k, v) in env { + cmd.env(k, v); + } + + let mut child = pair.slave.spawn_command(cmd).expect("spawn in PTY"); + drop(pair.slave); + + let mut reader = pair.master.try_clone_reader().expect("clone reader"); + let reader_handle = std::thread::spawn(move || { + let mut buf = Vec::new(); + let _ = reader.read_to_end(&mut buf); + buf + }); + + let mut killer = child.clone_killer(); + std::thread::spawn(move || { + std::thread::sleep(timeout); + let _ = killer.kill(); + }); + + // No prompt to answer: close the writer immediately so the child's + // stdin sees EOF if anything ever reads it. + drop(pair.master.take_writer().expect("take writer")); + + let status = child.wait().expect("child.wait"); + drop(pair.master); + let output = reader_handle.join().expect("reader join"); + (status.exit_code() as i32, String::from_utf8_lossy(&output).to_string()) + } + + #[tokio::test] + async fn real_tty_shows_notice_pty() { + let install = staged_install(); + let release = FakeReleaseBuilder::new("9.9.9").mount().await; + write_state(&install.state_dir, STALE, Some(CURRENT), None); + + let state_dir = install.state_dir.display().to_string(); + // The eligible kit WITHOUT the force knob — the PTY itself must + // satisfy the stderr-TTY guard. + let kit: Vec<(&str, &str)> = vec![ + ("SOCKET_UPDATE_STATE_DIR", state_dir.as_str()), + ("SOCKET_NO_UPDATE_CHECK", "0"), + ("CI", ""), + ("GITHUB_ACTIONS", ""), + ("SOCKET_UPDATE_BASE_URL", &release.base_url), + ]; + let (code, output) = run_in_pty( + &install.bin, + &install.workdir, + &kit, + Duration::from_secs(30), + ); + assert_eq!(code, 0, "carrier must succeed in a PTY; got: {output}"); + assert!( + output.contains("Update available") && output.contains("9.9.9"), + "a real terminal must receive the notice without the force knob: {output}" + ); + assert_install_pristine(&install); + } +} diff --git a/crates/socket-patch-core/Cargo.toml b/crates/socket-patch-core/Cargo.toml index 72e0abb..49f8ea8 100644 --- a/crates/socket-patch-core/Cargo.toml +++ b/crates/socket-patch-core/Cargo.toml @@ -28,10 +28,17 @@ fs2 = { workspace = true } tempfile = { workspace = true } zip = { workspace = true } base64 = { workspace = true } +semver = { workspace = true } [target.'cfg(unix)'.dependencies] libc = { workspace = true } +# Self-update only needs the rename-dance helper on Windows: a running .exe +# cannot be overwritten there, while Unix swaps are a plain atomic rename we +# do ourselves (mode-preserving, setuid-refusing — see update/swap.rs). +[target.'cfg(windows)'.dependencies] +self-replace = { workspace = true } + # All ecosystems (npm, PyPI, Ruby gems, Go, Cargo, NuGet, Maven, Composer, # Deno) are unconditionally compiled in — there are no ecosystem feature # gates. Maven `apply` stays runtime-gated behind `SOCKET_EXPERIMENTAL_MAVEN=1` diff --git a/crates/socket-patch-core/src/api/client.rs b/crates/socket-patch-core/src/api/client.rs index 5908b7c..dceda68 100644 --- a/crates/socket-patch-core/src/api/client.rs +++ b/crates/socket-patch-core/src/api/client.rs @@ -877,7 +877,13 @@ impl ApiClient { ))); } } - match read_capped(resp, MAX_VENDOR_PACKAGE_BYTES).await { + match crate::utils::http::read_capped( + resp, + MAX_VENDOR_PACKAGE_BYTES, + "vendor package", + ) + .await + { Ok(bytes) => ServeDownload::Ok(bytes), Err(e) => ServeDownload::Failed(ApiError::Network(e)), } @@ -979,30 +985,8 @@ fn plain_client() -> reqwest::Client { .expect("failed to build plain reqwest client") } -/// Stream a response body into memory with a hard byte cap, rejecting both an -/// over-large declared `Content-Length` and an actual stream that exceeds the -/// cap mid-flight. -async fn read_capped(mut resp: reqwest::Response, max: u64) -> Result, String> { - if let Some(len) = resp.content_length() { - if len > max { - return Err(format!( - "vendor package too large: declared {len} bytes > {max} cap" - )); - } - } - let mut bytes: Vec = Vec::new(); - while let Some(chunk) = resp - .chunk() - .await - .map_err(|e| format!("error reading vendor package body: {e}"))? - { - if bytes.len() as u64 + chunk.len() as u64 > max { - return Err(format!("vendor package exceeded {max}-byte cap mid-stream")); - } - bytes.extend_from_slice(&chunk); - } - Ok(bytes) -} +// The capped body reader lives in `utils/http.rs` (`read_capped`) so the +// self-update downloader shares the identical cap semantics. /// Normalize a service-reported sha512 into SRI form (`sha512-`). /// diff --git a/crates/socket-patch-core/src/lib.rs b/crates/socket-patch-core/src/lib.rs index 1d10dbf..9697e18 100644 --- a/crates/socket-patch-core/src/lib.rs +++ b/crates/socket-patch-core/src/lib.rs @@ -8,5 +8,6 @@ pub mod manifest; pub mod package_json; pub mod patch; pub mod pth_hook; +pub mod update; pub mod utils; pub mod vex; diff --git a/crates/socket-patch-core/src/update/channel.rs b/crates/socket-patch-core/src/update/channel.rs new file mode 100644 index 0000000..21d8573 --- /dev/null +++ b/crates/socket-patch-core/src/update/channel.rs @@ -0,0 +1,362 @@ +//! Install-channel detection for self-update. +//! +//! socket-patch ships through several channels, and only the standalone +//! ones (install.sh, manual tarball copy) own a binary that self-update may +//! replace. npm and PyPI bundle the binary inside a version-pinned package +//! directory — swapping it there desyncs the package manager's metadata and +//! the next `npm install` / `pip install` silently reverts the update. The +//! gem and Composer launchers exec a per-version cached binary they +//! re-resolve on every run, so replacing the cache entry is meaningless. +//! For all of those, `--update` refuses and prints the channel's own +//! upgrade command instead (`--force` overrides). +//! +//! Detection is a pure function over the canonicalized executable path plus +//! a snapshot of the relevant environment, so the heuristics are +//! table-testable across platforms without touching process state. + +use std::path::{Component, Path, PathBuf}; + +/// How the currently-running binary appears to have been installed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallChannel { + /// install.sh, manual tarball download, or any unrecognized location — + /// the binary is self-managed and safe to replace in place. + Standalone, + /// Inside a `node_modules` tree (the npm platform packages bundle the + /// binary; the JS shim spawns it from there). + Npm, + /// Inside `site-packages`/`dist-packages` (the PyPI wheel bundles the + /// binary under `socket_patch/bin/`). + Pypi, + /// Under `$CARGO_HOME/bin` — managed by `cargo install`. + Cargo, + /// Under the shared launcher cache (`/socket-patch/bin/…`) used + /// by both the RubyGems and Composer launchers. The two share one + /// layout and cannot be told apart from the path alone. + LauncherCache, + /// Under a Homebrew prefix (`Cellar`, `/opt/homebrew`). + Homebrew, +} + +/// Environment snapshot consumed by [`detect_channel`]. Captured by +/// [`ChannelEnv::from_env`] in production; constructed directly in tests. +#[derive(Debug, Default, Clone)] +pub struct ChannelEnv { + pub cargo_home: Option, + pub xdg_cache_home: Option, + pub home: Option, + pub local_app_data: Option, +} + +impl ChannelEnv { + /// Snapshot the process environment. Empty values count as unset, + /// matching the CLI-wide `env_non_empty` convention. + pub fn from_env() -> Self { + fn path_var(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + } + ChannelEnv { + cargo_home: path_var("CARGO_HOME"), + xdg_cache_home: path_var("XDG_CACHE_HOME"), + home: path_var("HOME").or_else(|| path_var("USERPROFILE")), + local_app_data: path_var("LOCALAPPDATA"), + } + } +} + +/// Classify the canonicalized executable path. First match wins; anything +/// unrecognized is [`InstallChannel::Standalone`] (self-update proceeds). +/// +/// Component matches are exact-component comparisons, not substring tests: +/// `/opt/my-node_modules-tools/socket-patch` must stay Standalone. +pub fn detect_channel(canonical_exe: &Path, env: &ChannelEnv) -> InstallChannel { + if has_component(canonical_exe, "node_modules") { + return InstallChannel::Npm; + } + if has_component(canonical_exe, "site-packages") || has_component(canonical_exe, "dist-packages") + { + return InstallChannel::Pypi; + } + if let Some(bin) = cargo_bin_dir(env) { + if canonical_exe.starts_with(&bin) { + return InstallChannel::Cargo; + } + } + if launcher_cache_roots(env) + .iter() + .any(|root| canonical_exe.starts_with(root.join("socket-patch").join("bin"))) + { + return InstallChannel::LauncherCache; + } + if has_component(canonical_exe, "Cellar") + || canonical_exe.starts_with("/opt/homebrew") + || canonical_exe.starts_with("/home/linuxbrew/.linuxbrew") + { + return InstallChannel::Homebrew; + } + InstallChannel::Standalone +} + +/// The channel's own upgrade command, shown when `--update` refuses. +pub fn upgrade_hint(channel: InstallChannel) -> &'static str { + match channel { + InstallChannel::Standalone => "socket-patch --update", + InstallChannel::Npm => "npm update -g @socketsecurity/socket-patch", + InstallChannel::Pypi => "pip install --upgrade socket-patch", + InstallChannel::Cargo => "cargo install socket-patch-cli", + InstallChannel::LauncherCache => { + "gem update socket-patch (RubyGems) or composer update socketsecurity/socket-patch" + } + InstallChannel::Homebrew => "brew upgrade socket-patch", + } +} + +/// Short human label for refusal messages ("managed by npm"). +pub fn channel_label(channel: InstallChannel) -> &'static str { + match channel { + InstallChannel::Standalone => "standalone", + InstallChannel::Npm => "npm", + InstallChannel::Pypi => "pip", + InstallChannel::Cargo => "cargo install", + InstallChannel::LauncherCache => "the RubyGems/Composer launcher", + InstallChannel::Homebrew => "Homebrew", + } +} + +fn has_component(path: &Path, name: &str) -> bool { + path.components() + .any(|c| matches!(c, Component::Normal(os) if os == std::ffi::OsStr::new(name))) +} + +fn cargo_bin_dir(env: &ChannelEnv) -> Option { + if let Some(cargo_home) = &env.cargo_home { + return Some(cargo_home.join("bin")); + } + env.home.as_ref().map(|h| h.join(".cargo").join("bin")) +} + +/// Cache roots the gem/composer launchers resolve, in their probe order: +/// `$XDG_CACHE_HOME`, `~/.cache`, `%LOCALAPPDATA%`. +fn launcher_cache_roots(env: &ChannelEnv) -> Vec { + let mut roots = Vec::new(); + if let Some(xdg) = &env.xdg_cache_home { + roots.push(xdg.clone()); + } + if let Some(home) = &env.home { + roots.push(home.join(".cache")); + } + if let Some(lad) = &env.local_app_data { + roots.push(lad.clone()); + } + roots +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env_with_home(home: &str) -> ChannelEnv { + ChannelEnv { + home: Some(PathBuf::from(home)), + ..Default::default() + } + } + + #[test] + fn npm_node_modules_component_detected() { + let env = env_with_home("/home/u"); + for p in [ + "/home/u/lib/node_modules/@socketsecurity/socket-patch-linux-x64-gnu/socket-patch", + "/usr/local/lib/node_modules/@socketsecurity/socket-patch-darwin-arm64/socket-patch", + "/w/proj/node_modules/@socketsecurity/socket-patch-linux-x64-musl/socket-patch", + ] { + assert_eq!( + detect_channel(Path::new(p), &env), + InstallChannel::Npm, + "{p}" + ); + } + } + + #[test] + fn component_match_is_exact_not_substring() { + // A directory that merely *contains* the marker text must not match: + // component equality, not substring search. + let env = env_with_home("/home/u"); + for p in [ + "/opt/my-node_modules-tools/socket-patch", + "/srv/site-packages-backup/socket-patch", + "/data/Cellar-archive/socket-patch", + ] { + assert_eq!( + detect_channel(Path::new(p), &env), + InstallChannel::Standalone, + "{p}" + ); + } + } + + #[test] + fn pypi_site_and_dist_packages_detected() { + let env = env_with_home("/home/u"); + for p in [ + "/venv/lib/python3.12/site-packages/socket_patch/bin/socket-patch", + // Debian system pythons use dist-packages. + "/usr/lib/python3/dist-packages/socket_patch/bin/socket-patch", + ] { + assert_eq!( + detect_channel(Path::new(p), &env), + InstallChannel::Pypi, + "{p}" + ); + } + } + + #[test] + fn cargo_bin_via_home_fallback() { + let env = env_with_home("/home/u"); + assert_eq!( + detect_channel(Path::new("/home/u/.cargo/bin/socket-patch"), &env), + InstallChannel::Cargo + ); + // A different user's .cargo/bin is NOT ours. + assert_eq!( + detect_channel(Path::new("/home/other/.cargo/bin/socket-patch"), &env), + InstallChannel::Standalone + ); + } + + #[test] + fn cargo_home_env_overrides_home_fallback() { + let env = ChannelEnv { + cargo_home: Some(PathBuf::from("/opt/rust/cargo")), + home: Some(PathBuf::from("/home/u")), + ..Default::default() + }; + assert_eq!( + detect_channel(Path::new("/opt/rust/cargo/bin/socket-patch"), &env), + InstallChannel::Cargo + ); + // With CARGO_HOME set, the ~/.cargo/bin fallback is NOT consulted — + // cargo itself resolves exactly one home. + assert_eq!( + detect_channel(Path::new("/home/u/.cargo/bin/socket-patch"), &env), + InstallChannel::Standalone + ); + } + + #[test] + fn launcher_cache_detected_via_xdg_then_home() { + let env = ChannelEnv { + xdg_cache_home: Some(PathBuf::from("/home/u/.custom-cache")), + home: Some(PathBuf::from("/home/u")), + ..Default::default() + }; + for p in [ + "/home/u/.custom-cache/socket-patch/bin/3.3.0/x86_64-unknown-linux-gnu/socket-patch", + "/home/u/.cache/socket-patch/bin/3.3.0/aarch64-apple-darwin/socket-patch", + ] { + assert_eq!( + detect_channel(Path::new(p), &env), + InstallChannel::LauncherCache, + "{p}" + ); + } + // The state file the notifier writes lives at + // /socket-patch/update-check.json — only the bin/ subtree is + // launcher territory. A hypothetical binary directly under the + // socket-patch cache root is standalone. + assert_eq!( + detect_channel(Path::new("/home/u/.cache/socket-patch/socket-patch"), &env), + InstallChannel::Standalone + ); + } + + #[test] + fn homebrew_prefixes_detected() { + let env = env_with_home("/Users/u"); + for p in [ + "/opt/homebrew/bin/socket-patch", + "/usr/local/Cellar/socket-patch/3.3.0/bin/socket-patch", + "/home/linuxbrew/.linuxbrew/bin/socket-patch", + ] { + assert_eq!( + detect_channel(Path::new(p), &env), + InstallChannel::Homebrew, + "{p}" + ); + } + } + + #[test] + fn standalone_install_locations_pass() { + let env = env_with_home("/home/u"); + for p in [ + "/usr/local/bin/socket-patch", + "/home/u/.local/bin/socket-patch", + "/home/u/bin/socket-patch", + "/tmp/wherever/socket-patch", + ] { + assert_eq!( + detect_channel(Path::new(p), &env), + InstallChannel::Standalone, + "{p}" + ); + } + } + + #[cfg(windows)] + #[test] + fn windows_paths_detected() { + // Backslash-separated paths only split into components on Windows, + // so these spellings can't be exercised from the Unix test runs. + let env = ChannelEnv { + home: Some(PathBuf::from(r"C:\Users\u")), + local_app_data: Some(PathBuf::from(r"C:\Users\u\AppData\Local")), + ..Default::default() + }; + assert_eq!( + detect_channel( + Path::new( + r"C:\Users\u\AppData\Roaming\npm\node_modules\@socketsecurity\socket-patch-win32-x64\socket-patch.exe" + ), + &env + ), + InstallChannel::Npm + ); + assert_eq!( + detect_channel( + Path::new(r"C:\Users\u\.cargo\bin\socket-patch.exe"), + &env + ), + InstallChannel::Cargo + ); + assert_eq!( + detect_channel( + Path::new( + r"C:\Users\u\AppData\Local\socket-patch\bin\3.3.0\x86_64-pc-windows-msvc\socket-patch.exe" + ), + &env + ), + InstallChannel::LauncherCache + ); + assert_eq!( + detect_channel(Path::new(r"C:\tools\socket-patch.exe"), &env), + InstallChannel::Standalone + ); + } + + #[test] + fn hints_route_to_the_owning_manager() { + assert!(upgrade_hint(InstallChannel::Npm).contains("npm update -g")); + assert!(upgrade_hint(InstallChannel::Pypi).contains("pip install --upgrade")); + assert!(upgrade_hint(InstallChannel::Cargo).contains("cargo install")); + assert!(upgrade_hint(InstallChannel::LauncherCache).contains("gem update")); + assert!(upgrade_hint(InstallChannel::LauncherCache).contains("composer update")); + assert!(upgrade_hint(InstallChannel::Homebrew).contains("brew upgrade")); + assert!(upgrade_hint(InstallChannel::Standalone).contains("--update")); + } +} diff --git a/crates/socket-patch-core/src/update/download.rs b/crates/socket-patch-core/src/update/download.rs new file mode 100644 index 0000000..511e570 --- /dev/null +++ b/crates/socket-patch-core/src/update/download.rs @@ -0,0 +1,563 @@ +//! Download, verify, extract, stage, and sanity-check a release binary. +//! +//! Order is load-bearing and pinned by tests: +//! +//! 1. fetch `SHA256SUMS` and find our asset's entry (refuse before wasting +//! a download on an asset the release cannot vouch for); +//! 2. fetch the archive (capped, explicit timeout); +//! 3. verify the SHA-256 of the raw archive bytes **before** extraction; +//! 4. extract exactly one member (`socket-patch`/`socket-patch.exe`); +//! 5. stage the binary INTO the destination directory (same-filesystem +//! rename; system temp is frequently `noexec`, which would break the +//! sanity exec; an `EACCES` here doubles as the permissions preflight); +//! 6. sanity-exec the staged file (`--version`) before any swap. +//! +//! Nothing in this module touches the destination path itself — the swap +//! lives in `swap.rs` and consumes the staged file this module returns. + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; + +use super::release::{UpdateEndpoints, UpdateTimeouts}; +use super::UpdateError; +use crate::utils::http::read_capped; + +/// Hard cap on the compressed archive (the real ones are ~5–10 MiB) — +/// matches the vendor artifact-download cap. +const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024; + +/// Hard cap on the single extracted binary, enforced during streaming +/// decompression so a decompression bomb can't balloon memory. +const MAX_BINARY_BYTES: u64 = 256 * 1024 * 1024; + +/// Prefix for staged-binary files in the destination directory. The +/// start-of-run sweep removes stale ones (crash leftovers). +pub(crate) const STAGE_PREFIX: &str = ".socket-patch.stage-"; + +/// A downloaded, verified, extracted, staged binary — everything but the +/// swap. Deleting the stage file on failure is the caller's job (the +/// [`StagedBinary::cleanup`] helper is best-effort). +#[derive(Debug)] +pub struct StagedBinary { + pub path: PathBuf, + pub asset: String, + pub archive_bytes: u64, + pub archive_sha256: String, +} + +impl StagedBinary { + pub fn cleanup(&self) { + let _ = std::fs::remove_file(&self.path); + } +} + +/// Download client: credential-free (User-Agent only — the Socket bearer +/// must never reach GitHub/CDN hosts), explicit timeouts, and the shared +/// redirect policy (`release::follow_redirect_policy`): HTTPS-only hops on +/// the default endpoints, hop-count-limited on overridden (loopback/ +/// mirror) bases. +fn download_client( + endpoints: &UpdateEndpoints, + timeouts: &UpdateTimeouts, +) -> Result { + reqwest::Client::builder() + .user_agent(crate::constants::USER_AGENT) + .connect_timeout(timeouts.connect) + .timeout(timeouts.download) + .redirect(super::release::follow_redirect_policy(endpoints)) + .build() + .map_err(|e| UpdateError::Network(format!("failed to build HTTP client: {e}"))) +} + +/// Fetch the release archive for `asset`, returning its raw bytes. +async fn fetch_archive( + endpoints: &UpdateEndpoints, + timeouts: &UpdateTimeouts, + version: &semver::Version, + asset: &str, +) -> Result, UpdateError> { + let client = download_client(endpoints, timeouts)?; + let url = endpoints.download_url(version, asset); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| UpdateError::Network(format!("GET {url}: {e}")))?; + let status = resp.status(); + if status == reqwest::StatusCode::NOT_FOUND { + return Err(UpdateError::AssetNotFound { + asset: asset.to_string(), + version: version.to_string(), + }); + } + if !status.is_success() { + return Err(UpdateError::DownloadFailed(format!( + "GET {url} returned {status}" + ))); + } + read_capped(resp, MAX_ARCHIVE_BYTES, "release archive") + .await + .map_err(UpdateError::DownloadFailed) +} + +/// Extract the single expected member from a `.tar.gz` (`socket-patch`) or +/// `.zip` (`socket-patch.exe`) archive. Exactly one candidate must exist; +/// paths are matched exactly, which rejects traversal names by +/// construction. Decompressed size is capped. +fn extract_binary(asset: &str, archive: &[u8]) -> Result, UpdateError> { + if asset.ends_with(".zip") { + extract_zip_member(archive, "socket-patch.exe") + } else { + extract_targz_member(archive, "socket-patch") + } +} + +fn extract_targz_member(archive: &[u8], member: &str) -> Result, UpdateError> { + let gz = flate2::read::GzDecoder::new(archive); + let mut tar = tar::Archive::new(gz); + let mut found: Option> = None; + let entries = tar + .entries() + .map_err(|e| UpdateError::VerifyFailed(format!("unreadable tar.gz archive: {e}")))?; + for entry in entries { + let entry = + entry.map_err(|e| UpdateError::VerifyFailed(format!("corrupt tar entry: {e}")))?; + let path = entry + .path() + .map_err(|e| UpdateError::VerifyFailed(format!("undecodable tar path: {e}")))?; + if path != Path::new(member) { + continue; + } + if found.is_some() { + return Err(UpdateError::VerifyFailed(format!( + "archive contains multiple {member} entries" + ))); + } + let mut bytes = Vec::new(); + entry + .take(MAX_BINARY_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|e| UpdateError::VerifyFailed(format!("error reading {member}: {e}")))?; + if bytes.len() as u64 > MAX_BINARY_BYTES { + return Err(UpdateError::VerifyFailed(format!( + "{member} exceeds the {MAX_BINARY_BYTES}-byte cap" + ))); + } + found = Some(bytes); + } + found.ok_or_else(|| { + UpdateError::VerifyFailed(format!("archive does not contain a {member} entry")) + }) +} + +fn extract_zip_member(archive: &[u8], member: &str) -> Result, UpdateError> { + let cursor = std::io::Cursor::new(archive); + let mut zip = zip::ZipArchive::new(cursor) + .map_err(|e| UpdateError::VerifyFailed(format!("unreadable zip archive: {e}")))?; + let file = zip + .by_name(member) + .map_err(|_| UpdateError::VerifyFailed(format!("archive does not contain a {member} entry")))?; + if file.size() > MAX_BINARY_BYTES { + return Err(UpdateError::VerifyFailed(format!( + "{member} exceeds the {MAX_BINARY_BYTES}-byte cap" + ))); + } + let mut bytes = Vec::new(); + file.take(MAX_BINARY_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|e| UpdateError::VerifyFailed(format!("error reading {member}: {e}")))?; + if bytes.len() as u64 > MAX_BINARY_BYTES { + return Err(UpdateError::VerifyFailed(format!( + "{member} exceeds the {MAX_BINARY_BYTES}-byte cap" + ))); + } + Ok(bytes) +} + +/// Write the extracted binary into `dest_dir` as an executable stage file. +/// `EACCES` here is the permissions preflight: it means the eventual +/// rename would fail too, so it maps to the sudo-hint error before any +/// mutation. +fn stage_binary(dest_dir: &Path, bytes: &[u8]) -> Result { + let stage = dest_dir.join(format!("{STAGE_PREFIX}{}", uuid::Uuid::new_v4())); + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o755); + } + let mut file = opts.open(&stage).map_err(|e| { + if e.kind() == std::io::ErrorKind::PermissionDenied { + UpdateError::PermissionDenied { + path: dest_dir.to_path_buf(), + } + } else { + UpdateError::SwapFailed(format!("cannot stage into {}: {e}", dest_dir.display())) + } + })?; + use std::io::Write; + let write_result = file + .write_all(bytes) + .and_then(|()| file.sync_all()); + drop(file); + if let Err(e) = write_result { + let _ = std::fs::remove_file(&stage); + return Err(UpdateError::SwapFailed(format!( + "error writing staged binary: {e}" + ))); + } + Ok(stage) +} + +/// Best-effort sweep of stale stage files (crash leftovers) in `dest_dir`. +/// +/// Age-gated: the update lock lives in the per-user state dir, so two +/// updaters with divergent state-dir resolution (different `$HOME`s +/// targeting one shared `/usr/local/bin`) can run concurrently — an +/// unconditional sweep would delete the other run's *live* stage mid- +/// pipeline and turn a benign race into a spurious failure. A genuine +/// crash leftover is minutes-to-days old; a live stage is seconds old. +pub(crate) fn sweep_stale_stages(dest_dir: &Path) { + const MIN_STALE_AGE: std::time::Duration = std::time::Duration::from_secs(60 * 60); + let Ok(entries) = std::fs::read_dir(dest_dir) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name.starts_with(STAGE_PREFIX) && !name.starts_with(".socket-patch.old-") { + continue; + } + let old_enough = entry + .metadata() + .and_then(|m| m.modified()) + .ok() + .and_then(|mtime| mtime.elapsed().ok()) + .map(|age| age >= MIN_STALE_AGE) + // Unreadable metadata/clock: assume stale — the pre-gate + // behavior — rather than accumulating junk forever. + .unwrap_or(true); + if old_enough { + let _ = std::fs::remove_file(entry.path()); + } + } +} + +/// Run ` --version` and check the answer. Catches wrong-arch +/// assets, exec-format problems, and (in strict mode) a release whose +/// binary does not report the tag it was published under. +/// +/// Strictness follows the endpoint trust model: against real GitHub the +/// reported version must equal `expected`; under a `SOCKET_UPDATE_BASE_URL` +/// override (mirror or test fixture — already a total-trust knob) a +/// mismatch only warns via the returned `Option`. +async fn sanity_exec( + staged: &Path, + expected: &semver::Version, + strict: bool, +) -> Result, UpdateError> { + let mut cmd = tokio::process::Command::new(staged); + cmd.arg("--version") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + let output = tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()) + .await + .map_err(|_| { + UpdateError::VerifyFailed( + "downloaded binary hung during its --version self-check".to_string(), + ) + })? + .map_err(|e| { + UpdateError::VerifyFailed(format!( + "downloaded binary failed to execute (wrong architecture?): {e}" + )) + })?; + if !output.status.success() { + return Err(UpdateError::VerifyFailed(format!( + "downloaded binary's --version self-check exited with {}", + output.status + ))); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let reported = stdout.trim(); + // clap prints "socket-patch ". + if !reported.starts_with("socket-patch") { + return Err(UpdateError::VerifyFailed(format!( + "downloaded binary identifies as {reported:?}, not socket-patch" + ))); + } + let version_ok = reported + .split_whitespace() + .nth(1) + .map(|v| v == expected.to_string()) + .unwrap_or(false); + if version_ok { + return Ok(None); + } + let detail = format!( + "downloaded binary reports {reported:?} instead of version {expected}" + ); + if strict { + Err(UpdateError::VerifyFailed(detail)) + } else { + Ok(Some(detail)) + } +} + +/// The full pre-swap pipeline (module docs). On success the returned +/// [`StagedBinary`] sits executable in `dest_dir`, verified end to end. +/// `warnings` collects non-fatal notes (relaxed version check). +pub async fn download_and_stage( + endpoints: &UpdateEndpoints, + timeouts: &UpdateTimeouts, + version: &semver::Version, + asset: &str, + dest_dir: &Path, + warnings: &mut Vec, +) -> Result { + // 1. SHA256SUMS first: refuse before downloading an unvouched asset. + let expected_sha = + super::release::fetch_sha256sums_entry(endpoints, timeouts, version, asset).await?; + + // 2. Archive. + let archive = fetch_archive(endpoints, timeouts, version, asset).await?; + + // 3. Checksum BEFORE extraction. + let actual_sha = hex::encode(Sha256::digest(&archive)); + if actual_sha != expected_sha { + return Err(UpdateError::ChecksumMismatch { + asset: asset.to_string(), + detail: format!("expected {expected_sha}, downloaded {actual_sha}"), + }); + } + + // 4. Extract the one expected member. + let binary = extract_binary(asset, &archive)?; + + // 5. Stage into the destination directory. + let staged_path = stage_binary(dest_dir, &binary)?; + let staged = StagedBinary { + path: staged_path, + asset: asset.to_string(), + archive_bytes: archive.len() as u64, + archive_sha256: actual_sha, + }; + + // 6. Sanity-exec before anything irreversible. + match sanity_exec(&staged.path, version, endpoints.is_default()).await { + Ok(None) => {} + Ok(Some(warning)) => warnings.push(format!("{warning} (allowed: custom update base URL)")), + Err(e) => { + staged.cleanup(); + return Err(e); + } + } + Ok(staged) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::GzEncoder; + use flate2::Compression; + + fn tgz_with(entries: &[(&str, &[u8])]) -> Vec { + let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default())); + for (name, bytes) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(bytes.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder.append_data(&mut header, name, *bytes).unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() + } + + fn zip_with(entries: &[(&str, &[u8])]) -> Vec { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut writer = zip::ZipWriter::new(&mut buf); + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + for (name, bytes) in entries { + use std::io::Write; + writer.start_file(*name, opts).unwrap(); + writer.write_all(bytes).unwrap(); + } + writer.finish().unwrap(); + } + buf.into_inner() + } + + #[test] + fn targz_single_member_extracts() { + let archive = tgz_with(&[("socket-patch", b"BINARY")]); + assert_eq!( + extract_binary("socket-patch-x.tar.gz", &archive).unwrap(), + b"BINARY" + ); + } + + #[test] + fn targz_missing_member_is_error() { + let archive = tgz_with(&[("README.md", b"nope")]); + let err = extract_binary("socket-patch-x.tar.gz", &archive).unwrap_err(); + assert!(err.to_string().contains("does not contain"), "{err}"); + } + + /// Like [`tgz_with`], but writes entry names into the raw GNU header + /// bytes, bypassing tar-rs's builder-side `..` sanitization — a hostile + /// archive wouldn't have used a polite builder either. + fn tgz_with_raw_names(entries: &[(&str, &[u8])]) -> Vec { + let mut builder = tar::Builder::new(GzEncoder::new(Vec::new(), Compression::default())); + for (name, bytes) in entries { + let mut header = tar::Header::new_gnu(); + let gnu = header.as_gnu_mut().unwrap(); + gnu.name[..name.len()].copy_from_slice(name.as_bytes()); + header.set_size(bytes.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder.append(&header, *bytes).unwrap(); + } + builder.into_inner().unwrap().finish().unwrap() + } + + #[test] + fn targz_traversal_names_are_not_the_member() { + // Exact-path matching rejects traversal spellings by construction: + // none of these IS "socket-patch", so nothing extracts. + let archive = tgz_with_raw_names(&[ + ("../socket-patch", b"evil"), + ("./x/../../socket-patch", b"evil"), + ("bin/socket-patch", b"nested"), + ]); + assert!(extract_binary("socket-patch-x.tar.gz", &archive).is_err()); + } + + #[test] + fn targz_duplicate_members_refused() { + let archive = tgz_with(&[("socket-patch", b"one"), ("socket-patch", b"two")]); + let err = extract_binary("socket-patch-x.tar.gz", &archive).unwrap_err(); + assert!(err.to_string().contains("multiple"), "{err}"); + } + + #[test] + fn targz_garbage_bytes_are_an_error_not_a_panic() { + assert!(extract_binary("socket-patch-x.tar.gz", b"not a tarball").is_err()); + } + + #[test] + fn zip_member_extracts_and_missing_errors() { + let archive = zip_with(&[("socket-patch.exe", b"PEBYTES")]); + assert_eq!( + extract_binary("socket-patch-x.zip", &archive).unwrap(), + b"PEBYTES" + ); + let archive = zip_with(&[("other.exe", b"nope")]); + assert!(extract_binary("socket-patch-x.zip", &archive).is_err()); + } + + #[test] + fn stage_lands_executable_in_dest_dir_and_sweep_is_age_gated() { + let tmp = tempfile::tempdir().unwrap(); + let staged = stage_binary(tmp.path(), b"#!/bin/sh\nexit 0\n").unwrap(); + assert!(staged.starts_with(tmp.path())); + assert!(staged + .file_name() + .unwrap() + .to_string_lossy() + .starts_with(STAGE_PREFIX)); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&staged).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o755, "staged binary must be executable"); + } + // A seconds-old stage may belong to a CONCURRENT update whose lock + // lives in a different state dir (shared install, divergent HOMEs) + // — the sweep must leave it alone. + sweep_stale_stages(tmp.path()); + assert!( + staged.exists(), + "sweep must not remove a freshly-created (possibly live) stage" + ); + // Aged past the threshold it is a crash leftover and goes away. + #[cfg(unix)] + { + let ok = std::process::Command::new("touch") + .args(["-m", "-t", "202001010000"]) + .arg(&staged) + .status() + .map(|s| s.success()) + .unwrap_or(false); + assert!(ok, "touch -t must succeed to age the stage file"); + sweep_stale_stages(tmp.path()); + assert!(!staged.exists(), "sweep must remove old stage leftovers"); + } + } + + #[cfg(unix)] + #[test] + fn stage_into_readonly_dir_maps_to_permission_denied() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("ro"); + std::fs::create_dir(&dir).unwrap(); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).unwrap(); + // Root ignores mode bits; skip there (CI containers sometimes run as root). + if std::fs::File::create(dir.join("probe")).is_ok() { + let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)); + eprintln!("skipping: running as root, 0555 does not block writes"); + return; + } + let err = stage_binary(&dir, b"x").unwrap_err(); + assert!( + matches!(err, UpdateError::PermissionDenied { .. }), + "expected PermissionDenied, got: {err}" + ); + std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + + #[cfg(unix)] + #[tokio::test] + async fn sanity_exec_rejects_wrong_program_and_honors_strictness() { + let tmp = tempfile::tempdir().unwrap(); + let expected = semver::Version::new(9, 9, 9); + + let write_script = |name: &str, body: &str| { + use std::os::unix::fs::PermissionsExt; + let path = tmp.path().join(name); + std::fs::write(&path, body).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + }; + + // Wrong program name: hard error in both modes. + let imposter = write_script("imposter", "#!/bin/sh\necho other-tool 9.9.9\n"); + assert!(sanity_exec(&imposter, &expected, false).await.is_err()); + + // Non-zero exit: hard error. + let failing = write_script("failing", "#!/bin/sh\necho socket-patch 9.9.9\nexit 3\n"); + assert!(sanity_exec(&failing, &expected, true).await.is_err()); + + // Version mismatch: fatal in strict mode, warning otherwise. + let mismatched = write_script("mismatch", "#!/bin/sh\necho socket-patch 1.0.0\n"); + assert!(sanity_exec(&mismatched, &expected, true).await.is_err()); + let warning = sanity_exec(&mismatched, &expected, false).await.unwrap(); + assert!(warning.unwrap().contains("1.0.0")); + + // Exact match: clean pass in strict mode. + let good = write_script("good", "#!/bin/sh\necho socket-patch 9.9.9\n"); + assert_eq!(sanity_exec(&good, &expected, true).await.unwrap(), None); + + // Exec-format failure (not executable at all): hard error. + let garbage = tmp.path().join("garbage"); + std::fs::write(&garbage, b"\x00\x01\x02").unwrap(); + assert!(sanity_exec(&garbage, &expected, false).await.is_err()); + } +} diff --git a/crates/socket-patch-core/src/update/mod.rs b/crates/socket-patch-core/src/update/mod.rs new file mode 100644 index 0000000..07402be --- /dev/null +++ b/crates/socket-patch-core/src/update/mod.rs @@ -0,0 +1,152 @@ +//! Self-update engine: resolve the latest GitHub release, download and +//! verify the platform asset, and atomically replace the installed binary. +//! +//! The CLI layer owns policy (offline gate, managed-channel refusal, +//! confirmation, envelopes, exit codes) and passes everything +//! environment-shaped in as parameters — most importantly the install path +//! ([`perform_update`] never calls `current_exe()` itself; see +//! `swap::resolve_install_path`) and the compiled target triple. That +//! dependency injection is what lets unit tests aim the machinery at +//! tempdir files and arbitrary triples, and makes it structurally +//! impossible for an in-process test to swap the test harness binary. + +pub mod channel; +pub mod download; +pub mod release; +pub mod state; +pub mod swap; + +use std::path::{Path, PathBuf}; + +pub use channel::{channel_label, detect_channel, upgrade_hint, ChannelEnv, InstallChannel}; +pub use release::{ + asset_name_for_target, current_version, fetch_latest_version, is_newer, parse_release_tag, + UpdateEndpoints, UpdateTimeouts, +}; +pub use state::{ + check_is_due, load_state, notice_is_due, save_state, unix_now, UpdateCheckState, + CHECK_INTERVAL, +}; +pub use swap::resolve_install_path; + +/// Errors from the update engine. `error_code()` values are the stable +/// envelope `errorCode` tags documented in CLI_CONTRACT.md. +#[derive(Debug, thiserror::Error)] +pub enum UpdateError { + #[error("could not check for updates: {0}")] + CheckFailed(String), + + #[error("network error: {0}")] + Network(String), + + #[error("release v{version} has no prebuilt binary {asset} for this platform")] + AssetNotFound { asset: String, version: String }, + + #[error("download failed: {0}")] + DownloadFailed(String), + + #[error("checksum verification failed for {asset}: {detail}")] + ChecksumMismatch { asset: String, detail: String }, + + #[error("downloaded binary failed verification: {0}")] + VerifyFailed(String), + + #[error("could not install the update: {0}")] + SwapFailed(String), + + #[error("permission denied writing to {}", path.display())] + PermissionDenied { path: PathBuf }, + + #[error("another socket-patch update is already in progress")] + InProgress, +} + +impl UpdateError { + /// Stable machine-routing tag for the JSON envelope. + pub fn error_code(&self) -> &'static str { + match self { + UpdateError::CheckFailed(_) => "check_failed", + UpdateError::Network(_) => "download_failed", + UpdateError::AssetNotFound { .. } => "asset_not_found", + UpdateError::DownloadFailed(_) => "download_failed", + UpdateError::ChecksumMismatch { .. } => "checksum_mismatch", + UpdateError::VerifyFailed(_) => "verify_failed", + UpdateError::SwapFailed(_) => "swap_failed", + UpdateError::PermissionDenied { .. } => "permission_denied", + UpdateError::InProgress => "update_in_progress", + } + } +} + +/// Everything [`perform_update`] needs, resolved by the CLI layer. +#[derive(Debug)] +pub struct UpdateRequest<'a> { + /// Compiled target triple (the CLI's `build.rs`-embedded + /// `SOCKET_PATCH_TARGET`). + pub target_triple: &'a str, + /// The exact version to install (already resolved: latest or a pin). + pub version: &'a semver::Version, + /// Canonicalized path of the binary to replace. + pub install_path: &'a Path, + pub endpoints: &'a UpdateEndpoints, + pub timeouts: &'a UpdateTimeouts, +} + +/// What a completed update did, for the envelope/summary. +#[derive(Debug)] +pub struct UpdateOutcome { + pub asset: String, + pub archive_bytes: u64, + pub archive_sha256: String, + pub installed_path: PathBuf, + /// Non-fatal notes (e.g. the relaxed version self-check under a custom + /// base URL). + pub warnings: Vec, +} + +/// Download → verify → stage → sanity-exec → swap, under the single-flight +/// lock. Every failure path leaves the installed binary untouched: all +/// mutation happens on a staged sibling until the one atomic rename. +pub async fn perform_update(req: UpdateRequest<'_>) -> Result { + let _lock = swap::acquire_update_lock()?; + + let dest_dir = req.install_path.parent().ok_or_else(|| { + UpdateError::SwapFailed(format!( + "install path {} has no parent directory", + req.install_path.display() + )) + })?; + + // Crash leftovers from previous runs (stale stages, parked old exes). + download::sweep_stale_stages(dest_dir); + + let asset = asset_name_for_target(req.target_triple); + let mut warnings = Vec::new(); + let staged = download::download_and_stage( + req.endpoints, + req.timeouts, + req.version, + &asset, + dest_dir, + &mut warnings, + ) + .await?; + + swap::swap_binary(&staged.path, req.install_path)?; + + // Remember what we just installed so the passive notifier never nags + // about a version the user already has. Best-effort: state problems + // must not fail a completed update. + let mut check_state = load_state(); + check_state.last_check_at = Some(unix_now()); + check_state.latest_seen = Some(req.version.to_string()); + let _ = save_state(&check_state).await; + + Ok(UpdateOutcome { + asset: staged.asset, + archive_bytes: staged.archive_bytes, + archive_sha256: staged.archive_sha256, + installed_path: req.install_path.to_path_buf(), + warnings, + }) +} diff --git a/crates/socket-patch-core/src/update/release.rs b/crates/socket-patch-core/src/update/release.rs new file mode 100644 index 0000000..ba761bf --- /dev/null +++ b/crates/socket-patch-core/src/update/release.rs @@ -0,0 +1,617 @@ +//! GitHub-release metadata for self-update: resolving the latest version +//! and mapping our compiled target triple to a release asset. +//! +//! Latest-version resolution is a two-step ladder: +//! +//! 1. **Redirect probe** (primary): `GET {base}/SocketDev/socket-patch/ +//! releases/latest` with redirects disabled; GitHub answers 302 with a +//! `Location` ending in `/releases/tag/v`. Same host as the +//! asset downloads (one proxy/allowlist story), no API rate limits, +//! zero-byte body. +//! 2. **API fallback**: `GET {api}/repos/SocketDev/socket-patch/releases/ +//! latest` (`tag_name` from JSON). Unauthenticated (60 req/h/IP) — fine +//! for a fallback that only fires when the redirect shape drifts. +//! +//! All fetch sizes are capped and every request carries an explicit +//! timeout: a hung self-update is strictly worse than a hung scan, so this +//! module does not inherit the API client's no-timeout posture. + +use std::time::Duration; + +use super::UpdateError; +use crate::utils::http::read_capped; + +/// GitHub org/repo path segment for release URLs. One constant so the +/// redirect probe, API fallback, and download URLs can never disagree. +pub(crate) const RELEASE_REPO: &str = "SocketDev/socket-patch"; + +/// Default web base for release URLs (redirect probe + asset downloads). +pub const DEFAULT_UPDATE_BASE_URL: &str = "https://github.com"; + +/// Default API base for the JSON fallback. +const DEFAULT_UPDATE_API_BASE_URL: &str = "https://api.github.com"; + +/// Metadata (redirect probe / SHA256SUMS / API JSON) responses are tiny; +/// anything above this is a misbehaving or hostile server. +const METADATA_CAP_BYTES: u64 = 1024 * 1024; + +/// Resolved base URLs for one update run. +/// +/// `SOCKET_UPDATE_BASE_URL` (internal, test/mirror support — same posture +/// as `SOCKET_NPM_REGISTRY`) points BOTH the web-style and API-style routes +/// at one server, so a wiremock fixture can serve the whole flow. When it +/// is set, [`UpdateEndpoints::is_default`] turns false and the downloaded +/// binary's version self-check downgrades from hard-fail to warning (a +/// mirror may repackage; the override is already a total-trust knob). +#[derive(Debug, Clone)] +pub struct UpdateEndpoints { + pub web_base: String, + pub api_base: String, + is_default: bool, +} + +impl UpdateEndpoints { + pub fn from_env() -> Self { + match std::env::var("SOCKET_UPDATE_BASE_URL") + .ok() + .filter(|v| !v.is_empty()) + { + Some(base) => { + let base = base.trim_end_matches('/').to_string(); + UpdateEndpoints { + web_base: base.clone(), + api_base: base, + is_default: false, + } + } + None => UpdateEndpoints { + web_base: DEFAULT_UPDATE_BASE_URL.to_string(), + api_base: DEFAULT_UPDATE_API_BASE_URL.to_string(), + is_default: true, + }, + } + } + + /// True when talking to real GitHub (no `SOCKET_UPDATE_BASE_URL`). + pub fn is_default(&self) -> bool { + self.is_default + } + + /// `{web_base}/SocketDev/socket-patch/releases/download/v/` + pub fn download_url(&self, version: &semver::Version, file: &str) -> String { + format!( + "{}/{}/releases/download/v{version}/{file}", + self.web_base, RELEASE_REPO + ) + } + + fn latest_probe_url(&self) -> String { + format!("{}/{}/releases/latest", self.web_base, RELEASE_REPO) + } + + fn latest_api_url(&self) -> String { + format!("{}/repos/{}/releases/latest", self.api_base, RELEASE_REPO) + } +} + +/// Timeouts for one update run. `from_env` honors the internal +/// `SOCKET_UPDATE_TIMEOUT_MS` override (tests need millisecond-scale +/// timeouts; slow links may need more than the defaults). +#[derive(Debug, Clone, Copy)] +pub struct UpdateTimeouts { + pub connect: Duration, + /// Whole-request budget for metadata fetches (probe, API JSON, SHA256SUMS). + pub metadata: Duration, + /// Whole-request budget for the archive download. + pub download: Duration, +} + +impl Default for UpdateTimeouts { + fn default() -> Self { + UpdateTimeouts { + connect: Duration::from_secs(10), + metadata: Duration::from_secs(30), + download: Duration::from_secs(300), + } + } +} + +impl UpdateTimeouts { + pub fn from_env() -> Self { + let default = UpdateTimeouts::default(); + match std::env::var("SOCKET_UPDATE_TIMEOUT_MS") + .ok() + .filter(|v| !v.is_empty()) + .and_then(|v| v.parse::().ok()) + { + Some(ms) => { + let budget = Duration::from_millis(ms); + UpdateTimeouts { + connect: budget.min(default.connect), + metadata: budget, + download: budget, + } + } + None => default, + } + } +} + +/// True when `candidate` is strictly newer than `current` by semver +/// *precedence* (build metadata ignored). The `semver` crate's `Ord` is a +/// total order that tiebreaks on build metadata, which would make +/// `3.4.0+hotfix` look "newer" than an installed `3.4.0` — precedence +/// comparison is the update-decision semantic. +pub fn is_newer(candidate: &semver::Version, current: &semver::Version) -> bool { + candidate.cmp_precedence(current) == std::cmp::Ordering::Greater +} + +/// The version currently compiled into this binary. +pub fn current_version() -> semver::Version { + // CARGO_PKG_VERSION is always valid semver — cargo enforces it. + semver::Version::parse(env!("CARGO_PKG_VERSION")) + .expect("CARGO_PKG_VERSION is valid semver by construction") +} + +/// Map a compiled target triple to its release asset filename. +/// +/// Release CI packages every non-Windows target as `socket-patch- +/// .tar.gz` and the three `*-pc-windows-msvc` targets as `.zip` +/// (see `.github/workflows/release.yml`). The triple arrives as a +/// parameter (the CLI passes its `build.rs`-embedded `SOCKET_PATCH_TARGET`) +/// so core stays testable across all fourteen triples from one host. +pub fn asset_name_for_target(target_triple: &str) -> String { + if target_triple.ends_with("-pc-windows-msvc") { + format!("socket-patch-{target_triple}.zip") + } else { + format!("socket-patch-{target_triple}.tar.gz") + } +} + +/// Parse a release tag (`v3.4.0` or `3.4.0`, surrounding whitespace +/// tolerated) into a semver version. +pub fn parse_release_tag(tag: &str) -> Result { + let trimmed = tag.trim(); + let bare = trimmed.strip_prefix('v').unwrap_or(trimmed); + semver::Version::parse(bare) + .map_err(|e| UpdateError::CheckFailed(format!("unparseable release tag {trimmed:?}: {e}"))) +} + +/// Extract the version from a `releases/latest` redirect `Location` header +/// (`…/releases/tag/v`). +pub(crate) fn version_from_location(location: &str) -> Result { + let tag = location + .rsplit_once("/releases/tag/") + .map(|(_, tag)| tag) + .ok_or_else(|| { + UpdateError::CheckFailed(format!( + "release redirect Location {location:?} does not contain /releases/tag/" + )) + })?; + // Strip any query/fragment noise a proxy might append. + let tag = tag.split(['?', '#']).next().unwrap_or(tag); + parse_release_tag(tag) +} + +/// Look up `file`'s SHA-256 in a `SHA256SUMS` body (` ` per +/// line, `*` binary-mode marker tolerated, CRLF tolerated — +/// the same grammar install.sh consumes). +pub fn sha256sums_entry(sums: &str, file: &str) -> Result { + let mut found: Option = None; + for line in sums.lines() { + let line = line.trim_end_matches('\r'); + let mut parts = line.split_whitespace(); + let (Some(hex_digest), Some(name)) = (parts.next(), parts.next()) else { + continue; // blank or malformed line: skip, absence still errors below + }; + let name = name.strip_prefix('*').unwrap_or(name); + if name != file { + continue; + } + if hex_digest.len() != 64 || !hex_digest.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(UpdateError::ChecksumMismatch { + asset: file.to_string(), + detail: format!("malformed SHA256SUMS digest {hex_digest:?}"), + }); + } + let digest = hex_digest.to_ascii_lowercase(); + // Two entries for the same file that disagree means the sums file + // itself is unreliable — refuse rather than pick one. + if let Some(prev) = &found { + if *prev != digest { + return Err(UpdateError::ChecksumMismatch { + asset: file.to_string(), + detail: "conflicting duplicate entries in SHA256SUMS".to_string(), + }); + } + } + found = Some(digest); + } + found.ok_or_else(|| UpdateError::ChecksumMismatch { + asset: file.to_string(), + detail: "no entry in SHA256SUMS".to_string(), + }) +} + +/// The redirect policy for every update-related request that follows +/// redirects: on the default (real GitHub) endpoints any non-HTTPS hop is +/// refused — GitHub bounces to CDNs, and one `http://` hop would let a +/// MITM tamper with whichever leg it captures (the SHA256SUMS leg is the +/// integrity root, so it needs this exactly as much as the archive leg). +/// Overridden bases (wiremock fixtures, mirrors) are plain-`http` loopback +/// by design, so there the policy is only hop-count-limited. +pub(crate) fn follow_redirect_policy( + endpoints: &UpdateEndpoints, +) -> reqwest::redirect::Policy { + if endpoints.is_default() { + reqwest::redirect::Policy::custom(|attempt| { + if attempt.previous().len() > 10 { + attempt.error("too many redirects") + } else if attempt.url().scheme() != "https" { + attempt.error("refusing insecure (non-HTTPS) redirect for release metadata") + } else { + attempt.follow() + } + }) + } else { + reqwest::redirect::Policy::limited(10) + } +} + +/// Build the reqwest client used for metadata fetches. Credential-free by +/// construction (mirrors `plain_client`: only a User-Agent — the Socket +/// bearer must never reach GitHub or a mirror). +fn metadata_client( + timeouts: &UpdateTimeouts, + redirects: reqwest::redirect::Policy, +) -> Result { + reqwest::Client::builder() + .user_agent(crate::constants::USER_AGENT) + .connect_timeout(timeouts.connect) + .timeout(timeouts.metadata) + .redirect(redirects) + .build() + .map_err(|e| UpdateError::Network(format!("failed to build HTTP client: {e}"))) +} + +/// Resolve the latest released version: redirect probe first, API fallback +/// second (see module docs). +pub async fn fetch_latest_version( + endpoints: &UpdateEndpoints, + timeouts: &UpdateTimeouts, +) -> Result { + let probe_err = match probe_latest_redirect(endpoints, timeouts).await { + Ok(version) => return Ok(version), + Err(e) => e, + }; + match fetch_latest_via_api(endpoints, timeouts).await { + Ok(version) => Ok(version), + Err(api_err) => Err(UpdateError::CheckFailed(format!( + "could not determine the latest release: {probe_err}; API fallback: {api_err}" + ))), + } +} + +async fn probe_latest_redirect( + endpoints: &UpdateEndpoints, + timeouts: &UpdateTimeouts, +) -> Result { + let client = metadata_client(timeouts, reqwest::redirect::Policy::none())?; + let url = endpoints.latest_probe_url(); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| UpdateError::Network(format!("GET {url}: {e}")))?; + if !resp.status().is_redirection() { + return Err(UpdateError::CheckFailed(format!( + "GET {url} returned {} (expected a redirect to the latest tag)", + resp.status() + ))); + } + let location = resp + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + UpdateError::CheckFailed(format!("GET {url}: redirect without a Location header")) + })?; + version_from_location(location) +} + +async fn fetch_latest_via_api( + endpoints: &UpdateEndpoints, + timeouts: &UpdateTimeouts, +) -> Result { + let client = metadata_client(timeouts, follow_redirect_policy(endpoints))?; + let url = endpoints.latest_api_url(); + let resp = client + .get(&url) + .header(reqwest::header::ACCEPT, "application/vnd.github+json") + .send() + .await + .map_err(|e| UpdateError::Network(format!("GET {url}: {e}")))?; + let status = resp.status(); + if !status.is_success() { + return Err(UpdateError::CheckFailed(format!( + "GET {url} returned {status}" + ))); + } + let body = read_capped(resp, METADATA_CAP_BYTES, "release metadata") + .await + .map_err(UpdateError::Network)?; + let json: serde_json::Value = serde_json::from_slice(&body) + .map_err(|e| UpdateError::CheckFailed(format!("GET {url}: invalid JSON: {e}")))?; + let tag = json + .get("tag_name") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + UpdateError::CheckFailed(format!("GET {url}: response has no tag_name")) + })?; + parse_release_tag(tag) +} + +/// Fetch and parse the `SHA256SUMS` published with `version`, returning the +/// digest recorded for `file`. +pub async fn fetch_sha256sums_entry( + endpoints: &UpdateEndpoints, + timeouts: &UpdateTimeouts, + version: &semver::Version, + file: &str, +) -> Result { + let client = metadata_client(timeouts, follow_redirect_policy(endpoints))?; + let url = endpoints.download_url(version, "SHA256SUMS"); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| UpdateError::Network(format!("GET {url}: {e}")))?; + let status = resp.status(); + if status == reqwest::StatusCode::NOT_FOUND { + return Err(UpdateError::CheckFailed(format!( + "release v{version} publishes no SHA256SUMS ({url} is 404) — cannot verify a download" + ))); + } + if !status.is_success() { + return Err(UpdateError::Network(format!("GET {url} returned {status}"))); + } + let body = read_capped(resp, METADATA_CAP_BYTES, "SHA256SUMS") + .await + .map_err(UpdateError::Network)?; + let text = String::from_utf8_lossy(&body); + sha256sums_entry(&text, file) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---------- version parsing ---------- + + #[test] + fn tag_parse_strips_v_prefix_and_whitespace() { + for raw in ["v3.4.0", "3.4.0", " v3.4.0 ", "v3.4.0\r\n"] { + assert_eq!( + parse_release_tag(raw).unwrap(), + semver::Version::new(3, 4, 0), + "{raw:?}" + ); + } + } + + #[test] + fn tag_parse_rejects_garbage_without_panicking() { + for raw in ["", "v", "not-a-version", "3.4", "v3.4.0.1"] { + assert!(parse_release_tag(raw).is_err(), "{raw:?} should not parse"); + } + } + + #[test] + fn prerelease_orders_below_release() { + // A 4.0.0-rc.1 dev build must treat released 4.0.0 as newer, and a + // 4.0.0 install must NOT be offered 4.1.0-rc.1 as an "update" if a + // prerelease tag ever leaks into releases/latest. + let rc = parse_release_tag("v4.0.0-rc.1").unwrap(); + let ga = parse_release_tag("v4.0.0").unwrap(); + assert!(rc < ga); + } + + #[test] + fn build_metadata_does_not_affect_update_decisions() { + // semver::Version's Ord tiebreaks on build metadata, so the update + // decision must go through is_newer (cmp_precedence), where + // 3.4.0+build.5 is NOT an update over 3.4.0. + let plain = parse_release_tag("v3.4.0").unwrap(); + let meta = parse_release_tag("v3.4.0+build.5").unwrap(); + assert!(!is_newer(&meta, &plain)); + assert!(!is_newer(&plain, &meta)); + let newer = parse_release_tag("v3.4.1").unwrap(); + assert!(is_newer(&newer, &plain)); + assert!(!is_newer(&plain, &newer)); + } + + #[test] + fn prerelease_is_newer_than_nothing_older() { + // A 4.0.0-rc.1 dev build sees released 4.0.0 as an update, and a + // 4.0.0 install never sees 4.0.0-rc.1 as one. + let rc = parse_release_tag("v4.0.0-rc.1").unwrap(); + let ga = parse_release_tag("v4.0.0").unwrap(); + assert!(is_newer(&ga, &rc)); + assert!(!is_newer(&rc, &ga)); + } + + #[test] + fn current_version_matches_crate() { + assert_eq!(current_version().to_string(), env!("CARGO_PKG_VERSION")); + } + + // ---------- Location parsing ---------- + + #[test] + fn location_parse_accepts_absolute_and_relative() { + for loc in [ + "https://github.com/SocketDev/socket-patch/releases/tag/v3.4.0", + "/SocketDev/socket-patch/releases/tag/v3.4.0", + "https://github.com/SocketDev/socket-patch/releases/tag/v3.4.0?ref=probe", + ] { + assert_eq!( + version_from_location(loc).unwrap(), + semver::Version::new(3, 4, 0), + "{loc}" + ); + } + } + + #[test] + fn location_parse_rejects_shapes_without_tag_segment() { + for loc in [ + "https://github.com/SocketDev/socket-patch/releases", + "https://github.com/login?return_to=…", + "", + ] { + assert!(version_from_location(loc).is_err(), "{loc:?}"); + } + } + + // ---------- asset mapping ---------- + + #[test] + fn asset_names_match_release_workflow_matrix() { + // The exact 14 targets release.yml builds, with their archive kinds. + let expected = [ + ("aarch64-apple-darwin", "tar.gz"), + ("x86_64-apple-darwin", "tar.gz"), + ("x86_64-unknown-linux-gnu", "tar.gz"), + ("x86_64-unknown-linux-musl", "tar.gz"), + ("aarch64-unknown-linux-gnu", "tar.gz"), + ("aarch64-unknown-linux-musl", "tar.gz"), + ("x86_64-pc-windows-msvc", "zip"), + ("i686-pc-windows-msvc", "zip"), + ("aarch64-pc-windows-msvc", "zip"), + ("aarch64-linux-android", "tar.gz"), + ("arm-unknown-linux-gnueabihf", "tar.gz"), + ("arm-unknown-linux-musleabihf", "tar.gz"), + ("i686-unknown-linux-gnu", "tar.gz"), + ("i686-unknown-linux-musl", "tar.gz"), + ]; + for (triple, kind) in expected { + assert_eq!( + asset_name_for_target(triple), + format!("socket-patch-{triple}.{kind}") + ); + } + } + + // ---------- SHA256SUMS parsing ---------- + + const DIGEST_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const DIGEST_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + #[test] + fn sums_two_space_format_parses() { + let sums = format!("{DIGEST_A} socket-patch-x.tar.gz\n{DIGEST_B} other.zip\n"); + assert_eq!( + sha256sums_entry(&sums, "socket-patch-x.tar.gz").unwrap(), + DIGEST_A + ); + } + + #[test] + fn sums_binary_mode_star_prefix_tolerated() { + let sums = format!("{DIGEST_A} *socket-patch-x.tar.gz\n"); + assert_eq!( + sha256sums_entry(&sums, "socket-patch-x.tar.gz").unwrap(), + DIGEST_A + ); + } + + #[test] + fn sums_crlf_endings_tolerated() { + let sums = format!("{DIGEST_A} socket-patch-x.tar.gz\r\n"); + assert_eq!( + sha256sums_entry(&sums, "socket-patch-x.tar.gz").unwrap(), + DIGEST_A + ); + } + + #[test] + fn sums_digest_compare_is_case_insensitive() { + let sums = format!( + "{} socket-patch-x.tar.gz\n", + DIGEST_A.to_ascii_uppercase() + ); + assert_eq!( + sha256sums_entry(&sums, "socket-patch-x.tar.gz").unwrap(), + DIGEST_A, + "digests must normalize to lowercase for comparison" + ); + } + + #[test] + fn sums_missing_entry_is_specific_error() { + let sums = format!("{DIGEST_A} other.tar.gz\n"); + let err = sha256sums_entry(&sums, "socket-patch-x.tar.gz").unwrap_err(); + assert!(err.to_string().contains("no entry"), "{err}"); + } + + #[test] + fn sums_empty_file_is_error() { + assert!(sha256sums_entry("", "socket-patch-x.tar.gz").is_err()); + } + + #[test] + fn sums_conflicting_duplicates_refused() { + let sums = format!( + "{DIGEST_A} socket-patch-x.tar.gz\n{DIGEST_B} socket-patch-x.tar.gz\n" + ); + let err = sha256sums_entry(&sums, "socket-patch-x.tar.gz").unwrap_err(); + assert!(err.to_string().contains("conflicting"), "{err}"); + // Agreeing duplicates are harmless. + let sums = format!( + "{DIGEST_A} socket-patch-x.tar.gz\n{DIGEST_A} socket-patch-x.tar.gz\n" + ); + assert_eq!( + sha256sums_entry(&sums, "socket-patch-x.tar.gz").unwrap(), + DIGEST_A + ); + } + + #[test] + fn sums_malformed_digest_refused() { + let sums = "zznotahexdigest socket-patch-x.tar.gz\n"; + assert!(sha256sums_entry(sums, "socket-patch-x.tar.gz").is_err()); + let sums = format!("{} socket-patch-x.tar.gz\n", &DIGEST_A[..40]); + assert!(sha256sums_entry(&sums, "socket-patch-x.tar.gz").is_err()); + } + + #[test] + fn sums_unparseable_lines_skipped_but_absence_still_errs() { + let sums = format!("# comment line\n\n{DIGEST_A} present.tar.gz\ngarbage\n"); + assert_eq!(sha256sums_entry(&sums, "present.tar.gz").unwrap(), DIGEST_A); + assert!(sha256sums_entry(&sums, "absent.tar.gz").is_err()); + } + + // ---------- endpoints ---------- + + #[test] + fn default_base_urls_are_https_github() { + // The default constants are the security boundary: overriding them + // (SOCKET_UPDATE_BASE_URL) relaxes the version self-check, so the + // defaults themselves must always be the real HTTPS GitHub hosts. + assert_eq!(DEFAULT_UPDATE_BASE_URL, "https://github.com"); + assert_eq!(DEFAULT_UPDATE_API_BASE_URL, "https://api.github.com"); + } + + #[test] + fn download_url_shape_matches_install_sh() { + let endpoints = UpdateEndpoints { + web_base: DEFAULT_UPDATE_BASE_URL.to_string(), + api_base: DEFAULT_UPDATE_API_BASE_URL.to_string(), + is_default: true, + }; + assert_eq!( + endpoints.download_url(&semver::Version::new(3, 4, 0), "SHA256SUMS"), + "https://github.com/SocketDev/socket-patch/releases/download/v3.4.0/SHA256SUMS" + ); + } +} diff --git a/crates/socket-patch-core/src/update/state.rs b/crates/socket-patch-core/src/update/state.rs new file mode 100644 index 0000000..087a40f --- /dev/null +++ b/crates/socket-patch-core/src/update/state.rs @@ -0,0 +1,253 @@ +//! Persistent update-check state, shared by the passive notifier and +//! `--update` itself (an explicit update refreshes `latest_seen` so the +//! notifier never nags about a version the user just installed). +//! +//! This is disposable *cache* state, not configuration: it lives under the +//! per-user cache root (the same root the gem/composer launchers use for +//! their binary cache) and every read tolerates absence, corruption, and +//! clock skew by degrading to "never checked". Nothing in here may ever +//! fail a command — callers treat all errors as "skip the check". + +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::utils::fs::atomic_write_bytes; + +/// Checks are due at most once per this interval. +pub const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); + +/// A `last_check_at` this far in the future is clock skew, not a valid +/// suppression: treat it as never-checked so a wrong clock cannot wedge +/// the notifier until the bogus timestamp passes. +const FORWARD_SKEW_SLACK: Duration = Duration::from_secs(5 * 60); + +/// On-disk schema (camelCase JSON, unix seconds). Unknown fields are +/// ignored and missing fields default, so both directions of version drift +/// stay non-fatal. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct UpdateCheckState { + pub schema_version: u32, + /// When a check last *ran* (success or failure) — rate-limits attempts. + pub last_check_at: Option, + /// Newest release version observed by any check or explicit update. + pub latest_seen: Option, + /// When a notice was last printed — rate-limits the nag itself. + pub last_notified_at: Option, +} + +pub const STATE_SCHEMA_VERSION: u32 = 1; + +/// Seconds since the unix epoch, saturating at 0 on a pre-1970 clock. +pub fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Directory holding the state file (and the update lock). Resolution: +/// `SOCKET_UPDATE_STATE_DIR` (internal override so tests never touch the +/// real per-user dir) → `$XDG_CACHE_HOME` → `~/.cache` (all Unix flavors, +/// macOS included — deliberately the launchers' shared cache root, not +/// `~/Library/Caches`) → `%LOCALAPPDATA%` → `%USERPROFILE%\AppData\Local` +/// (Windows). `None` = no resolvable base; callers silently skip. +pub fn state_dir() -> Option { + fn env_dir(name: &str) -> Option { + std::env::var(name) + .ok() + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + } + if let Some(dir) = env_dir("SOCKET_UPDATE_STATE_DIR") { + return Some(dir); + } + let base = if cfg!(windows) { + env_dir("LOCALAPPDATA").or_else(|| { + env_dir("USERPROFILE").map(|p| p.join("AppData").join("Local")) + }) + } else { + env_dir("XDG_CACHE_HOME").or_else(|| env_dir("HOME").map(|h| h.join(".cache"))) + }; + base.map(|b| b.join("socket-patch")) +} + +fn state_file_path() -> Option { + state_dir().map(|d| d.join("update-check.json")) +} + +/// Load the state, degrading to `Default` (never-checked) on any missing +/// dir, unreadable file, or unparseable content. Cache, not config: no +/// warning is worth printing. +pub fn load_state() -> UpdateCheckState { + let Some(path) = state_file_path() else { + return UpdateCheckState::default(); + }; + let Ok(bytes) = std::fs::read(&path) else { + return UpdateCheckState::default(); + }; + serde_json::from_slice(&bytes).unwrap_or_default() +} + +/// Persist the state atomically (stage + fsync + rename). Errors bubble so +/// callers can debug-log them, but callers must treat them as non-fatal. +pub async fn save_state(state: &UpdateCheckState) -> std::io::Result<()> { + let Some(path) = state_file_path() else { + return Ok(()); // nowhere to persist — same as the load side's silence + }; + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let mut state = state.clone(); + state.schema_version = STATE_SCHEMA_VERSION; + let bytes = serde_json::to_vec_pretty(&state).map_err(std::io::Error::other)?; + atomic_write_bytes(&path, &bytes).await +} + +/// Whether a fresh check is due at `now`, given the recorded +/// `last_check_at`. Pure so the skew rules are table-testable. +pub fn check_is_due(last_check_at: Option, now: u64) -> bool { + is_due(last_check_at, now) +} + +/// Whether printing a notice is due (same cadence + skew rules as checks). +pub fn notice_is_due(last_notified_at: Option, now: u64) -> bool { + is_due(last_notified_at, now) +} + +fn is_due(last: Option, now: u64) -> bool { + let Some(last) = last else { + return true; + }; + // A timestamp more than the slack into the future is clock skew: + // due now, so a bad clock self-heals instead of wedging the check. + if last > now + FORWARD_SKEW_SLACK.as_secs() { + return true; + } + now.saturating_sub(last) >= CHECK_INTERVAL.as_secs() +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + const NOW: u64 = 1_753_142_400; + + #[test] + fn due_when_never_checked() { + assert!(check_is_due(None, NOW)); + } + + #[test] + fn fresh_check_suppresses_until_interval_elapses() { + assert!(!check_is_due(Some(NOW - 60 * 60), NOW), "1h ago: fresh"); + assert!( + !check_is_due(Some(NOW - CHECK_INTERVAL.as_secs() + 1), NOW), + "one second inside the interval: still fresh" + ); + assert!( + check_is_due(Some(NOW - CHECK_INTERVAL.as_secs()), NOW), + "exactly the interval: due" + ); + assert!(check_is_due(Some(NOW - 25 * 60 * 60), NOW), "25h ago: due"); + } + + #[test] + fn future_timestamp_beyond_slack_means_due() { + // A wrong clock (or a state file written by a machine with one) + // must never suppress checks until the bogus timestamp passes. + assert!(check_is_due(Some(NOW + 48 * 60 * 60), NOW)); + // Small forward skew (below the slack) is normal cross-process + // drift and counts as fresh. + assert!(!check_is_due(Some(NOW + 60), NOW)); + } + + #[test] + fn state_round_trips_through_json() { + let state = UpdateCheckState { + schema_version: STATE_SCHEMA_VERSION, + last_check_at: Some(NOW), + latest_seen: Some("3.4.0".to_string()), + last_notified_at: Some(NOW - 10), + }; + let json = serde_json::to_string(&state).unwrap(); + // The wire format is camelCase — pinned because external tooling + // (and future schema migrations) key off these exact names. + assert!(json.contains("\"lastCheckAt\""), "{json}"); + assert!(json.contains("\"latestSeen\""), "{json}"); + let back: UpdateCheckState = serde_json::from_str(&json).unwrap(); + assert_eq!(back, state); + } + + #[test] + fn unknown_fields_and_missing_fields_tolerated() { + let forward: UpdateCheckState = + serde_json::from_str(r#"{"schemaVersion":9,"futureField":true,"lastCheckAt":5}"#) + .unwrap(); + assert_eq!(forward.last_check_at, Some(5)); + let sparse: UpdateCheckState = serde_json::from_str("{}").unwrap(); + assert_eq!(sparse, UpdateCheckState::default()); + } + + #[test] + fn corrupt_state_loads_as_default() { + // load_state's parse path: any non-JSON bytes degrade to Default. + let garbage: Result = serde_json::from_slice(b"\x00garbage{{{"); + assert!(garbage.is_err()); + // (The full read path is exercised e2e; here we pin that the + // fallback the code uses — unwrap_or_default — yields never-checked.) + assert!(check_is_due(UpdateCheckState::default().last_check_at, NOW)); + } + + #[test] + #[serial(update_state_dir_env)] + fn state_dir_honors_override_and_empty_env_falls_through() { + // Env-mutating test: keep it self-contained and restore. + let prev = std::env::var_os("SOCKET_UPDATE_STATE_DIR"); + std::env::set_var("SOCKET_UPDATE_STATE_DIR", "/tmp/socket-update-test"); + assert_eq!( + state_dir(), + Some(PathBuf::from("/tmp/socket-update-test")) + ); + // Empty value means unset (env_non_empty convention) — falls through + // to the platform default rather than yielding "". + std::env::set_var("SOCKET_UPDATE_STATE_DIR", ""); + assert_ne!(state_dir(), Some(PathBuf::from(""))); + match prev { + Some(v) => std::env::set_var("SOCKET_UPDATE_STATE_DIR", v), + None => std::env::remove_var("SOCKET_UPDATE_STATE_DIR"), + } + } + + #[tokio::test] + #[serial(update_state_dir_env)] + async fn save_state_writes_atomically_with_no_stage_droppings() { + let tmp = tempfile::tempdir().unwrap(); + let prev = std::env::var_os("SOCKET_UPDATE_STATE_DIR"); + std::env::set_var("SOCKET_UPDATE_STATE_DIR", tmp.path()); + let state = UpdateCheckState { + last_check_at: Some(NOW), + latest_seen: Some("9.9.9".into()), + ..Default::default() + }; + save_state(&state).await.unwrap(); + let loaded = load_state(); + assert_eq!(loaded.latest_seen.as_deref(), Some("9.9.9")); + assert_eq!(loaded.schema_version, STATE_SCHEMA_VERSION); + // Atomic writer leaves no .socket-stage-* siblings behind. + let leftovers: Vec<_> = std::fs::read_dir(tmp.path()) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n != "update-check.json") + .collect(); + assert!(leftovers.is_empty(), "unexpected files: {leftovers:?}"); + match prev { + Some(v) => std::env::set_var("SOCKET_UPDATE_STATE_DIR", v), + None => std::env::remove_var("SOCKET_UPDATE_STATE_DIR"), + } + } +} diff --git a/crates/socket-patch-core/src/update/swap.rs b/crates/socket-patch-core/src/update/swap.rs new file mode 100644 index 0000000..b732cec --- /dev/null +++ b/crates/socket-patch-core/src/update/swap.rs @@ -0,0 +1,264 @@ +//! The swap: atomically replace the installed binary with a staged one. +//! +//! Unix: a plain `rename(2)` over a running executable is legal (the old +//! inode lives on until its last mmap goes away), so the swap is our own +//! mode-preserving rename — plus a refusal for setuid/setgid targets, +//! which an unprivileged rename would silently strip (see the +//! chown-clears-setuid ordering note in `patch/apply.rs`). +//! +//! Windows: a running `.exe` cannot be overwritten but can be *renamed*; +//! the `self-replace` crate owns that dance (rename the running exe aside, +//! move the new one in, schedule the old file's removal). +//! +//! Concurrency: one advisory `flock` on `/update.lock` makes +//! concurrent `--update` runs single-flight **per environment**. The lock +//! file lives in the per-user state dir, never in the install dir (writing +//! locks into `/usr/local/bin` would demand privileges the check itself +//! doesn't need), and `flock` semantics release it when the process dies — +//! there is no stale-lock failure mode. Two updaters whose state dirs +//! diverge (different `$HOME`s targeting one shared install) can race, but +//! every path to the destination is a whole-file rename and the stage +//! sweep is age-gated, so the worst case is duplicated work with a +//! complete binary winning — never a torn one. + +use std::path::{Path, PathBuf}; + +use fs2::FileExt; + +use super::UpdateError; + +/// Guard holding the exclusive update lock; dropping releases it. +pub struct UpdateLock { + _file: std::fs::File, +} + +/// Take the single-flight update lock, or fail with +/// [`UpdateError::InProgress`] if another update holds it. +pub fn acquire_update_lock() -> Result, UpdateError> { + let Some(dir) = super::state::state_dir() else { + // No resolvable per-user dir: proceed unlocked rather than + // refusing updates on exotic environments. The swap itself is + // still a whole-file rename, so the race is benign duplicated + // work, not a torn binary. + return Ok(None); + }; + std::fs::create_dir_all(&dir) + .map_err(|e| UpdateError::SwapFailed(format!("cannot create {}: {e}", dir.display())))?; + let path = dir.join("update.lock"); + let file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&path) + .map_err(|e| UpdateError::SwapFailed(format!("cannot open {}: {e}", path.display())))?; + match file.try_lock_exclusive() { + Ok(()) => Ok(Some(UpdateLock { _file: file })), + Err(_) => Err(UpdateError::InProgress), + } +} + +/// Resolve the path the swap must replace: the canonicalized current +/// executable. Canonicalizing matters twice — channel detection must see +/// the *real* location (macOS `current_exe` can return the symlink used to +/// exec), and the swap must replace the real file rather than turning a +/// symlink into a regular binary. +pub fn resolve_install_path() -> Result { + let exe = std::env::current_exe() + .map_err(|e| UpdateError::SwapFailed(format!("cannot determine current executable: {e}")))?; + std::fs::canonicalize(&exe).map_err(|e| { + UpdateError::SwapFailed(format!("cannot canonicalize {}: {e}", exe.display())) + }) +} + +/// Atomically replace `dest` with the staged binary at `staged`. +/// +/// The caller guarantees `staged` sits in `dest`'s directory (same +/// filesystem ⇒ atomic rename) and has already passed its sanity exec. +/// On failure the stage file is removed; `dest` is never touched except by +/// the final atomic step. +pub fn swap_binary(staged: &Path, dest: &Path) -> Result<(), UpdateError> { + let result = swap_binary_inner(staged, dest); + if result.is_err() { + let _ = std::fs::remove_file(staged); + } + result +} + +/// Linux file capabilities (`setcap`) live in the `security.capability` +/// xattr — the same class of privilege grant as setuid: a rename replaces +/// the inode and an unprivileged updater cannot restore them, so a target +/// carrying them is refused rather than silently stripped. +#[cfg(target_os = "linux")] +fn has_file_capabilities(path: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + let Ok(cpath) = std::ffi::CString::new(path.as_os_str().as_bytes()) else { + return false; + }; + let ret = unsafe { + libc::getxattr( + cpath.as_ptr(), + c"security.capability".as_ptr(), + std::ptr::null_mut(), + 0, + ) + }; + ret > 0 +} + +#[cfg(all(unix, not(target_os = "linux")))] +fn has_file_capabilities(_path: &Path) -> bool { + false +} + +#[cfg(unix)] +fn swap_binary_inner(staged: &Path, dest: &Path) -> Result<(), UpdateError> { + use std::os::unix::fs::PermissionsExt; + + let dest_meta = std::fs::metadata(dest).map_err(|e| { + UpdateError::SwapFailed(format!("cannot stat {}: {e}", dest.display())) + })?; + let mode = dest_meta.permissions().mode(); + if mode & 0o6000 != 0 { + return Err(UpdateError::SwapFailed(format!( + "refusing to replace {}: it carries setuid/setgid bits an update cannot restore; \ + reinstall manually", + dest.display() + ))); + } + if has_file_capabilities(dest) { + return Err(UpdateError::SwapFailed(format!( + "refusing to replace {}: it carries file capabilities (setcap) an update cannot \ + restore; reinstall manually and re-apply setcap", + dest.display() + ))); + } + // Carry the destination's exact mode onto the staged inode before the + // rename so a 0555 install never appears 0755, even briefly. + std::fs::set_permissions(staged, std::fs::Permissions::from_mode(mode)).map_err(|e| { + UpdateError::SwapFailed(format!("cannot set mode on staged binary: {e}")) + })?; + std::fs::rename(staged, dest).map_err(|e| { + if e.kind() == std::io::ErrorKind::PermissionDenied { + UpdateError::PermissionDenied { + path: dest.parent().unwrap_or(dest).to_path_buf(), + } + } else { + UpdateError::SwapFailed(format!("rename onto {} failed: {e}", dest.display())) + } + })?; + // The rename only updated the directory entry; fsync the directory so + // the swap survives a crash. Best-effort (same posture as + // atomic_write_bytes). + if let Some(parent) = dest.parent() { + if let Ok(dir) = std::fs::File::open(parent) { + let _ = dir.sync_all(); + } + } + Ok(()) +} + +#[cfg(windows)] +fn swap_binary_inner(staged: &Path, dest: &Path) -> Result<(), UpdateError> { + // `self_replace` operates on the *current executable*; `dest` IS the + // canonicalized current exe (resolve_install_path), so delegate the + // rename dance to it. It renames the running exe aside and moves the + // new file in; the parked old exe is cleaned up by the OS/helper, and + // our start-of-run sweep removes any strays. + let _ = dest; // dest == current_exe by contract; self_replace re-derives it + self_replace::self_replace(staged).map_err(|e| { + if e.kind() == std::io::ErrorKind::PermissionDenied { + UpdateError::PermissionDenied { + path: dest.parent().unwrap_or(dest).to_path_buf(), + } + } else { + UpdateError::SwapFailed(format!("self-replace failed: {e}")) + } + })?; + let _ = std::fs::remove_file(staged); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[cfg(unix)] + #[test] + fn swap_preserves_destination_mode_and_replaces_content() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let dest = tmp.path().join("socket-patch"); + std::fs::write(&dest, b"old").unwrap(); + std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o555)).unwrap(); + let staged = tmp.path().join(".socket-patch.stage-test"); + std::fs::write(&staged, b"new").unwrap(); + std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).unwrap(); + + swap_binary(&staged, &dest).unwrap(); + + assert_eq!(std::fs::read(&dest).unwrap(), b"new"); + let mode = std::fs::metadata(&dest).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o555, "destination mode must be preserved"); + assert!(!staged.exists(), "stage must be consumed by the rename"); + } + + #[cfg(unix)] + #[test] + fn swap_refuses_setuid_target_and_removes_stage() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let dest = tmp.path().join("socket-patch"); + std::fs::write(&dest, b"old").unwrap(); + std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o4755)).unwrap(); + let staged = tmp.path().join(".socket-patch.stage-test"); + std::fs::write(&staged, b"new").unwrap(); + + let err = swap_binary(&staged, &dest).unwrap_err(); + assert!(err.to_string().contains("setuid"), "{err}"); + assert_eq!( + std::fs::read(&dest).unwrap(), + b"old", + "refusal must leave the target untouched" + ); + assert!(!staged.exists(), "failure path must clean the stage"); + } + + #[cfg(unix)] + #[test] + fn swap_missing_dest_is_error_not_create() { + // The swap replaces an existing install; a vanished destination is + // a bug upstream, not something to silently create. + let tmp = tempfile::tempdir().unwrap(); + let staged = tmp.path().join(".socket-patch.stage-test"); + std::fs::write(&staged, b"new").unwrap(); + let err = swap_binary(&staged, &tmp.path().join("gone")).unwrap_err(); + assert!(err.to_string().contains("stat"), "{err}"); + } + + #[test] + #[serial(update_state_dir_env)] + fn update_lock_is_exclusive_and_released_on_drop() { + let tmp = tempfile::tempdir().unwrap(); + let prev = std::env::var_os("SOCKET_UPDATE_STATE_DIR"); + std::env::set_var("SOCKET_UPDATE_STATE_DIR", tmp.path()); + + let first = acquire_update_lock().unwrap(); + assert!(first.is_some(), "state dir resolvable ⇒ a real lock"); + let second = acquire_update_lock(); + assert!( + matches!(second, Err(UpdateError::InProgress)), + "second concurrent acquire must report update-in-progress" + ); + drop(first); + assert!( + acquire_update_lock().unwrap().is_some(), + "lock must be reacquirable after release" + ); + + match prev { + Some(v) => std::env::set_var("SOCKET_UPDATE_STATE_DIR", v), + None => std::env::remove_var("SOCKET_UPDATE_STATE_DIR"), + } + } +} diff --git a/crates/socket-patch-core/src/utils/http.rs b/crates/socket-patch-core/src/utils/http.rs new file mode 100644 index 0000000..269f1c8 --- /dev/null +++ b/crates/socket-patch-core/src/utils/http.rs @@ -0,0 +1,93 @@ +//! Small shared HTTP primitives. + +/// Stream a response body into memory with a hard byte cap, rejecting both +/// an over-large declared `Content-Length` and an actual stream that +/// exceeds the cap mid-flight. `what` names the payload in error messages +/// ("vendor package", "release archive", …). +/// +/// Hoisted from `api/client.rs` so the self-update downloader shares the +/// exact cap semantics the vendor/artifact fetches already have. +pub(crate) async fn read_capped( + mut resp: reqwest::Response, + max: u64, + what: &str, +) -> Result, String> { + if let Some(len) = resp.content_length() { + if len > max { + return Err(format!( + "{what} too large: declared {len} bytes > {max} cap" + )); + } + } + let mut bytes: Vec = Vec::new(); + while let Some(chunk) = resp + .chunk() + .await + .map_err(|e| format!("error reading {what} body: {e}"))? + { + if bytes.len() as u64 + chunk.len() as u64 > max { + return Err(format!("{what} exceeded {max}-byte cap mid-stream")); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + async fn get(server: &MockServer, route: &str) -> reqwest::Response { + reqwest::Client::new() + .get(format!("{}{route}", server.uri())) + .send() + .await + .unwrap() + } + + #[tokio::test] + async fn declared_content_length_over_cap_is_refused() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/big")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0u8; 64])) + .mount(&server) + .await; + let resp = get(&server, "/big").await; + let err = read_capped(resp, 16, "test payload").await.unwrap_err(); + assert!(err.contains("too large"), "{err}"); + assert!(err.contains("test payload"), "{err}"); + } + + #[tokio::test] + async fn body_within_cap_reads_fully() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/ok")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"hello".to_vec())) + .mount(&server) + .await; + let resp = get(&server, "/ok").await; + assert_eq!( + read_capped(resp, 16, "test payload").await.unwrap(), + b"hello" + ); + } + + #[tokio::test] + async fn exact_cap_boundary_is_allowed() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/edge")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![7u8; 16])) + .mount(&server) + .await; + let resp = get(&server, "/edge").await; + assert_eq!( + read_capped(resp, 16, "test payload").await.unwrap().len(), + 16 + ); + } +} diff --git a/crates/socket-patch-core/src/utils/mod.rs b/crates/socket-patch-core/src/utils/mod.rs index 041be6a..fc28d23 100644 --- a/crates/socket-patch-core/src/utils/mod.rs +++ b/crates/socket-patch-core/src/utils/mod.rs @@ -1,6 +1,7 @@ pub mod cleanup_blobs; pub mod env_compat; pub mod fs; +pub(crate) mod http; pub mod fuzzy_match; pub mod process; pub mod purl; diff --git a/crates/socket-patch-core/tests/crawler_npm_e2e.rs b/crates/socket-patch-core/tests/crawler_npm_e2e.rs index abff1bd..dea364d 100644 --- a/crates/socket-patch-core/tests/crawler_npm_e2e.rs +++ b/crates/socket-patch-core/tests/crawler_npm_e2e.rs @@ -1169,11 +1169,21 @@ async fn read_package_json_rejects_fifo_without_hanging() { let fifo_pkg = nm.join("fifo-pkg"); tokio::fs::create_dir_all(&fifo_pkg).await.unwrap(); let fifo = fifo_pkg.join("package.json"); - let status = std::process::Command::new("mkfifo") - .arg(&fifo) - .status() - .expect("mkfifo must be runnable"); - assert!(status.success(), "mkfifo failed"); + // mkfifo(2) directly, not the /usr/bin/mkfifo binary: spawning a child + // here made the test flake under heavy parallel load (fork/exec + // starvation panicked the fixture setup before the code under test + // ever ran), and the syscall needs no process at all. + let c_path = { + use std::os::unix::ffi::OsStrExt; + std::ffi::CString::new(fifo.as_os_str().as_bytes()).expect("fifo path has no NUL") + }; + let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }; + assert_eq!( + rc, + 0, + "mkfifo(2) failed: {}", + std::io::Error::last_os_error() + ); // A sibling real package proves the tree stays crawlable around the FIFO. stage_npm_pkg(&nm, "real-pkg", "1.0.0").await; diff --git a/scripts/install.sh b/scripts/install.sh index 26a695e..5ea1005 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -29,11 +29,17 @@ case "$OS" in detect_libc() { if ldd --version 2>&1 | grep -qi musl; then echo "musl" - elif [ -e /lib/ld-musl-*.so.1 ] 2>/dev/null; then - echo "musl" - else - echo "gnu" + return fi + # `[ -e ]` cannot take a glob (SC2144): with several matches it is a + # syntax error, with none it tests the literal pattern. Loop instead. + for loader in /lib/ld-musl-*.so.1; do + if [ -e "$loader" ]; then + echo "musl" + return + fi + done + echo "gnu" } LIBC="$(detect_libc)" case "$ARCH" in diff --git a/tests/docker/Dockerfile.npm b/tests/docker/Dockerfile.npm index f3c0a5e..ef2f817 100644 --- a/tests/docker/Dockerfile.npm +++ b/tests/docker/Dockerfile.npm @@ -32,6 +32,14 @@ RUN corepack enable \ && corepack prepare pnpm@9.15.0 --activate \ && corepack prepare yarn@1.22.22 --activate +# Corepack defaults to fetching the LATEST package manager at run time for +# projects with no `packageManager` field (COREPACK_DEFAULT_TO_LATEST=1), +# silently overriding the activations above — which is how test runs +# started downloading pnpm 11.x (requires Node >= 22) onto this Node 20 +# image and crashing with ERR_UNKNOWN_BUILTIN_MODULE(node:sqlite). Pin +# runtime resolution to the activated versions. +ENV COREPACK_DEFAULT_TO_LATEST=0 + # Cache yarn berry 4.x in corepack WITHOUT activating it: the global `yarn` # stays 1.22.22 (classic), and berry fixtures opt in per-project via # package.json `"packageManager": "yarn@4.12.0"`, which the corepack shim