diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3f8e5f7..c028494 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -69,7 +69,7 @@ jobs: smoke_directory="$RUNNER_TEMP/aqua-smoke" mkdir -p "$smoke_directory" AQUA_ROOT_DIR="$native_root" aqua install - expected_version=$(node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync("distribution/distribution-contract.json", "utf8")).product.currentSourceVersion)') + expected_version=$(node -e 'process.stdout.write(JSON.parse(require("fs").readFileSync("distribution/distribution-contract.json", "utf8")).product.currentReleaseVersion)') test "$("$native_root/bin/stack" --version)" = "stack $expected_version" "$native_root/bin/stack" init -o "$smoke_directory/diagram.stack" "$native_root/bin/stack" check "$smoke_directory/diagram.stack" @@ -182,7 +182,6 @@ jobs: ./target/release/stack check --help ./target/release/stack fmt --help ./target/release/stack render --help - ./target/release/stack update --help ./target/release/stack lsp --help ./target/release/stack doctor --help ./target/release/stack config --help @@ -224,6 +223,7 @@ jobs: test -s aqua/registry.yaml test -s distribution/distribution-contract.json test -s distribution/distribution-contract.schema.json + test -s distribution/distribution-contract-v2.schema.json test -s distribution/install-receipt.schema.json test -s distribution/release-manifest.schema.json test -s schemas/cli-output-v1.schema.json @@ -239,9 +239,6 @@ jobs: test -s src/command_docs.rs test -s src/lsp.rs test -s src/machine_output.rs - test -s src/update.rs - test -s src/update/install.rs - test -s src/update/tests.rs test -s src/main.rs test -s src/templates.rs test -s src/provider.rs @@ -299,8 +296,6 @@ jobs: test -s tests/snapshots/config-help.txt test -s tests/snapshots/config-path-help.txt test -s tests/snapshots/config-get-help.txt - test -s tests/snapshots/update-help.txt - test -s tests/update.rs msrv: name: Minimum supported Rust diff --git a/.gitignore b/.gitignore index 9ef6530..e459977 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ /target/ /node_modules/ +__pycache__/ +*.pyc diff --git a/Cargo.lock b/Cargo.lock index 7f31445..c80ea2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,16 +109,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - [[package]] name = "find-msvc-tools" version = "0.1.12" @@ -304,12 +294,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde" version = "1.0.229" @@ -385,11 +369,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "stack-cli" -version = "0.4.0" +version = "0.5.0" dependencies = [ - "flate2", "roxmltree", - "semver", "serde", "serde_json", "serde_yaml_ng", @@ -397,7 +379,6 @@ dependencies = [ "stack-compiler", "stack-engine", "stack-theme", - "tar", "ureq", "zip", ] @@ -465,16 +446,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", -] - [[package]] name = "typenum" version = "1.20.1" diff --git a/Cargo.toml b/Cargo.toml index 74631b2..ce77d02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "stack-cli" -version = "0.4.0" +version = "0.5.0" edition = "2024" rust-version = "1.85" publish = false @@ -14,9 +14,7 @@ name = "stack" path = "src/main.rs" [dependencies] -flate2 = { version = "=1.1.10", default-features = false, features = ["zlib-rs"] } roxmltree = "=0.21.1" -semver = "=1.0.28" serde = { version = "=1.0.229", features = ["derive"] } serde_json = "=1.0.151" serde_yaml_ng = "=0.10.0" @@ -24,7 +22,6 @@ sha2 = "=0.11.0" stack-compiler = { git = "https://github.com/stack-sh/compiler.git", rev = "84ab5663a7f7c5b7dc0b5e9e2f04c8894ed02820" } stack-engine = { git = "https://github.com/stack-sh/engine.git", rev = "9af727aea79233b8389e0ed6fdbae7d3f388dc29" } stack-theme = { git = "https://github.com/stack-sh/theme.git", rev = "7e208d6a3c90d255799f390a4e8b86248c73caee" } -tar = { version = "=0.4.46", default-features = false } ureq = { version = "=3.4.0", default-features = false, features = ["rustls"] } zip = { version = "=6.0.0", default-features = false, features = ["deflate-flate2-zlib-rs"] } diff --git a/README.md b/README.md index 2a98359..272aed8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `stack-sh/cli` is the open-source native Rust `stack` command for Stack architecture diagrams. -The repository contains native validation, formatting, and rendering commands. [Stack CLI 0.4.0](https://github.com/stack-sh/cli/releases/tag/v0.4.0) is the supported native binary release for macOS 13 or newer and glibc-based Linux 2.31 or newer, on arm64 and x86_64. GitHub Releases, Homebrew, and the owner-maintained Aqua registry are available; Cargo and self-update are still planned. The target matrix, artifact names, verification material, channel ownership, and rollback rules are defined by the [distribution contract](./docs/distribution.md), with signing and verification procedures in the [supply-chain guide](./docs/supply-chain.md). +The repository contains native validation, formatting, and rendering commands. [Stack CLI 0.4.0](https://github.com/stack-sh/cli/releases/tag/v0.4.0) is the supported native binary release for macOS 13 or newer and glibc-based Linux 2.31 or newer, on arm64 and x86_64. GitHub Releases, Homebrew, and the owner-maintained Aqua registry are available; Cargo remains planned. Source for 0.5.0 removes self-update; use the installation owner to upgrade. The target matrix, artifact names, verification material, channel ownership, and rollback rules are defined by the [distribution contract](./docs/distribution.md), with signing and verification procedures in the [supply-chain guide](./docs/supply-chain.md). ## Install @@ -73,7 +73,7 @@ stack manpage `stack config path` prints the selected `config.yaml` path without creating or reading the file. `stack config get default_icons_path` strictly reads the supported configuration and prints the effective icon-store path. `stack doctor` reports the CLI version, configuration path and source, configuration validity, effective icon-store source, and installed known-provider packs. It is read-only, emits actionable categories instead of configuration contents, exits `0` for healthy and warning-only reports, and exits `2` when it finds an operational problem. See the [configuration discovery and doctor contract](./docs/configuration.md). -`stack update` is included in 0.4.0 for future receipted direct installations, with `--check`, exact-version selection, authenticated release-manifest and archive verification, and rollback-aware atomic replacement. It refuses Homebrew, Aqua, Cargo, and unknown ownership. The 0.4.0 release manifest does not activate `self-update`, and the documented manual installation creates no receipt, so the channel remains planned. See the [self-update contract](./docs/self-update.md). +`stack update` is removed in 0.5.0. Homebrew, Aqua, and future Cargo installations are updated through their package manager; direct downloads are updated manually after verification. See the [upgrade and migration guide](./docs/self-update.md). `stack completions ` and `stack manpage` generate deterministic shell integration and an offline roff manual from the CLI command metadata. The 0.4.0 release archives carry the exact generated files; Homebrew installs them into its managed completion and manual paths, while direct, Aqua, and future Cargo users can generate them into user-owned locations without modifying shell startup files. See the [completion and manual guide](./docs/completions.md). diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 5c87796..0463af8 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -12,9 +12,7 @@ Audit date: 2026-09-05 | `roxmltree` | `0.21.1` | MIT OR Apache-2.0 | | Parses untrusted local SVG into a read-only tree before allowlisted serialization. | | `sha2`, `digest`, `block-buffer`, `crypto-common`, `hybrid-array`, `const-oid`, `typenum` | `0.11.0`, `0.11.3`, `0.12.1`, `0.2.2`, `0.4.14`, `0.10.2`, `1.20.1` | MIT OR Apache-2.0 | | Computes complete archive and per-asset SHA-256 identities. | | `zip` | `6.0.0` | MIT | | Reads audited, allowlisted entries from verified official ZIP archives. | -| `flate2` / `zlib-rs` / `crc32fast` | `1.1.10`, `0.6.7`, `1.5.1` | MIT OR Apache-2.0 / Zlib / MIT OR Apache-2.0 | , , | Pure Rust DEFLATE decoding and integrity checks for provider ZIPs and release tarballs. | -| `tar` / `filetime` | `0.4.46`, `0.2.29` | MIT OR Apache-2.0 | , | Reads the authenticated release archive and validates its exact entry metadata before replacement. | -| `semver` | `1.0.28` | MIT OR Apache-2.0 | | Parses and orders exact stable and release-candidate update versions. | +| `flate2` / `zlib-rs` / `crc32fast` | `1.1.10`, `0.6.7`, `1.5.1` | MIT OR Apache-2.0 / Zlib / MIT OR Apache-2.0 | , , | Pure Rust DEFLATE decoding and integrity checks for provider ZIPs. | | `indexmap` / `hashbrown` / `equivalent` | `2.14.1`, `0.17.1`, `1.0.2` | Apache-2.0 OR MIT | , , | ZIP archive entry index. | | `cfg-if` / `cpufeatures` / `libc` | `1.0.4`, `0.3.1`, `0.2.189` | MIT OR Apache-2.0 | , , | Target selection and SHA-256 acceleration support. | | `serde` / `serde_core` | `1.0.229` | MIT OR Apache-2.0 | | Runtime catalog data types through `stack-theme`. | diff --git a/distribution/distribution-contract-v2.schema.json b/distribution/distribution-contract-v2.schema.json new file mode 100644 index 0000000..f8e8306 --- /dev/null +++ b/distribution/distribution-contract-v2.schema.json @@ -0,0 +1,404 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stack-sh/cli/main/distribution/distribution-contract-v2.schema.json", + "title": "Stack CLI distribution contract", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "product", + "availability", + "versioning", + "artifacts", + "targets", + "unsupported", + "channels", + "verification" + ], + "properties": { + "$schema": { + "type": "string" + }, + "schemaVersion": { + "const": 2 + }, + "product": { + "type": "object", + "additionalProperties": false, + "required": [ + "binary", + "sourceCargoPackage", + "publishedCargoPackage", + "sourceVersionFile", + "currentSourceVersion", + "minimumRustVersion", + "currentReleaseVersion" + ], + "properties": { + "binary": { + "const": "stack" + }, + "sourceCargoPackage": { + "const": "stack-cli" + }, + "publishedCargoPackage": { + "type": "null" + }, + "sourceVersionFile": { + "const": "Cargo.toml" + }, + "currentSourceVersion": { + "$ref": "#/$defs/version" + }, + "minimumRustVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+$" + }, + "currentReleaseVersion": { + "$ref": "#/$defs/version" + } + } + }, + "availability": { + "type": "object", + "additionalProperties": false, + "required": [ + "state", + "message" + ], + "properties": { + "state": { + "enum": [ + "planned", + "available" + ] + }, + "message": { + "type": "string", + "minLength": 1 + } + } + }, + "versioning": { + "type": "object", + "additionalProperties": false, + "required": [ + "scheme", + "tagTemplate", + "stableVersionRequirement", + "prereleaseVersionRequirement", + "prereleasePolicy", + "minimumSupportedVersionSource", + "preOneSupportWindow", + "stableSupportWindow" + ], + "properties": { + "scheme": { + "const": "Semantic Versioning" + }, + "tagTemplate": { + "const": "v{version}" + }, + "stableVersionRequirement": { + "type": "string", + "minLength": 1 + }, + "prereleaseVersionRequirement": { + "type": "string", + "minLength": 1 + }, + "prereleasePolicy": { + "type": "string", + "minLength": 1 + }, + "minimumSupportedVersionSource": { + "type": "string", + "minLength": 1 + }, + "preOneSupportWindow": { + "type": "string", + "minLength": 1 + }, + "stableSupportWindow": { + "type": "string", + "minLength": 1 + } + } + }, + "artifacts": { + "type": "object", + "additionalProperties": false, + "required": [ + "archiveNameTemplate", + "archiveRootTemplate", + "requiredEntries", + "completionPaths", + "manpagePath", + "releaseManifestNameTemplate", + "checksumNameTemplate", + "signatureBundleNameTemplate", + "sbomNameTemplate", + "provenanceNameTemplate", + "sbomAttestationNameTemplate", + "checksumAlgorithm", + "reproducibility" + ], + "properties": { + "archiveNameTemplate": { + "$ref": "#/$defs/template" + }, + "archiveRootTemplate": { + "$ref": "#/$defs/template" + }, + "requiredEntries": { + "type": "array", + "minItems": 8, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "completionPaths": { + "type": "object", + "additionalProperties": false, + "required": [ + "bash", + "zsh", + "fish" + ], + "properties": { + "bash": { + "const": "share/bash-completion/completions/stack" + }, + "zsh": { + "const": "share/zsh/site-functions/_stack" + }, + "fish": { + "const": "share/fish/vendor_completions.d/stack.fish" + } + } + }, + "manpagePath": { + "const": "share/man/man1/stack.1" + }, + "releaseManifestNameTemplate": { + "$ref": "#/$defs/template" + }, + "checksumNameTemplate": { + "$ref": "#/$defs/template" + }, + "signatureBundleNameTemplate": { + "$ref": "#/$defs/template" + }, + "sbomNameTemplate": { + "$ref": "#/$defs/template" + }, + "provenanceNameTemplate": { + "$ref": "#/$defs/template" + }, + "sbomAttestationNameTemplate": { + "$ref": "#/$defs/template" + }, + "checksumAlgorithm": { + "const": "sha256" + }, + "reproducibility": { + "type": "object", + "additionalProperties": false, + "required": [ + "archiveOrder", + "uid", + "gid", + "mtime", + "gzipHeader" + ], + "properties": { + "archiveOrder": { + "type": "string", + "minLength": 1 + }, + "uid": { + "const": 0 + }, + "gid": { + "const": 0 + }, + "mtime": { + "const": "SOURCE_DATE_EPOCH" + }, + "gzipHeader": { + "type": "string", + "minLength": 1 + } + } + } + } + }, + "targets": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/target" + } + }, + "unsupported": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "platform", + "reason" + ], + "properties": { + "platform": { + "type": "string", + "minLength": 1 + }, + "reason": { + "type": "string", + "minLength": 1 + } + } + } + }, + "channels": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/channel" + } + }, + "verification": { + "type": "object", + "additionalProperties": false, + "required": [ + "releaseActivation", + "rollback" + ], + "properties": { + "releaseActivation": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "rollback": { + "type": "string", + "minLength": 1 + } + } + } + }, + "$defs": { + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-rc\\.[1-9][0-9]*)?$" + }, + "template": { + "type": "string", + "pattern": "\\{version\\}" + }, + "target": { + "type": "object", + "additionalProperties": false, + "required": [ + "target", + "os", + "architecture", + "libc", + "minimumRuntime", + "supportTier", + "state" + ], + "properties": { + "target": { + "type": "string", + "minLength": 1 + }, + "os": { + "enum": [ + "macos", + "linux" + ] + }, + "architecture": { + "enum": [ + "arm64", + "x86_64" + ] + }, + "libc": { + "enum": [ + "system", + "glibc" + ] + }, + "minimumRuntime": { + "type": "string", + "minLength": 1 + }, + "supportTier": { + "const": "tier-1" + }, + "state": { + "enum": [ + "planned", + "available" + ] + } + } + }, + "channel": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "state", + "targets", + "owns", + "source", + "updatePolicy" + ], + "properties": { + "id": { + "enum": [ + "github-release", + "homebrew", + "cargo", + "aqua" + ] + }, + "state": { + "enum": [ + "planned", + "available" + ] + }, + "targets": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + }, + "owns": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "string", + "minLength": 1 + }, + "updatePolicy": { + "type": "string", + "minLength": 1 + } + } + } + } +} diff --git a/distribution/distribution-contract.json b/distribution/distribution-contract.json index ae20275..799fdfb 100644 --- a/distribution/distribution-contract.json +++ b/distribution/distribution-contract.json @@ -1,25 +1,26 @@ { - "$schema": "./distribution-contract.schema.json", - "schemaVersion": 1, + "$schema": "./distribution-contract-v2.schema.json", + "schemaVersion": 2, "product": { "binary": "stack", "sourceCargoPackage": "stack-cli", "publishedCargoPackage": null, "sourceVersionFile": "Cargo.toml", - "currentSourceVersion": "0.4.0", - "minimumRustVersion": "1.85" + "currentSourceVersion": "0.5.0", + "minimumRustVersion": "1.85", + "currentReleaseVersion": "0.4.0" }, "availability": { "state": "available", - "message": "Stack CLI 0.4.0 is available from GitHub Releases, Homebrew, and the owner-maintained Aqua registry. Cargo and self-update remain planned." + "message": "Stack CLI 0.5.0 is the next source release. GitHub Releases, Homebrew, and Aqua currently distribute 0.4.0. Cargo remains planned; self-update is removed from 0.5.0." }, "versioning": { "scheme": "Semantic Versioning", "tagTemplate": "v{version}", "stableVersionRequirement": "MAJOR.MINOR.PATCH without a prerelease suffix", "prereleaseVersionRequirement": "MAJOR.MINOR.PATCH-rc.N", - "prereleasePolicy": "GitHub prerelease only; never selected by default by package managers or self-update", - "minimumSupportedVersionSource": "The self-update channel's minimumSupportedCliVersion floor, copied into each release manifest", + "prereleasePolicy": "GitHub prerelease only; never selected by default by package managers", + "minimumSupportedVersionSource": "Legacy release-manifest minimumSupportedCliVersion is set to the release version for schema compatibility; it does not authorize self-update", "preOneSupportWindow": "latest stable release only", "stableSupportWindow": "latest two minor lines after 1.0.0" }, @@ -43,7 +44,6 @@ }, "manpagePath": "share/man/man1/stack.1", "releaseManifestNameTemplate": "stack-v{version}-release-manifest.json", - "installReceiptSchema": "distribution/install-receipt.schema.json", "checksumNameTemplate": "stack-v{version}-checksums.txt", "signatureBundleNameTemplate": "stack-v{version}-checksums.txt.sigstore.json", "sbomNameTemplate": "stack-v{version}-{target}.spdx.json", @@ -126,7 +126,7 @@ ], "owns": "canonical immutable binary archives with generated shell completions and manual page, release manifest, checksums, signature bundle, SBOMs, and provenance", "source": "tagged stack-sh/cli source", - "updatePolicy": "stable releases only unless the user requests an exact prerelease" + "updatePolicy": "Verify and manually install the desired immutable GitHub release; never overwrite a package-manager-owned binary" }, { "id": "homebrew", @@ -138,7 +138,7 @@ ], "owns": "formula metadata, GitHub archive URL and SHA-256 mapping, completion and manual placement, install, upgrade, and uninstall lifecycle on current Homebrew tier-1 hosts", "source": "github-release", - "updatePolicy": "Homebrew owns upgrades; stack self-update must refuse replacement" + "updatePolicy": "Homebrew owns upgrades; stack never replaces its own executable" }, { "id": "cargo", @@ -151,7 +151,7 @@ ], "owns": "a future unambiguous crates.io source package and dependency graph; installs the stack binary with Rust 1.85 or newer", "source": "crates.io", - "updatePolicy": "Cargo owns upgrades; stack self-update must refuse replacement" + "updatePolicy": "Cargo owns upgrades; stack never replaces its own executable" }, { "id": "aqua", @@ -164,21 +164,7 @@ ], "owns": "owner registry metadata, immutable registry revision, version pinning, and SHA-256 lock mapped to canonical GitHub archives", "source": "github-release", - "updatePolicy": "Aqua owns upgrades; stack self-update must refuse replacement" - }, - { - "id": "self-update", - "state": "planned", - "targets": [ - "aarch64-apple-darwin", - "x86_64-apple-darwin", - "aarch64-unknown-linux-gnu", - "x86_64-unknown-linux-gnu" - ], - "owns": "GitHub-attested release-manifest and archive verification, then atomic replacement for direct GitHub installations carrying a Stack installation receipt", - "source": "github-release", - "minimumSupportedCliVersion": null, - "updatePolicy": "refuse without a direct-install receipt matching the running binary and print the detected or possible owning package manager command" + "updatePolicy": "Aqua owns upgrades; stack never replaces its own executable" } ], "verification": { @@ -192,11 +178,6 @@ "generated manual page matches the archived stack.1 file", "the release manifest records minimumSupportedCliVersion and each verified channel" ], - "selfUpdateActivation": [ - "the authenticated release manifest explicitly records the self-update channel", - "the direct installer writes a receipt matching the installed binary path, digest, target, version, and source commit", - "local update server, tampered material, package-manager ownership, permission failure, atomic replacement, and rollback tests pass" - ], "rollback": "Never replace a tag or asset. Mark a broken release as withdrawn, remove it from default update resolution, restore package-manager metadata to the last verified release, and publish a new patch version." } } diff --git a/distribution/generated/share/bash-completion/completions/stack b/distribution/generated/share/bash-completion/completions/stack index c4e9a57..bae7467 100644 --- a/distribution/generated/share/bash-completion/completions/stack +++ b/distribution/generated/share/bash-completion/completions/stack @@ -15,14 +15,13 @@ case "$previous" in esac if [[ -z "$words" ]]; then if (( COMP_CWORD == 1 )); then -words="init check fmt render update lsp doctor config icons completions manpage help version -h --help -v -V --version" +words="init check fmt render lsp doctor config icons completions manpage help version -h --help -v -V --version" else case "$context" in "init") words="--template -o --output --force -h --help" ;; "check") words="--json -h --help" ;; "fmt") words="- --check --json -h --help" ;; "render") words="--provider-pack -o --notice --json -h --help" ;; - "update") words="--check --version -h --help" ;; "lsp") words="-h --help" ;; "doctor") words="--provider-pack -h --help" ;; "config") words="path get help -h --help" ;; @@ -33,7 +32,7 @@ words="init check fmt render update lsp doctor config icons completions manpage "icons import") words="aws gcp azure simple-icons --accept-terms -o -h --help" ;; "completions") words="bash zsh fish -h --help" ;; "manpage") words="-h --help" ;; - "help") words="init check fmt render update lsp doctor config icons completions manpage help version -h --help" ;; + "help") words="init check fmt render lsp doctor config icons completions manpage help version -h --help" ;; "version") words="-h --help" ;; *) words="" ;; esac diff --git a/distribution/generated/share/fish/vendor_completions.d/stack.fish b/distribution/generated/share/fish/vendor_completions.d/stack.fish index 1438278..c41d502 100644 --- a/distribution/generated/share/fish/vendor_completions.d/stack.fish +++ b/distribution/generated/share/fish/vendor_completions.d/stack.fish @@ -7,7 +7,6 @@ complete -c stack -n __stack_needs_command -a 'init' -d 'Create a Stack file fro complete -c stack -n __stack_needs_command -a 'check' -d 'Validate a Stack source file without modifying it' complete -c stack -n __stack_needs_command -a 'fmt' -d 'Format a file in place or read from standard input' complete -c stack -n __stack_needs_command -a 'render' -d 'Render standalone SVG to standard output or a file' -complete -c stack -n __stack_needs_command -a 'update' -d 'Check for or install a verified direct-install update' complete -c stack -n __stack_needs_command -a 'lsp' -d 'Run the Stack language server over standard input and output' complete -c stack -n __stack_needs_command -a 'doctor' -d 'Diagnose CLI configuration and provider icon packs' complete -c stack -n __stack_needs_command -a 'config' -d 'Inspect effective read-only configuration' @@ -41,10 +40,6 @@ complete -c stack -n '__fish_seen_subcommand_from render' -l 'notice' -r complete -c stack -n '__fish_seen_subcommand_from render' -l 'json' complete -c stack -n '__fish_seen_subcommand_from render' -s 'h' complete -c stack -n '__fish_seen_subcommand_from render' -l 'help' -complete -c stack -n '__fish_seen_subcommand_from update' -l 'check' -complete -c stack -n '__fish_seen_subcommand_from update' -l 'version' -r -complete -c stack -n '__fish_seen_subcommand_from update' -s 'h' -complete -c stack -n '__fish_seen_subcommand_from update' -l 'help' complete -c stack -n '__fish_seen_subcommand_from lsp' -s 'h' complete -c stack -n '__fish_seen_subcommand_from lsp' -l 'help' complete -c stack -n '__fish_seen_subcommand_from doctor' -l 'provider-pack' -r @@ -90,7 +85,6 @@ complete -c stack -n '__fish_seen_subcommand_from help' -a 'init' complete -c stack -n '__fish_seen_subcommand_from help' -a 'check' complete -c stack -n '__fish_seen_subcommand_from help' -a 'fmt' complete -c stack -n '__fish_seen_subcommand_from help' -a 'render' -complete -c stack -n '__fish_seen_subcommand_from help' -a 'update' complete -c stack -n '__fish_seen_subcommand_from help' -a 'lsp' complete -c stack -n '__fish_seen_subcommand_from help' -a 'doctor' complete -c stack -n '__fish_seen_subcommand_from help' -a 'config' diff --git a/distribution/generated/share/man/man1/stack.1 b/distribution/generated/share/man/man1/stack.1 index 032ad63..cb916f3 100644 --- a/distribution/generated/share/man/man1/stack.1 +++ b/distribution/generated/share/man/man1/stack.1 @@ -1,4 +1,4 @@ -.TH STACK 1 "" "Stack CLI 0.4.0" "Stack CLI Manual" +.TH STACK 1 "" "Stack CLI 0.5.0" "Stack CLI Manual" .SH NAME stack \- Stack diagram toolchain .SH SYNOPSIS @@ -20,7 +20,6 @@ Commands: check Validate a Stack source file without modifying it fmt Format a file in place or read from standard input render Render standalone SVG to standard output or a file - update Check for or install a verified direct\-install update lsp Run the Stack language server over standard input and output doctor Diagnose CLI configuration and provider icon packs config Inspect effective read\-only configuration @@ -40,7 +39,6 @@ Examples: stack check arch.stack stack fmt \-\-check arch.stack stack render arch.stack \-o arch.svg - stack update \-\-check stack lsp stack doctor stack config get default_icons_path @@ -150,32 +148,6 @@ Examples: stack render arch.stack \-\-notice arch.NOTICE.md \-o arch.svg stack render arch.stack \-\-json .fi -.SS "stack update" -.nf -Check for or install a verified direct\-install update - -Usage: - stack update - stack update \-\-check - stack update \-\-version - -Options: - \-\-check Resolve an update without downloading or changing files - \-\-version Select an exact stable or MAJOR.MINOR.PATCH\-rc.N release - \-h, \-\-help Print help - -Safety: - Replacement requires a matching direct\-install receipt and a GitHub CLI - artifact\-attestation check for the exact repository, workflow, tag, commit, - and GitHub\-hosted runner. Homebrew, Aqua, Cargo, and unknown installs are - never replaced. - -Examples: - stack update \-\-check - stack update - stack update \-\-version 0.4.0 - stack update \-\-version 0.4.0\-rc.1 -.fi .SS "stack lsp" .nf Run the Stack language server over standard input and output @@ -377,7 +349,7 @@ Usage: stack help icons Arguments: - init, check, fmt, render, update, lsp, doctor, config, icons, + init, check, fmt, render, lsp, doctor, config, icons, completions, manpage, help, or version Options: diff --git a/distribution/generated/share/zsh/site-functions/_stack b/distribution/generated/share/zsh/site-functions/_stack index 2629958..797c1f1 100644 --- a/distribution/generated/share/zsh/site-functions/_stack +++ b/distribution/generated/share/zsh/site-functions/_stack @@ -12,7 +12,6 @@ commands=( 'check:Validate a Stack source file without modifying it' 'fmt:Format a file in place or read from standard input' 'render:Render standalone SVG to standard output or a file' - 'update:Check for or install a verified direct-install update' 'lsp:Run the Stack language server over standard input and output' 'doctor:Diagnose CLI configuration and provider icon packs' 'config:Inspect effective read-only configuration' @@ -36,7 +35,6 @@ case "$context" in "check") candidates=('--json' '-h' '--help') ;; "fmt") candidates=('-' '--check' '--json' '-h' '--help') ;; "render") candidates=('--provider-pack' '-o' '--notice' '--json' '-h' '--help') ;; - "update") candidates=('--check' '--version' '-h' '--help') ;; "lsp") candidates=('-h' '--help') ;; "doctor") candidates=('--provider-pack' '-h' '--help') ;; "config") candidates=('path' 'get' 'help' '-h' '--help') ;; @@ -47,7 +45,7 @@ case "$context" in "icons import") candidates=('aws' 'gcp' 'azure' 'simple-icons' '--accept-terms' '-o' '-h' '--help') ;; "completions") candidates=('bash' 'zsh' 'fish' '-h' '--help') ;; "manpage") candidates=('-h' '--help') ;; - "help") candidates=('init' 'check' 'fmt' 'render' 'update' 'lsp' 'doctor' 'config' 'icons' 'completions' 'manpage' 'help' 'version' '-h' '--help') ;; + "help") candidates=('init' 'check' 'fmt' 'render' 'lsp' 'doctor' 'config' 'icons' 'completions' 'manpage' 'help' 'version' '-h' '--help') ;; "version") candidates=('-h' '--help') ;; *) candidates=() ;; esac diff --git a/docs/distribution.md b/docs/distribution.md index 8cee89d..c1e295a 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -1,8 +1,8 @@ # Distribution contract -This document defines the shared release contract for the Stack CLI. It is normative for GitHub Releases, Homebrew, Cargo, Aqua, and `stack` self-update implementations. The machine-readable source is [`distribution/distribution-contract.json`](../distribution/distribution-contract.json). +This document defines the shared release contract for the Stack CLI. It is normative for GitHub Releases, Homebrew, Cargo, Aqua, implementations. The machine-readable source is [`distribution/distribution-contract.json`](../distribution/distribution-contract.json). -[Stack CLI 0.4.0](https://github.com/stack-sh/cli/releases/tag/v0.4.0) is available as a supported GitHub Release for every target below, through the owner-maintained Homebrew tap for the hosts marked below, and through the checksum-locked owner Aqua registry. Cargo and self-update remain **planned** and have no supported install command yet. +[Stack CLI 0.4.0](https://github.com/stack-sh/cli/releases/tag/v0.4.0) is available as a supported GitHub Release for every target below, through the owner-maintained Homebrew tap for the hosts marked below, and through the checksum-locked owner Aqua registry. Cargo remains **planned**. The 0.5.0 source removes self-update; see the [upgrade guide](./self-update.md). ## Supported platform matrix @@ -22,9 +22,9 @@ Windows, musl-based Linux distributions such as Alpine, BSD, and 32-bit architec ## Version and support policy - Cargo `package.version`, CLI output, the Git tag `v{version}`, release title, archive names, and release manifest version must agree exactly. -- Stable versions use `MAJOR.MINOR.PATCH`. Release candidates use `MAJOR.MINOR.PATCH-rc.N`, are GitHub prereleases, and are never selected by default by package managers or self-update. +- Stable versions use `MAJOR.MINOR.PATCH`. Release candidates use `MAJOR.MINOR.PATCH-rc.N`, are GitHub prereleases, and are never selected by default by package managers. - Before 1.0, only the latest stable release is supported. Starting at 1.0, the latest two minor lines are supported. -- Each stable release manifest records `minimumSupportedCliVersion`. The self-update channel owns this compatibility floor, and release generation copies it forward instead of advancing it automatically with every release. This is the only input used by update clients and documentation to describe the minimum supported updater. +- Release-manifest schema v1 retains `minimumSupportedCliVersion` for compatibility and sets it to the release version. It does not enable self-update: new manifests never include that channel. Distribution contract v2 removes the updater channel, receipt requirement, and activation rules; the original v1 schema and receipt schema remain unchanged for historical consumers. - A Cargo source version alone is not a supported distribution. Support starts only when a stable GitHub Release built from that exact source passes every activation check; changing a version does not reserve or silently publish it. `.github/workflows/release.yaml` accepts a version-checked manual run from `main` without publication and an annotated `v{version}` tag for publication. A tag run is allowed only for a commit contained in `main`. The manual path must pass first for the same commit and version before a release tag is created. @@ -130,7 +130,7 @@ aqua install stack --version ``` -Commit `aqua-checksums.json` with the configuration. To upgrade after a new stable Stack release, run `aqua update`, review the version change, then run `aqua update-checksum` and `aqua install`. Aqua owns the replacement; `stack` self-update must refuse to overwrite it. The registry maintainer procedure and four-target test command are in [`aqua/README.md`](../aqua/README.md). +Commit `aqua-checksums.json` with the configuration. To upgrade after a new stable Stack release, run `aqua update`, review the version change, then run `aqua update-checksum` and `aqua install`. Aqua owns the replacement; Stack never replaces its own executable. The registry maintainer procedure and four-target test command are in [`aqua/README.md`](../aqua/README.md). Aqua installs the executable declared by its registry mapping and does not own shell startup files or a global manual database. Stack CLI 0.4.0 includes the generators; use `stack completions` and `stack manpage` to write the desired user-owned files as documented in the [completion guide](./completions.md). @@ -147,7 +147,7 @@ install -m 0755 "stack-v0.4.0-{target}/stack" "$HOME/.local/bin/stack" "$HOME/.local/bin/stack" --version ``` -Add `$HOME/.local/bin` to `PATH` if it is not already present. This manual installation has no self-update receipt. Although 0.4.0 contains `stack update`, its release manifest does not activate `self-update`, and an unreceipted binary cannot be claimed retroactively without risking a package-manager-owned installation. Self-update remains unavailable until a later release and verified direct installer separately activate the channel. The command and receipt contract are documented in the [self-update guide](./self-update.md). +Add `$HOME/.local/bin` to `PATH` if it is not already present. Repeat the verified manual installation to update a directly downloaded binary; never overwrite a package-manager-owned binary. No receipt is created or required. See the [upgrade guide](./self-update.md). The 0.4.0 archive carries completion and manual assets. Either copy its verified `share/` files into the matching system prefix or use the installed binary to generate user-owned files following the [completion guide](./completions.md). Do not copy these files from a different Stack version; CI and release verification require them to match the binary's command definition. @@ -159,9 +159,8 @@ The 0.4.0 archive carries completion and manual assets. Either copy its verified | Homebrew | Formula metadata, archive URL/digest mapping, standard completion/manual placement, install, upgrade, and uninstall | Rebuild a different binary or delegate upgrades to `stack` | | Cargo | A future unambiguous crates.io source package, its registry dependency graph, and installation of the `stack` binary | Claim binary-archive identity, promise the local `stack-cli` package name on crates.io, or publish while dependencies remain Git-only | | Aqua | Registry metadata and version pinning mapped to canonical archives and digests | Repack an archive or select prereleases by default | -| `stack` self-update | Verified atomic replacement for direct installs with a Stack installation receipt | Replace a binary owned by Homebrew, Cargo, Aqua, or an unknown installer | -The direct installer must create an installation receipt that identifies the GitHub Release channel, installed version, target, source commit, archive digest, and final binary path and digest. Its public format is [`distribution/install-receipt.schema.json`](../distribution/install-receipt.schema.json). Self-update refuses to write when that receipt is absent or names another owner and prints detected or possible package-manager upgrade commands. Paths may improve guidance, but never authorize replacement. This keeps ownership deterministic instead of guessing from an executable path. +Stack does not provide a self-updater or a receipt-writing installer. Update through the tool that installed the binary, or verify and manually install a new GitHub archive for a direct download. Existing receipts are neither read nor deleted. The workspace currently uses `stack-cli` as its local Cargo package name, but that name is already occupied by an unrelated crates.io package. No public Cargo install command is supported yet. The Cargo channel must select and verify an unambiguous registry package name, while keeping the installed binary name `stack`, before changing its state to available. diff --git a/docs/releases/v0.5.0.md b/docs/releases/v0.5.0.md new file mode 100644 index 0000000..2c3165d --- /dev/null +++ b/docs/releases/v0.5.0.md @@ -0,0 +1,17 @@ +# Stack CLI 0.5.0 + +## Added + +- `stack doctor` diagnoses effective configuration and installed provider packs without writing files or exposing configuration contents. +- `stack config path` and `stack config get default_icons_path` expose read-only configuration discovery. +- `stack check --json`, `stack fmt --json`, and `stack render --json` provide the version 1 machine-readable envelope for agents, CI, and editor consumers, preserving existing exit statuses. + +## Removed + +- `stack update` and its self-replacement implementation. Use Homebrew or Aqua to update their installations; verified direct downloads are updated manually. Cargo distribution remains planned. See the [upgrade and migration guide](https://github.com/stack-sh/cli/blob/v0.5.0/docs/self-update.md). + +## Installation and verification + +The release includes native archives for macOS 13+ and glibc Linux 2.31+, on arm64 and x86_64. Each contains the binary, Apache-2.0 notices, and matching bash/zsh/fish completions and manual page. Verify the signed checksums, provenance, SBOMs, and release manifest using the [verification guide](https://github.com/stack-sh/cli/blob/v0.5.0/docs/supply-chain.md) before installing. Homebrew and the owner Aqua registry are updated separately after artifact verification. + +The release-manifest v1 format and historical receipt schema are retained unchanged. Distribution contract v2 removes self-update ownership and activation. No self-update channel is enabled. Existing configuration, provider packs, and user files are not migrated or deleted. diff --git a/docs/self-update.md b/docs/self-update.md index a295d75..c1f9c7d 100644 --- a/docs/self-update.md +++ b/docs/self-update.md @@ -1,51 +1,23 @@ -# Verified self-update +# Upgrading Stack -`stack update` updates only a direct GitHub Release installation that has a matching Stack installation receipt. It never claims an unreceipted binary and never replaces an installation owned by Homebrew, Aqua, Cargo, or an unknown installer. +Stack 0.5.0 removes `stack update` and its self-replacement implementation. Update with the tool that installed Stack; mixing package-manager installs with direct binary replacement can invalidate ownership, checksums, and version pins. -The command is included in Stack CLI 0.4.0, but that release's authenticated manifest lists only `github-release`, and the documented manual installation does not create a receipt. The self-update channel therefore remains **planned** until a later release both activates `self-update` in its authenticated release manifest and has a verified direct installer that creates the receipt. Do not describe 0.4.0 as self-updatable. +## Homebrew -## Commands +Run `brew update`, then `brew upgrade stack-sh/tap/stack`. Homebrew also updates its managed completion and manual files. -```sh -stack update --check -stack update -stack update --version 0.4.0 -stack update --version 0.4.0-rc.1 -``` +## Aqua -With no version, GitHub's latest stable release is selected. `--version` accepts an exact stable version or `MAJOR.MINOR.PATCH-rc.N`; release candidates are never selected by default. `--check` resolves release metadata only and does not require a receipt, download artifacts, invoke a verifier, or change files. An exact request for the already-running version is also a local no-op. +In the project containing your Aqua configuration, run `aqua update`, review the Stack version change, then run `aqua update-checksum` and `aqua install`. Commit the reviewed configuration and checksum lock. Follow the [owner registry guide](../aqua/README.md) to refresh an immutable registry revision when needed. -Actual replacement requires [GitHub CLI](https://cli.github.com/manual/gh_attestation_verify) with `gh attestation verify`. The verifier constrains both the release manifest and target archive to: +## Cargo -- repository `stack-sh/cli`; -- `.github/workflows/release.yaml` at the exact release tag; -- GitHub's OIDC issuer and a GitHub-hosted runner; -- the manifest's exact source commit; -- SLSA provenance. +The crates.io channel is not available yet. Do not install the unrelated `stack-cli` crate. When the channel is released, Cargo will own updates and the supported command will be documented in the [distribution guide](./distribution.md). -The manifest must explicitly list both `github-release` and `self-update` in `verifiedChannels`. HTTPS release metadata supplies the asset name, size, URL, and SHA-256; the authenticated manifest independently binds the source, target archive digest, minimum updater version, and channel activation. The updater compatibility floor is owned by the distribution contract and copied into each release manifest; it does not automatically advance to the new release version. +## Direct GitHub download -## Installation receipt +Download the desired version for your supported target. Follow the [supply-chain verification guide](./supply-chain.md), then the manual installation procedure in the [distribution guide](./distribution.md). Replace only a binary you installed manually, and refresh completion/manual files from that same release. Run `stack --version`, `stack check`, and `stack render` to verify your installation. Keep the previous verified archive for manual rollback; published assets are never rewritten. -The direct installer owns `$XDG_CONFIG_HOME/stack/install-receipt.json`, falling back to `$HOME/.config/stack/install-receipt.json`. Its format is [`distribution/install-receipt.schema.json`](../distribution/install-receipt.schema.json). A receipt records the owner, repository, exact version and target, source commit, archive name and digest, and the absolute installed-binary path and digest. +## Migration from 0.4.0 -Before any network request or write, an actual update requires all receipt fields to match the running executable and verifies its complete SHA-256. A missing, malformed, symlinked, oversized, mismatched, or non-`github-release` receipt fails closed. A canonicalized path recognized as Homebrew, Aqua, or Cargo managed also fails closed even if a forged receipt claims `github-release` ownership. Recognized package-manager ownership produces the corresponding upgrade guidance; an unrecognized path lists the safe alternatives without guessing ownership. - -## Replacement and recovery - -After both attestations pass, the updater checks the archive's exact root, bytewise entry order, regular-file types, uid/gid, `SOURCE_DATE_EPOCH`, modes, expanded-size limit, and target binary. It writes the candidate next to the installed executable, preserves permissions, syncs it, and runs `--version` before changing the live path. - -The live executable is replaced with a same-filesystem rename while a hard-linked rollback copy remains. The new receipt is prepared and synced in its own directory before replacement. If the executable rename fails, the old binary remains at its original path. If the receipt commit fails, the updater restores the original binary from the rollback link. Failure diagnostics never claim success and identify any retained recovery path if automatic rollback itself fails. - -An operating-system or power interruption can occur between the two file renames because the binary and configuration directory may be on different filesystems. In that case the old receipt's binary digest will reject another update. Preserve any `.stack-update-backup-*` file beside the executable and restore it before retrying; do not delete the receipt or bypass its digest check. - -## Maintainer activation - -Self-update is activated only after all of these are true: - -1. The release workflow attests the release manifest and every target archive from the exact tag. -2. A verified direct installer writes a schema-valid receipt for the final installed bytes and path. -3. Local-server integration, tampered material, package-manager refusal, permission failure, atomic replacement, and rollback tests pass. -4. The distribution contract sets `minimumSupportedCliVersion` to the earliest compatible released updater and changes the `self-update` channel to `available`; tagged release context then copies that floor into the manifest and records the channel in `verifiedChannels`. - -Changing source code or documenting the command alone does not activate the channel. Published tags and assets remain immutable; a broken release is withdrawn and replaced by a new patch version. +Remove any `stack update` invocation from automation and use the installation owner above. In 0.5.0 it is an unknown command with exit status 2 and performs no update. The 0.4.0 command existed but its release never activated the self-update channel or provided a verified receipt-writing installer. Stack 0.5.0 neither reads nor deletes old installation receipts or backup files. The [historical 0.4.0 contract](https://github.com/stack-sh/cli/blob/v0.4.0/docs/self-update.md) and its immutable schemas remain available; no future self-update channel is planned. diff --git a/docs/supply-chain.md b/docs/supply-chain.md index a815f0c..7f1fb13 100644 --- a/docs/supply-chain.md +++ b/docs/supply-chain.md @@ -31,7 +31,7 @@ stack-v{version}-checksums.txt.sigstore.json The checksum inventory covers all 16 target materials and the release manifest. The signature bundle itself is intentionally not self-referential. The manifest format is constrained by [`distribution/release-manifest.schema.json`](../distribution/release-manifest.schema.json). Each generated manifest links to that schema at the exact source commit rather than a mutable branch or a release-local path. -Stack CLI 0.3.0 predates the separate GitHub provenance attestation for the release manifest; its keyless checksum signature still covers the manifest bytes. The self-update client requires the additional manifest attestation and an explicit `self-update` channel, so it cannot select 0.3.0 as an update target. Later releases generated by the current workflow retain the checksum signature and add the independent manifest attestation. +Stack CLI 0.3.0 predates the separate GitHub provenance attestation for the release manifest; its keyless checksum signature still covers the manifest bytes. Later releases retain that signature and add the independent manifest attestation for external verification. Stack 0.5.0 has no self-update client. ## Release-side generation diff --git a/scripts/aqua-registry.test.mjs b/scripts/aqua-registry.test.mjs index 1bcccc6..5a73edb 100644 --- a/scripts/aqua-registry.test.mjs +++ b/scripts/aqua-registry.test.mjs @@ -15,7 +15,7 @@ const distributionContract = JSON.parse( const checksums = JSON.parse( fs.readFileSync(path.join(root, "tests/aqua/aqua-checksums.json"), "utf8"), ); -const releaseVersion = `v${distributionContract.product.currentSourceVersion}`; +const releaseVersion = `v${distributionContract.product.currentReleaseVersion}`; const targets = [ "aarch64-apple-darwin", diff --git a/scripts/distribution-contract.test.mjs b/scripts/distribution-contract.test.mjs index 028c2e8..b4d2770 100644 --- a/scripts/distribution-contract.test.mjs +++ b/scripts/distribution-contract.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; import test from "node:test"; +import Ajv2020 from "ajv/dist/2020.js"; import { fileURLToPath } from "node:url"; import { validateDistributionContract } from "./validate-distribution-contract.mjs"; @@ -21,7 +22,7 @@ function changed(change) { test("the checked-in distribution contract is valid", () => { assert.deepEqual(validateDistributionContract(contract, cargoToml), { targets: 4, - channels: 5, + channels: 4, }); }); @@ -100,16 +101,16 @@ test("an unactivated package-manager channel cannot become available", () => { assert.throws(() => validateDistributionContract(candidate, cargoToml), /cargo state must be planned/); }); -test("self-update activation cannot omit authenticated release metadata", () => { - const candidate = changed((value) => { - value.verification.selfUpdateActivation = ["direct installer and rollback tests pass"]; - }); - assert.throws(() => validateDistributionContract(candidate, cargoToml), /authenticated release manifest/); +test("removed self-update cannot be reintroduced", () => { + const candidate = changed(value => value.channels.push({ id: "self-update", state: "available" })); + assert.throws(() => validateDistributionContract(candidate, cargoToml), /channel set must be exactly/); }); -test("planned self-update cannot claim an updater compatibility floor", () => { - const candidate = changed((value) => { - value.channels.find(({ id }) => id === "self-update").minimumSupportedCliVersion = "0.3.0"; - }); - assert.throws(() => validateDistributionContract(candidate, cargoToml), /must not claim a minimum supported/); +test("distribution v2 schema rejects removed updater fields", () => { + const schema = JSON.parse(fs.readFileSync(path.join(root, "distribution/distribution-contract-v2.schema.json"), "utf8")); + const validate = new Ajv2020({ strict: false }).compile(schema); + assert.equal(validate(contract), true, JSON.stringify(validate.errors)); + for (const mutate of [value => value.artifacts.installReceiptSchema = "distribution/install-receipt.schema.json", value => value.verification.selfUpdateActivation = ["obsolete"], value => value.channels[0].minimumSupportedCliVersion = "0.4.0"]) { + assert.equal(validate(changed(mutate)), false); + } }); diff --git a/scripts/release-security.test.mjs b/scripts/release-security.test.mjs index d7c9e18..121feac 100644 --- a/scripts/release-security.test.mjs +++ b/scripts/release-security.test.mjs @@ -12,7 +12,7 @@ import { verifyReleaseMetadata, } from "./release-security.mjs"; -const version = "0.4.0"; +const version = "0.5.0"; const commit = "0123456789abcdef0123456789abcdef01234567"; const provenancePredicate = "https://slsa.dev/provenance/v1"; const sbomPredicate = "https://spdx.dev/Document/v2.3"; @@ -232,7 +232,7 @@ test("release metadata cannot drift from the source version", (t) => { assert.throws( () => generateReleaseMetadata({ directory, - version: "0.4.0-rc.1", + version: "0.5.0-rc.1", commit, minimumSupportedCliVersion: "0.3.0", sourceDateEpoch: 1_788_566_400, @@ -251,7 +251,7 @@ test("the minimum supported version cannot be newer than the release", (t) => { directory, version, commit, - minimumSupportedCliVersion: "0.5.0", + minimumSupportedCliVersion: "0.6.0", sourceDateEpoch: 1_788_566_400, builderWorkflow: "stack-sh/cli/.github/workflows/release.yaml", }), diff --git a/scripts/resolve-release-context.mjs b/scripts/resolve-release-context.mjs index 2dfe6c0..642dfc8 100644 --- a/scripts/resolve-release-context.mjs +++ b/scripts/resolve-release-context.mjs @@ -16,43 +16,14 @@ function cargoVersion(cargoToml) { return match[1]; } -function compareVersions(left, right) { - const parse = (value) => { - const match = value.match(/^(\d+)\.(\d+)\.(\d+)(?:-rc\.([1-9]\d*))?$/); - invariant(match, `invalid updater compatibility version: ${value}`); - return [BigInt(match[1]), BigInt(match[2]), BigInt(match[3]), match[4] ? BigInt(match[4]) : null]; - }; - const leftParts = parse(left); - const rightParts = parse(right); - for (let index = 0; index < 3; index += 1) { - if (leftParts[index] < rightParts[index]) return -1; - if (leftParts[index] > rightParts[index]) return 1; - } - if (leftParts[3] === null) return rightParts[3] === null ? 0 : 1; - if (rightParts[3] === null) return -1; - if (leftParts[3] < rightParts[3]) return -1; - if (leftParts[3] > rightParts[3]) return 1; - return 0; -} - export function resolveReleaseContext({ eventName, ref, refName, sha, requestedVersion, cargoToml, contract }) { const version = cargoVersion(cargoToml); invariant(versionPattern.test(version), "Cargo.toml version is not a supported release version"); invariant(contract.product?.currentSourceVersion === version, "distribution contract version does not match Cargo.toml"); invariant(commitPattern.test(sha), "release source must be a full lowercase Git SHA"); - const selfUpdate = contract.channels?.find(({ id }) => id === "self-update"); - let minimumSupportedCliVersion = version; - if (selfUpdate?.state === "available") { - invariant( - typeof selfUpdate.minimumSupportedCliVersion === "string", - "available self-update requires a minimum supported CLI version", - ); - invariant( - compareVersions(selfUpdate.minimumSupportedCliVersion, version) <= 0, - "minimum supported CLI version cannot be newer than the release", - ); - minimumSupportedCliVersion = selfUpdate.minimumSupportedCliVersion; - } + invariant(!contract.channels?.some(({ id }) => id === "self-update"), "self-update has been removed"); + // Preserve the immutable release-manifest v1 shape without enabling an updater. + const minimumSupportedCliVersion = version; if (eventName === "workflow_dispatch") { invariant(ref === "refs/heads/main", "manual release verification must run from main"); @@ -71,9 +42,6 @@ export function resolveReleaseContext({ eventName, ref, refName, sha, requestedV invariant(ref === `refs/tags/v${version}`, "release tag must exactly match Cargo.toml version"); invariant(refName === `v${version}`, "release ref name must exactly match Cargo.toml version"); const verifiedChannels = ["github-release"]; - if (selfUpdate?.state === "available") { - verifiedChannels.push("self-update"); - } return { version, tag: refName, diff --git a/scripts/resolve-release-context.test.mjs b/scripts/resolve-release-context.test.mjs index be0ffa8..3a074c0 100644 --- a/scripts/resolve-release-context.test.mjs +++ b/scripts/resolve-release-context.test.mjs @@ -20,17 +20,17 @@ test("main dispatch resolves a non-publishing verification run", () => { ref: "refs/heads/main", refName: "main", sha, - requestedVersion: "0.4.0", + requestedVersion: "0.5.0", cargoToml, contract, }), { - version: "0.4.0", - tag: "v0.4.0", + version: "0.5.0", + tag: "v0.5.0", sourceRef: "refs/heads/main", publish: false, verifiedChannels: "", - minimumSupportedCliVersion: "0.4.0", + minimumSupportedCliVersion: "0.5.0", }, ); }); @@ -39,69 +39,28 @@ test("an exact version tag resolves a publishing run", () => { assert.deepEqual( resolveReleaseContext({ eventName: "push", - ref: "refs/tags/v0.4.0", - refName: "v0.4.0", + ref: "refs/tags/v0.5.0", + refName: "v0.5.0", sha, requestedVersion: "", cargoToml, contract, }), { - version: "0.4.0", - tag: "v0.4.0", - sourceRef: "refs/tags/v0.4.0", + version: "0.5.0", + tag: "v0.5.0", + sourceRef: "refs/tags/v0.5.0", publish: true, verifiedChannels: "github-release", - minimumSupportedCliVersion: "0.4.0", + minimumSupportedCliVersion: "0.5.0", }, ); }); -test("an activated self-update channel is recorded in a tagged release", () => { +test("removed self-update cannot be activated", () => { const activated = structuredClone(contract); - const selfUpdate = activated.channels.find(({ id }) => id === "self-update"); - selfUpdate.state = "available"; - selfUpdate.minimumSupportedCliVersion = "0.2.1"; - assert.deepEqual( - resolveReleaseContext({ - eventName: "push", - ref: "refs/tags/v0.4.0", - refName: "v0.4.0", - sha, - requestedVersion: "", - cargoToml, - contract: activated, - }), - { - version: "0.4.0", - tag: "v0.4.0", - sourceRef: "refs/tags/v0.4.0", - publish: true, - verifiedChannels: "github-release,self-update", - minimumSupportedCliVersion: "0.2.1", - }, - ); -}); - -test("self-update activation rejects a missing or future compatibility floor", () => { - for (const floor of [null, "0.5.0", "invalid"]) { - const activated = structuredClone(contract); - const selfUpdate = activated.channels.find(({ id }) => id === "self-update"); - selfUpdate.state = "available"; - selfUpdate.minimumSupportedCliVersion = floor; - assert.throws( - () => resolveReleaseContext({ - eventName: "push", - ref: "refs/tags/v0.4.0", - refName: "v0.4.0", - sha, - requestedVersion: "", - cargoToml, - contract: activated, - }), - /minimum supported CLI version|invalid updater compatibility version/, - ); - } + activated.channels.push({ id: "self-update", state: "available" }); + assert.throws(() => resolveReleaseContext({eventName: "push", ref: "refs/tags/v0.5.0", refName: "v0.5.0", sha, cargoToml, contract: activated}), /self-update has been removed/); }); test("manual runs from another ref or version are rejected", () => { @@ -113,17 +72,17 @@ test("manual runs from another ref or version are rejected", () => { contract, }; assert.throws( - () => resolveReleaseContext({ ...common, ref: "refs/heads/topic", requestedVersion: "0.4.0" }), + () => resolveReleaseContext({ ...common, ref: "refs/heads/topic", requestedVersion: "0.5.0" }), /must run from main/, ); assert.throws( - () => resolveReleaseContext({ ...common, ref: "refs/heads/main", requestedVersion: "0.5.0" }), + () => resolveReleaseContext({ ...common, ref: "refs/heads/main", requestedVersion: "0.6.0" }), /must match Cargo.toml/, ); }); test("floating and mismatched tags are rejected", () => { - for (const ref of ["refs/tags/latest", "refs/tags/v0.4", "refs/tags/v0.5.0"]) { + for (const ref of ["refs/tags/latest", "refs/tags/v0.4", "refs/tags/v0.6.0"]) { assert.throws( () => resolveReleaseContext({ eventName: "push", @@ -141,14 +100,14 @@ test("floating and mismatched tags are rejected", () => { test("source and contract version drift is rejected", () => { const drifted = structuredClone(contract); - drifted.product.currentSourceVersion = "0.5.0"; + drifted.product.currentSourceVersion = "0.6.0"; assert.throws( () => resolveReleaseContext({ eventName: "workflow_dispatch", ref: "refs/heads/main", refName: "main", sha, - requestedVersion: "0.4.0", + requestedVersion: "0.5.0", cargoToml, contract: drifted, }), diff --git a/scripts/test_package_release.py b/scripts/test_package_release.py index 8c3019c..ae33930 100644 --- a/scripts/test_package_release.py +++ b/scripts/test_package_release.py @@ -9,7 +9,7 @@ class PackageReleaseTest(unittest.TestCase): - version = "0.4.0" + version = "0.5.0" target = "aarch64-apple-darwin" source_date_epoch = 1_788_566_400 @@ -69,7 +69,7 @@ def test_existing_archive_is_never_replaced(self): def test_version_and_target_drift_are_rejected(self): with self.assertRaisesRegex(ValueError, "match Cargo.toml"): - create_archive(self.binary, self.target, "0.5.0", self.source_date_epoch, self.root) + create_archive(self.binary, self.target, "0.6.0", self.source_date_epoch, self.root) with self.assertRaisesRegex(ValueError, "unsupported release target"): create_archive(self.binary, "x86_64-pc-windows-msvc", self.version, self.source_date_epoch, self.root) diff --git a/scripts/validate-distribution-contract.mjs b/scripts/validate-distribution-contract.mjs index 47817bc..2e993cb 100644 --- a/scripts/validate-distribution-contract.mjs +++ b/scripts/validate-distribution-contract.mjs @@ -11,7 +11,7 @@ const expectedTargets = [ "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", ]; -const expectedChannels = ["aqua", "cargo", "github-release", "homebrew", "self-update"]; +const expectedChannels = ["aqua", "cargo", "github-release", "homebrew"]; const availableChannels = new Set(["aqua", "github-release", "homebrew"]); const requiredArchiveEntries = [ "LICENSE", @@ -60,7 +60,7 @@ function cargoValue(cargoToml, field) { export function validateDistributionContract(contract, cargoToml) { const cargoVersion = cargoValue(cargoToml, "version"); - invariant(contract.schemaVersion === 1, "schemaVersion must be 1"); + invariant(contract.schemaVersion === 2, "schemaVersion must be 2"); invariant(contract.product?.binary === "stack", "binary must be stack"); invariant(contract.product?.sourceCargoPackage === "stack-cli", "source Cargo package must be stack-cli"); invariant( @@ -123,13 +123,11 @@ export function validateDistributionContract(contract, cargoToml) { const githubTargets = contract.channels.find(({ id }) => id === "github-release")?.targets ?? []; const cargoTargets = contract.channels.find(({ id }) => id === "cargo")?.targets ?? []; const aquaTargets = contract.channels.find(({ id }) => id === "aqua")?.targets ?? []; - const updateTargets = contract.channels.find(({ id }) => id === "self-update")?.targets ?? []; const homebrewTargets = contract.channels.find(({ id }) => id === "homebrew")?.targets ?? []; const channels = new Map(contract.channels.map((channel) => [channel.id, channel])); sameValues(githubTargets, expectedTargets, "github-release targets"); sameValues(cargoTargets, expectedTargets, "cargo targets"); sameValues(aquaTargets, expectedTargets, "aqua targets"); - sameValues(updateTargets, expectedTargets, "self-update targets"); sameValues( homebrewTargets, ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu"], @@ -137,23 +135,15 @@ export function validateDistributionContract(contract, cargoToml) { ); invariant(channels.get("github-release")?.source === "tagged stack-sh/cli source", "GitHub releases must build tagged source"); invariant(channels.get("cargo")?.source === "crates.io", "Cargo must install from crates.io"); - for (const id of ["homebrew", "aqua", "self-update"]) { + for (const id of ["homebrew", "aqua"]) { invariant(channels.get(id)?.source === "github-release", `${id} must consume GitHub releases`); } for (const id of ["homebrew", "cargo", "aqua"]) { invariant( - channels.get(id)?.updatePolicy.includes("self-update must refuse replacement"), + channels.get(id)?.updatePolicy.includes("owns upgrades; stack never replaces its own executable"), `${id} must own upgrades instead of self-update`, ); } - invariant( - channels.get("self-update")?.updatePolicy.includes("refuse without a direct-install receipt"), - "self-update must require a direct-install receipt", - ); - invariant( - channels.get("self-update")?.minimumSupportedCliVersion === null, - "planned self-update must not claim a minimum supported CLI version", - ); for (const id of ["github-release", "homebrew", "cargo", "aqua"]) { invariant( !("minimumSupportedCliVersion" in channels.get(id)), @@ -184,10 +174,6 @@ export function validateDistributionContract(contract, cargoToml) { "manual page must use the canonical archive location", ); invariant(contract.artifacts?.checksumAlgorithm === "sha256", "checksum algorithm must be sha256"); - invariant( - contract.artifacts?.installReceiptSchema === "distribution/install-receipt.schema.json", - "install receipt schema path is invalid", - ); invariant(contract.artifacts?.signatureBundleNameTemplate?.endsWith(".sigstore.json"), "signature bundle must use .sigstore.json"); invariant(contract.artifacts?.sbomNameTemplate?.endsWith(".spdx.json"), "SBOM must use .spdx.json"); invariant( @@ -213,10 +199,6 @@ export function validateDistributionContract(contract, cargoToml) { for (const term of [...requiredActivationTerms, "shell completion", "manual page"]) { invariant(activation.includes(term), `release activation must mention ${term}`); } - const selfUpdateActivation = (contract.verification?.selfUpdateActivation ?? []).join(" "); - for (const term of ["authenticated release manifest", "direct installer", "tampered material", "atomic replacement", "rollback"]) { - invariant(selfUpdateActivation.includes(term), `self-update activation must mention ${term}`); - } invariant(contract.verification?.rollback?.includes("Never replace"), "rollback must preserve immutable releases"); return { diff --git a/scripts/verify_release_binary.py b/scripts/verify_release_binary.py index 01b77b9..9381e77 100644 --- a/scripts/verify_release_binary.py +++ b/scripts/verify_release_binary.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import argparse +import json import os from pathlib import Path import re @@ -100,10 +101,9 @@ def verify_commands(binary, version): b"stack lsp" in command([binary, "lsp", "--help"]), "LSP help output is missing usage", ) - require( - b"stack update" in command([binary, "update", "--help"]), - "update help output is missing usage", - ) + removed = subprocess.run([binary, "update"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) + require(removed.returncode == 2 and not removed.stdout, "removed update command must fail without output") + require(b"unknown command" in removed.stderr, "removed update must be an unknown command") require( b"stack doctor" in command([binary, "doctor", "--help"]), "doctor help output is missing usage", @@ -149,6 +149,14 @@ def verify_commands(binary, version): svg = ET.fromstring(rendered.read_bytes()) require(svg.tag == "{http://www.w3.org/2000/svg}svg", "rendered output is not an SVG root") require(svg.attrib.get("viewBox"), "rendered SVG has no viewBox") + for operation in ("check", "fmt", "render"): + envelope = json.loads(command([binary, operation, source, "--json"], working_directory, environment)) + require(envelope["schemaVersion"] == 1, "JSON output schema version changed") + require(envelope["command"] == operation, "JSON output command is inconsistent") + require(envelope["exitStatus"] == 0 and envelope["error"] is None, "JSON command failed") + require(isinstance(envelope["diagnostics"], list), "JSON diagnostics are missing") + if operation == "render": + require(envelope["artifacts"], "JSON render did not report its SVG artifact") def verify_release_binary(binary, target, version): diff --git a/src/command_docs.rs b/src/command_docs.rs index d68e47c..66452c4 100644 --- a/src/command_docs.rs +++ b/src/command_docs.rs @@ -5,7 +5,7 @@ use std::fmt::Write as _; use super::{ CHECK_HELP, COMPLETIONS_HELP, CONFIG_GET_HELP, CONFIG_HELP, CONFIG_PATH_HELP, DOCTOR_HELP, FORMAT_HELP, GENERAL_HELP, HELP_HELP, ICONS_HELP, ICONS_IMPORT_HELP, ICONS_LIST_HELP, - INIT_HELP, LSP_HELP, MANPAGE_HELP, RENDER_HELP, UPDATE_HELP, VERSION_HELP, + INIT_HELP, LSP_HELP, MANPAGE_HELP, RENDER_HELP, VERSION_HELP, }; pub(crate) const TOP_LEVEL_NAMES: &[&str] = &[ @@ -13,7 +13,6 @@ pub(crate) const TOP_LEVEL_NAMES: &[&str] = &[ "check", "fmt", "render", - "update", "lsp", "doctor", "config", @@ -79,12 +78,6 @@ const COMMANDS: &[CommandSpec] = &[ ], values: &[], }, - CommandSpec { - context: "update", - description: "Check for or install a verified direct-install update", - options: &["--check", "--version", "-h", "--help"], - values: &[], - }, CommandSpec { context: "lsp", description: "Run the Stack language server over standard input and output", @@ -398,7 +391,6 @@ Stack validates, formats, renders, and develops Stack architecture diagrams.\n\ ("stack check", CHECK_HELP), ("stack fmt", FORMAT_HELP), ("stack render", RENDER_HELP), - ("stack update", UPDATE_HELP), ("stack lsp", LSP_HELP), ("stack doctor", DOCTOR_HELP), ("stack config", CONFIG_HELP), @@ -439,7 +431,6 @@ mod tests { "check" => Some(CHECK_HELP), "fmt" => Some(FORMAT_HELP), "render" => Some(RENDER_HELP), - "update" => Some(UPDATE_HELP), "lsp" => Some(LSP_HELP), "doctor" => Some(DOCTOR_HELP), "config" => Some(CONFIG_HELP), diff --git a/src/config.rs b/src/config.rs index 74827af..fd1aa3a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -124,12 +124,6 @@ pub(crate) fn icon_store_root( discover(environment).map(|discovery| discovery.icon_store_root) } -pub(crate) fn installation_receipt_path(environment: &Environment) -> Result { - Ok(config_root(environment)? - .0 - .join("stack/install-receipt.json")) -} - fn config_root(environment: &Environment) -> Result<(PathBuf, ConfigRootSource), String> { if let Some(value) = &environment.xdg_config_home { if !value.is_empty() { @@ -274,14 +268,6 @@ mod tests { config_file_path(&Environment::new(None, Some(&home))), Ok((path, ConfigRootSource::Home)) if path == home.join(".config/stack/config.yaml") )); - assert!(matches!( - installation_receipt_path(&Environment::new(Some(&xdg), Some(&home))), - Ok(path) if path == xdg.join("stack/install-receipt.json") - )); - assert!(matches!( - installation_receipt_path(&Environment::new(None, Some(&home))), - Ok(path) if path == home.join(".config/stack/install-receipt.json") - )); } #[test] diff --git a/src/lib.rs b/src/lib.rs index 2044b53..a992ebf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,7 +21,6 @@ mod machine_output; mod provider; mod provider_catalog; mod templates; -mod update; /// Exit status used when a command completes without Stack error diagnostics. pub const EXIT_SUCCESS: u8 = 0; @@ -42,7 +41,6 @@ Commands: check Validate a Stack source file without modifying it fmt Format a file in place or read from standard input render Render standalone SVG to standard output or a file - update Check for or install a verified direct-install update lsp Run the Stack language server over standard input and output doctor Diagnose CLI configuration and provider icon packs config Inspect effective read-only configuration @@ -62,7 +60,6 @@ Examples: stack check arch.stack stack fmt --check arch.stack stack render arch.stack -o arch.svg - stack update --check stack lsp stack doctor stack config get default_icons_path @@ -254,31 +251,6 @@ is created or changed. Examples: stack config get default_icons_path "; -const UPDATE_HELP: &str = "\ -Check for or install a verified direct-install update - -Usage: - stack update - stack update --check - stack update --version - -Options: - --check Resolve an update without downloading or changing files - --version Select an exact stable or MAJOR.MINOR.PATCH-rc.N release - -h, --help Print help - -Safety: - Replacement requires a matching direct-install receipt and a GitHub CLI - artifact-attestation check for the exact repository, workflow, tag, commit, - and GitHub-hosted runner. Homebrew, Aqua, Cargo, and unknown installs are - never replaced. - -Examples: - stack update --check - stack update - stack update --version 0.4.0 - stack update --version 0.4.0-rc.1 -"; const ICONS_HELP: &str = "\ Manage local provider icon packs @@ -383,7 +355,7 @@ Usage: stack help icons Arguments: - init, check, fmt, render, update, lsp, doctor, config, icons, + init, check, fmt, render, lsp, doctor, config, icons, completions, manpage, help, or version Options: @@ -588,9 +560,6 @@ pub fn run( if command == OsStr::new("render") { return run_render(arguments, stdout, stderr); } - if command == OsStr::new("update") { - return run_update(arguments, stdout, stderr); - } if command == OsStr::new("lsp") { return run_lsp(arguments, stdin, stdout, stderr); } @@ -651,8 +620,6 @@ fn run_help( FORMAT_HELP } else if command == OsStr::new("render") { RENDER_HELP - } else if command == OsStr::new("update") { - UPDATE_HELP } else if command == OsStr::new("lsp") { LSP_HELP } else if command == OsStr::new("doctor") { @@ -898,66 +865,6 @@ fn run_manpage( write_stdout(&command_docs::manpage(), stdout, stderr) } -fn run_update( - mut arguments: impl Iterator, - stdout: &mut dyn Write, - stderr: &mut dyn Write, -) -> u8 { - let first = arguments.next(); - if first - .as_ref() - .is_some_and(|argument| is_help_flag(argument)) - { - if let Some(extra) = arguments.next() { - return argument_error( - &format!("unexpected argument '{}'", extra.to_string_lossy()), - stderr, - ); - } - return write_stdout(UPDATE_HELP, stdout, stderr); - } - - let mut check_only = false; - let mut requested_version = None; - let mut remaining = first.into_iter().chain(arguments); - while let Some(option) = remaining.next() { - if option == OsStr::new("--check") { - if check_only { - return argument_error("duplicate '--check' option", stderr); - } - check_only = true; - } else if option == OsStr::new("--version") { - if requested_version.is_some() { - return argument_error("duplicate '--version' option", stderr); - } - let Some(value) = remaining.next() else { - return argument_error("missing version after '--version'", stderr); - }; - if value.to_string_lossy().starts_with('-') { - return argument_error("missing version after '--version'", stderr); - } - match update::parse_version(&value) { - Ok(version) => requested_version = Some(version), - Err(error) => return argument_error(&error, stderr), - } - } else { - return argument_error( - &format!("unexpected argument '{}'", option.to_string_lossy()), - stderr, - ); - } - } - - let options = update::Options { - check_only, - requested_version, - }; - match update::run(options, &config::Environment::capture()) { - Ok(message) => write_stdout(&message, stdout, stderr), - Err(error) => write_stderr_error(&format!("cannot update Stack CLI: {error}"), stderr), - } -} - fn run_lsp( mut arguments: impl Iterator, stdin: &mut dyn Read, diff --git a/src/update.rs b/src/update.rs deleted file mode 100644 index 853bb38..0000000 --- a/src/update.rs +++ /dev/null @@ -1,736 +0,0 @@ -//! Verified self-update for receipt-owned direct installations. - -use std::collections::{BTreeMap, BTreeSet}; -use std::env; -use std::ffi::OsStr; -use std::fs; -use std::io; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::time::Duration; - -use semver::Version; -use serde::Deserialize; - -use crate::config; - -const REPOSITORY: &str = "stack-sh/cli"; -const RELEASE_WORKFLOW: &str = "stack-sh/cli/.github/workflows/release.yaml"; -const API_VERSION: &str = "2026-03-10"; -const RECEIPT_SCHEMA_VERSION: u8 = 1; -const MAX_RELEASE_RESPONSE_BYTES: u64 = 1024 * 1024; -const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; -const MAX_RECEIPT_BYTES: u64 = 64 * 1024; -const MAX_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024; -const MAX_BINARY_BYTES: u64 = 256 * 1024 * 1024; -const SUPPORTED_TARGETS: [&str; 4] = [ - "aarch64-apple-darwin", - "aarch64-unknown-linux-gnu", - "x86_64-apple-darwin", - "x86_64-unknown-linux-gnu", -]; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct Options { - pub(crate) check_only: bool, - pub(crate) requested_version: Option, -} - -pub(crate) fn parse_version(value: &OsStr) -> Result { - let Some(value) = value.to_str() else { - return Err("update version must be valid UTF-8".to_owned()); - }; - let version = match Version::parse(value) { - Ok(version) => version, - Err(_) => { - return Err( - "update version must be MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-rc.N".to_owned(), - ); - } - }; - if !version.build.is_empty() { - return Err("update version must not contain build metadata".to_owned()); - } - if !version.pre.is_empty() { - let prerelease = version.pre.as_str(); - let Some(sequence) = prerelease.strip_prefix("rc.") else { - return Err( - "only an exact MAJOR.MINOR.PATCH-rc.N prerelease can be requested".to_owned(), - ); - }; - if sequence.is_empty() - || !sequence.bytes().all(|byte| byte.is_ascii_digit()) - || sequence == "0" - { - return Err( - "only an exact MAJOR.MINOR.PATCH-rc.N prerelease can be requested".to_owned(), - ); - } - } - Ok(version) -} - -pub(crate) fn run(options: Options, environment: &config::Environment) -> Result { - let current_version = match Version::parse(env!("CARGO_PKG_VERSION")) { - Ok(version) => version, - Err(_) => return Err("the running CLI has an invalid embedded version".to_owned()), - }; - let current_executable = match env::current_exe().and_then(fs::canonicalize) { - Ok(executable) => executable, - Err(error) => { - return Err(format!( - "cannot resolve the running executable: {}", - io_error(error) - )); - } - }; - let receipt_path = config::installation_receipt_path(environment)?; - let runtime = Runtime { - current_version, - current_executable, - receipt_path, - target: host_target()?, - }; - execute( - &options, - &runtime, - &ReleaseClient::production(), - &GitHubAttestationVerifier::production(), - &ExecutableVersionVerifier, - ) -} - -#[cfg(test)] -pub(crate) fn run_integration_test( - current_version: &str, - current_executable: &Path, - receipt_path: &Path, - target: &str, - server_base: &str, - gh_command: &Path, -) -> Result { - let runtime = Runtime { - current_version: parse_version(OsStr::new(current_version))?, - current_executable: match fs::canonicalize(current_executable) { - Ok(executable) => executable, - Err(error) => { - return Err(format!( - "cannot resolve test executable: {}", - io_error(error) - )); - } - }, - receipt_path: receipt_path.to_owned(), - target: target.to_owned(), - }; - execute( - &Options { - check_only: false, - requested_version: None, - }, - &runtime, - &ReleaseClient::for_debug(server_base), - &GitHubAttestationVerifier { - command: gh_command.to_owned(), - }, - &ExecutableVersionVerifier, - ) -} - -#[cfg(test)] -pub(crate) fn run_integration_noop() -> Result { - run( - Options { - check_only: true, - requested_version: Some(match Version::parse(env!("CARGO_PKG_VERSION")) { - Ok(version) => version, - Err(_) => return Err("the test package version is invalid".to_owned()), - }), - }, - &config::Environment::capture(), - ) -} - -#[derive(Clone, Debug)] -struct Runtime { - current_version: Version, - current_executable: PathBuf, - receipt_path: PathBuf, - target: String, -} - -fn host_target() -> Result { - match (env::consts::OS, env::consts::ARCH) { - ("macos", "aarch64") => Ok("aarch64-apple-darwin".to_owned()), - ("macos", "x86_64") => Ok("x86_64-apple-darwin".to_owned()), - ("linux", "aarch64") if cfg!(target_env = "gnu") => { - Ok("aarch64-unknown-linux-gnu".to_owned()) - } - ("linux", "x86_64") if cfg!(target_env = "gnu") => { - Ok("x86_64-unknown-linux-gnu".to_owned()) - } - _ => Err(format!( - "self-update is unsupported on {}/{}; install with a supported package manager", - env::consts::OS, - env::consts::ARCH - )), - } -} - -fn execute( - options: &Options, - runtime: &Runtime, - client: &ReleaseClient, - attestation_verifier: &dyn AttestationVerifier, - binary_verifier: &dyn BinaryVerifier, -) -> Result { - if options - .requested_version - .as_ref() - .is_some_and(|version| version == &runtime.current_version) - { - return Ok(format!( - "stack {} is already installed; no files were changed.\n", - runtime.current_version - )); - } - if !options.check_only { - read_and_validate_receipt(runtime)?; - } - - let release = client.resolve_release(options.requested_version.as_ref())?; - let comparison = release.version.cmp(&runtime.current_version); - - if comparison.is_eq() { - return Ok(format!( - "stack {} is already installed; no files were changed.\n", - runtime.current_version - )); - } - if options.requested_version.is_none() && comparison.is_lt() { - return Ok(format!( - "stack {} is newer than latest stable {}; no files were changed.\n", - runtime.current_version, release.version - )); - } - if options.check_only { - let direction = if comparison.is_gt() { - "Update available" - } else { - "Requested rollback available" - }; - return Ok(format!( - "{direction}: {} -> {} for {}. No files were changed.\n", - runtime.current_version, release.version, runtime.target - )); - } - - let manifest_name = format!("stack-v{}-release-manifest.json", release.version); - let archive_name = archive_name(&release.version, &runtime.target); - let manifest_asset = release.asset(&manifest_name, MAX_MANIFEST_BYTES)?; - let archive_asset = release.asset(&archive_name, MAX_ARCHIVE_BYTES)?; - let manifest_bytes = client.download_asset(manifest_asset, MAX_MANIFEST_BYTES)?; - let manifest = validate_manifest( - &manifest_bytes, - &release, - runtime, - &archive_name, - &archive_asset.digest, - )?; - let executable_parent = parent_directory(&runtime.current_executable)?; - let manifest_file = - TemporaryFile::write(executable_parent, "update-manifest", &manifest_bytes, None)?; - attestation_verifier.verify( - manifest_file.path(), - &release.version, - &manifest.source.commit, - )?; - - let archive_bytes = client.download_asset(archive_asset, MAX_ARCHIVE_BYTES)?; - let archive_file = - TemporaryFile::write(executable_parent, "update-archive", &archive_bytes, None)?; - attestation_verifier.verify( - archive_file.path(), - &release.version, - &manifest.source.commit, - )?; - - let candidate = extract_binary( - &archive_bytes, - &release.version, - &runtime.target, - manifest.source_date_epoch, - )?; - let candidate_digest = sha256_bytes(&candidate); - let new_receipt = InstallationReceipt::for_release( - runtime, - &release.version, - &manifest.source.commit, - &archive_name, - &archive_asset.digest, - &candidate_digest, - )?; - let warning = replace_binary_and_receipt(runtime, &candidate, &new_receipt, binary_verifier)?; - - let action = if comparison.is_gt() { - "Updated" - } else { - "Rolled back" - }; - let mut message = format!( - "{action} stack {} -> {} for {}. Restart running language server processes.\n", - runtime.current_version, release.version, runtime.target - ); - if let Some(warning) = warning { - message.push_str(&format!("Warning: {warning}\n")); - } - Ok(message) -} - -#[derive(Clone)] -struct ReleaseClient { - agent: ureq::Agent, - api_base: String, - asset_base: String, -} - -impl ReleaseClient { - fn production() -> Self { - #[cfg(debug_assertions)] - if let Some(value) = env::var_os("STACK_CLI_TEST_UPDATE_BASE_URL") { - if let Ok(base) = value.into_string() { - if base.starts_with("http://127.0.0.1:") || base.starts_with("http://[::1]:") { - return Self::for_debug(&base); - } - } - } - let config = ureq::Agent::config_builder() - .https_only(true) - .timeout_global(Some(Duration::from_secs(120))) - .build(); - Self { - agent: ureq::Agent::new_with_config(config), - api_base: "https://api.github.com".to_owned(), - asset_base: "https://github.com/stack-sh/cli/releases/download".to_owned(), - } - } - - #[cfg(debug_assertions)] - fn for_debug(base: &str) -> Self { - let config = ureq::Agent::config_builder() - .https_only(false) - .timeout_global(Some(Duration::from_secs(5))) - .build(); - Self { - agent: ureq::Agent::new_with_config(config), - api_base: base.to_owned(), - asset_base: format!("{base}/download"), - } - } - - #[cfg(test)] - fn for_test(base: &str) -> Self { - Self::for_debug(base) - } - - fn resolve_release(&self, requested: Option<&Version>) -> Result { - let endpoint = match requested { - Some(version) => format!("/repos/{REPOSITORY}/releases/tags/v{version}"), - None => format!("/repos/{REPOSITORY}/releases/latest"), - }; - let bytes = self.get( - &format!("{}{endpoint}", self.api_base), - MAX_RELEASE_RESPONSE_BYTES, - true, - )?; - let response: ApiRelease = match serde_json::from_slice(&bytes) { - Ok(response) => response, - Err(_) => return Err("GitHub returned invalid release metadata".to_owned()), - }; - if response.draft { - return Err("the selected GitHub release is still a draft".to_owned()); - } - let Some(version_text) = response.tag_name.strip_prefix('v') else { - return Err("the selected GitHub release tag is invalid".to_owned()); - }; - let version = parse_version(OsStr::new(version_text))?; - if let Some(requested) = requested { - if requested != &version { - return Err("GitHub returned a different release version than requested".to_owned()); - } - } - if requested.is_none() && !version.pre.is_empty() { - return Err("GitHub latest release unexpectedly selected a prerelease".to_owned()); - } - if response.prerelease == version.pre.is_empty() { - return Err("GitHub release prerelease metadata does not match its tag".to_owned()); - } - - let mut assets = BTreeMap::new(); - for asset in response.assets { - let Some(digest) = asset.digest.strip_prefix("sha256:") else { - return Err(format!( - "release asset '{}' has no SHA-256 digest", - asset.name - )); - }; - validate_digest(digest, &format!("release asset '{}'", asset.name))?; - if asset.state != "uploaded" || asset.size == 0 { - return Err(format!("release asset '{}' is not available", asset.name)); - } - let expected_url = format!("{}/v{}/{}", self.asset_base, version, asset.name); - if asset.browser_download_url != expected_url { - return Err(format!( - "release asset '{}' has an unexpected download URL", - asset.name - )); - } - let name = asset.name.clone(); - if assets - .insert( - name.clone(), - ReleaseAsset { - name, - url: asset.browser_download_url, - size: asset.size, - digest: digest.to_owned(), - }, - ) - .is_some() - { - return Err("GitHub release metadata contains duplicate assets".to_owned()); - } - } - Ok(Release { version, assets }) - } - - fn download_asset(&self, asset: &ReleaseAsset, limit: u64) -> Result, String> { - if asset.size > limit { - return Err(format!( - "release asset '{}' exceeds the {} byte limit", - asset.name, limit - )); - } - let bytes = self.get(&asset.url, limit, false)?; - if bytes.len() as u64 != asset.size { - return Err(format!( - "release asset '{}' size differs from GitHub metadata", - asset.name - )); - } - if sha256_bytes(&bytes) != asset.digest { - return Err(format!( - "release asset '{}' failed SHA-256 verification", - asset.name - )); - } - Ok(bytes) - } - - fn get(&self, url: &str, limit: u64, api: bool) -> Result, String> { - let mut request = self - .agent - .get(url) - .header( - "User-Agent", - concat!("stack-cli/", env!("CARGO_PKG_VERSION")), - ) - .header("Accept", "application/vnd.github+json"); - if api { - request = request.header("X-GitHub-Api-Version", API_VERSION); - } - let mut response = match request.call() { - Ok(response) => response, - Err(error) => { - return Err(format!( - "cannot download release metadata or artifact: {error}" - )); - } - }; - match response.body_mut().with_config().limit(limit).read_to_vec() { - Ok(bytes) => Ok(bytes), - Err(error) => Err(format!("cannot read release response: {error}")), - } - } -} - -#[derive(Debug, Deserialize)] -struct ApiRelease { - tag_name: String, - draft: bool, - prerelease: bool, - assets: Vec, -} - -#[derive(Debug, Deserialize)] -struct ApiAsset { - name: String, - state: String, - size: u64, - digest: String, - browser_download_url: String, -} - -#[derive(Debug)] -struct Release { - version: Version, - assets: BTreeMap, -} - -impl Release { - fn asset(&self, name: &str, limit: u64) -> Result<&ReleaseAsset, String> { - let asset = match self.assets.get(name) { - Some(asset) => asset, - None => return Err(format!("GitHub release is missing required asset '{name}'")), - }; - if asset.size > limit { - return Err(format!( - "release asset '{name}' exceeds the {limit} byte limit" - )); - } - Ok(asset) - } -} - -#[derive(Debug)] -struct ReleaseAsset { - name: String, - url: String, - size: u64, - digest: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ReleaseManifest { - #[serde(rename = "$schema")] - schema: String, - schema_version: u8, - version: String, - tag: String, - source: ManifestSource, - minimum_supported_cli_version: String, - source_date_epoch: u64, - builder_workflow: String, - verified_channels: Vec, - targets: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ManifestSource { - repository: String, - commit: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -struct ManifestTarget { - target: String, - archive: ManifestFile, - sbom: ManifestFile, - provenance: ManifestFile, - sbom_attestation: ManifestFile, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ManifestFile { - name: String, - sha256: String, -} - -fn validate_manifest( - bytes: &[u8], - release: &Release, - runtime: &Runtime, - selected_archive_name: &str, - archive_digest: &str, -) -> Result { - let manifest: ReleaseManifest = match serde_json::from_slice(bytes) { - Ok(manifest) => manifest, - Err(_) => { - return Err("release manifest is invalid JSON or has unsupported fields".to_owned()); - } - }; - if manifest.schema_version != 1 - || manifest.version != release.version.to_string() - || manifest.tag != format!("v{}", release.version) - { - return Err("release manifest version metadata is inconsistent".to_owned()); - } - if manifest.source.repository != REPOSITORY { - return Err("release manifest names an unexpected source repository".to_owned()); - } - validate_commit(&manifest.source.commit, "release manifest source commit")?; - let expected_schema = format!( - "https://raw.githubusercontent.com/{}/{}/distribution/release-manifest.schema.json", - REPOSITORY, manifest.source.commit - ); - if manifest.schema != expected_schema || manifest.builder_workflow != RELEASE_WORKFLOW { - return Err("release manifest source or workflow evidence is inconsistent".to_owned()); - } - let minimum = parse_version(OsStr::new(&manifest.minimum_supported_cli_version))?; - if minimum > release.version { - return Err("release minimum supported CLI version is newer than the release".to_owned()); - } - if runtime.current_version < minimum { - return Err(format!( - "stack {} cannot self-update to {}; minimum supported updater is {}", - runtime.current_version, release.version, minimum - )); - } - let channels: BTreeSet<&str> = manifest - .verified_channels - .iter() - .map(String::as_str) - .collect(); - if channels.len() != manifest.verified_channels.len() - || manifest - .verified_channels - .windows(2) - .any(|pair| pair[0] >= pair[1]) - || channels.iter().any(|channel| { - !matches!( - *channel, - "github-release" | "homebrew" | "cargo" | "aqua" | "self-update" - ) - }) - { - return Err("release manifest verified channels are invalid".to_owned()); - } - if !channels.contains("github-release") || !channels.contains("self-update") { - return Err("the selected release has not activated the self-update channel".to_owned()); - } - let mut targets = BTreeSet::new(); - for target in &manifest.targets { - if !targets.insert(target.target.as_str()) { - return Err("release manifest contains duplicate targets".to_owned()); - } - validate_manifest_file( - &target.archive, - "archive", - &archive_name(&release.version, &target.target), - )?; - validate_manifest_file( - &target.sbom, - "SBOM", - &format!("stack-v{}-{}.spdx.json", release.version, target.target), - )?; - validate_manifest_file( - &target.provenance, - "provenance", - &format!( - "stack-v{}-{}.provenance.sigstore.json", - release.version, target.target - ), - )?; - validate_manifest_file( - &target.sbom_attestation, - "SBOM attestation", - &format!( - "stack-v{}-{}.sbom.sigstore.json", - release.version, target.target - ), - )?; - } - if targets != SUPPORTED_TARGETS.into_iter().collect() { - return Err("release manifest must contain exactly the four supported targets".to_owned()); - } - let mut selected_target = None; - for target in &manifest.targets { - if target.target == runtime.target { - selected_target = Some(target); - break; - } - } - let target = match selected_target { - Some(target) => target, - None => return Err("release manifest does not support this host target".to_owned()), - }; - if target.archive.name != selected_archive_name || target.archive.sha256 != archive_digest { - return Err("release manifest archive identity differs from GitHub metadata".to_owned()); - } - Ok(manifest) -} - -fn validate_manifest_file( - file: &ManifestFile, - label: &str, - expected_name: &str, -) -> Result<(), String> { - if file.name != expected_name { - return Err(format!("release manifest {label} name is invalid")); - } - validate_digest(&file.sha256, &format!("release manifest {label}")) -} - -trait AttestationVerifier { - fn verify(&self, archive: &Path, version: &Version, source_commit: &str) -> Result<(), String>; -} - -struct GitHubAttestationVerifier { - command: PathBuf, -} - -impl GitHubAttestationVerifier { - fn production() -> Self { - Self { - command: PathBuf::from("gh"), - } - } -} - -impl AttestationVerifier for GitHubAttestationVerifier { - fn verify(&self, archive: &Path, version: &Version, source_commit: &str) -> Result<(), String> { - let tag_ref = format!("refs/tags/v{version}"); - let certificate_identity = - format!("https://github.com/{REPOSITORY}/.github/workflows/release.yaml@{tag_ref}"); - let outcome = Command::new(&self.command) - .arg("attestation") - .arg("verify") - .arg(archive) - .arg("--repo") - .arg(REPOSITORY) - .arg("--cert-identity") - .arg(certificate_identity) - .arg("--cert-oidc-issuer") - .arg("https://token.actions.githubusercontent.com") - .arg("--deny-self-hosted-runners") - .arg("--source-ref") - .arg(tag_ref) - .arg("--source-digest") - .arg(source_commit) - .arg("--predicate-type") - .arg("https://slsa.dev/provenance/v1") - .arg("--limit") - .arg("5") - .env("GH_HOST", "github.com") - .env("GH_PROMPT_DISABLED", "1") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - match outcome { - Ok(status) if status.success() => Ok(()), - Ok(_) => Err( - "GitHub artifact attestation verification failed; the existing binary was not changed" - .to_owned(), - ), - Err(error) if error.kind() == io::ErrorKind::NotFound => Err( - "GitHub CLI with `gh attestation verify` is required for self-update".to_owned(), - ), - Err(error) => Err(format!( - "cannot run GitHub artifact attestation verification: {}", - io_error(error) - )), - } - } -} - -mod install; -use install::*; - -#[cfg(test)] -#[path = "update/tests.rs"] -mod tests; diff --git a/src/update/install.rs b/src/update/install.rs deleted file mode 100644 index 2858c98..0000000 --- a/src/update/install.rs +++ /dev/null @@ -1,664 +0,0 @@ -//! Receipt, archive, and local replacement boundaries for self-update. - -use std::ffi::OsStr; -use std::fs::{self, File, OpenOptions}; -use std::io::{self, Cursor, Read, Write}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; - -use flate2::read::GzDecoder; -use semver::Version; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; - -use super::{ - MAX_ARCHIVE_BYTES, MAX_BINARY_BYTES, MAX_RECEIPT_BYTES, RECEIPT_SCHEMA_VERSION, REPOSITORY, - Runtime, parse_version, -}; - -pub(super) trait BinaryVerifier { - fn verify(&self, candidate: &Path, version: &Version) -> Result<(), String>; -} - -pub(super) struct ExecutableVersionVerifier; - -impl BinaryVerifier for ExecutableVersionVerifier { - fn verify(&self, candidate: &Path, version: &Version) -> Result<(), String> { - let outcome = Command::new(candidate) - .arg("--version") - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .output(); - let output = match outcome { - Ok(output) => output, - Err(error) => { - return Err(format!( - "cannot execute verified update candidate: {}", - io_error(error) - )); - } - }; - if !output.status.success() || output.stdout != format!("stack {version}\n").as_bytes() { - return Err( - "verified update candidate did not report the selected version; the existing binary was not changed" - .to_owned(), - ); - } - Ok(()) - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(super) struct InstallationReceipt { - #[serde(rename = "$schema")] - pub(super) schema: String, - pub(super) schema_version: u8, - pub(super) owner: String, - pub(super) repository: String, - pub(super) version: String, - pub(super) target: String, - pub(super) source_commit: String, - pub(super) archive: ReceiptArtifact, - pub(super) binary: ReceiptBinary, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub(super) struct ReceiptArtifact { - pub(super) name: String, - pub(super) sha256: String, -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub(super) struct ReceiptBinary { - pub(super) path: String, - pub(super) sha256: String, -} - -impl InstallationReceipt { - pub(super) fn for_release( - runtime: &Runtime, - version: &Version, - source_commit: &str, - archive_name: &str, - archive_digest: &str, - binary_digest: &str, - ) -> Result { - let Some(executable_path) = runtime.current_executable.to_str() else { - return Err("the executable path is not valid UTF-8".to_owned()); - }; - Ok(Self { - schema: format!( - "https://raw.githubusercontent.com/{REPOSITORY}/{source_commit}/distribution/install-receipt.schema.json" - ), - schema_version: RECEIPT_SCHEMA_VERSION, - owner: "github-release".to_owned(), - repository: REPOSITORY.to_owned(), - version: version.to_string(), - target: runtime.target.clone(), - source_commit: source_commit.to_owned(), - archive: ReceiptArtifact { - name: archive_name.to_owned(), - sha256: archive_digest.to_owned(), - }, - binary: ReceiptBinary { - path: executable_path.to_owned(), - sha256: binary_digest.to_owned(), - }, - }) - } -} - -pub(super) fn read_and_validate_receipt(runtime: &Runtime) -> Result { - let metadata = match fs::symlink_metadata(&runtime.receipt_path) { - Ok(metadata) => metadata, - Err(error) if error.kind() == io::ErrorKind::NotFound => { - return Err(missing_receipt_guidance(runtime)); - } - Err(error) => { - return Err(format!( - "cannot read install receipt '{}': {}", - runtime.receipt_path.display(), - io_error(error) - )); - } - }; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(format!( - "install receipt '{}' must be a regular file, not a symlink", - runtime.receipt_path.display() - )); - } - if metadata.len() == 0 || metadata.len() > MAX_RECEIPT_BYTES { - return Err(format!( - "install receipt '{}' must be between 1 byte and 64 KiB", - runtime.receipt_path.display() - )); - } - let mut bytes = Vec::with_capacity(metadata.len() as usize); - let mut file = match File::open(&runtime.receipt_path) { - Ok(file) => file, - Err(error) => { - return Err(format!( - "cannot read install receipt '{}': {}", - runtime.receipt_path.display(), - io_error(error) - )); - } - }; - if let Err(error) = file.read_to_end(&mut bytes) { - return Err(format!( - "cannot read install receipt '{}': {}", - runtime.receipt_path.display(), - io_error(error) - )); - } - let receipt: InstallationReceipt = match serde_json::from_slice(&bytes) { - Ok(receipt) => receipt, - Err(_) => { - return Err(format!( - "install receipt '{}' is invalid JSON or has unsupported fields", - runtime.receipt_path.display() - )); - } - }; - validate_receipt(runtime, &receipt)?; - Ok(receipt) -} - -pub(super) fn validate_receipt( - runtime: &Runtime, - receipt: &InstallationReceipt, -) -> Result<(), String> { - if let Some(owner) = managed_path_owner(&runtime.current_executable) { - return Err(owner_guidance(owner)); - } - if receipt.owner != "github-release" { - return Err(owner_guidance(&receipt.owner)); - } - if receipt.schema_version != RECEIPT_SCHEMA_VERSION || receipt.repository != REPOSITORY { - return Err("install receipt has an unsupported schema or repository owner".to_owned()); - } - let version = parse_version(OsStr::new(&receipt.version))?; - if version != runtime.current_version { - return Err(format!( - "install receipt records stack {}, but the running binary is stack {}; no files were changed", - version, runtime.current_version - )); - } - if receipt.target != runtime.target { - return Err("install receipt target does not match the running binary".to_owned()); - } - validate_commit(&receipt.source_commit, "install receipt source commit")?; - let expected_schema = format!( - "https://raw.githubusercontent.com/{REPOSITORY}/{}/distribution/install-receipt.schema.json", - receipt.source_commit - ); - if receipt.schema != expected_schema { - return Err("install receipt schema URL does not match its source commit".to_owned()); - } - if receipt.archive.name != archive_name(&version, &runtime.target) { - return Err("install receipt archive name is inconsistent".to_owned()); - } - validate_digest(&receipt.archive.sha256, "install receipt archive")?; - validate_digest(&receipt.binary.sha256, "install receipt binary")?; - let Some(expected_path) = runtime.current_executable.to_str() else { - return Err("the executable path is not valid UTF-8".to_owned()); - }; - if receipt.binary.path != expected_path { - return Err(format!( - "install receipt belongs to '{}', not the running executable; no files were changed", - receipt.binary.path - )); - } - let actual_digest = sha256_file(&runtime.current_executable, MAX_BINARY_BYTES)?; - if actual_digest != receipt.binary.sha256 { - return Err( - "the running executable differs from its direct-install receipt; no files were changed" - .to_owned(), - ); - } - Ok(()) -} - -pub(super) fn missing_receipt_guidance(runtime: &Runtime) -> String { - let guidance = match managed_path_owner(&runtime.current_executable) { - Some("homebrew") => { - "This path appears to be owned by Homebrew; run `brew upgrade stack-sh/tap/stack`." - } - Some("aqua") => { - "This path appears to be owned by Aqua; update the version and checksum lock, then run `aqua install`." - } - Some("cargo") => { - "This path appears to be owned by Cargo; reinstall it through the Cargo package that installed `stack`." - } - _ => { - "Use the verified direct installer to create a receipt. Homebrew users should run `brew upgrade stack-sh/tap/stack`; Aqua users should update their version and checksum lock, then run `aqua install`; Cargo users should reinstall through the owning package." - } - }; - format!( - "no eligible direct-install receipt exists at '{}'; no files were changed. {guidance}", - runtime.receipt_path.display() - ) -} - -pub(super) fn managed_path_owner(executable: &Path) -> Option<&'static str> { - let executable = executable.to_string_lossy(); - if executable.contains("/Cellar/") - || executable.contains("/homebrew/") - || executable.contains("/linuxbrew/") - { - Some("homebrew") - } else if executable.contains("/aquaproj-aqua/") || executable.contains("/aqua/pkgs/") { - Some("aqua") - } else if executable.contains("/.cargo/bin/") { - Some("cargo") - } else { - None - } -} - -pub(super) fn owner_guidance(owner: &str) -> String { - match owner { - "homebrew" => { - "this executable is owned by Homebrew; run `brew upgrade stack-sh/tap/stack`" - .to_owned() - } - "aqua" => "this executable is owned by Aqua; update the version and checksum lock, then run `aqua install`".to_owned(), - "cargo" => "this executable is owned by Cargo; reinstall it through the Cargo package that created the receipt".to_owned(), - _ => "the install receipt names an unsupported owner; no files were changed".to_owned(), - } -} - -pub(super) fn archive_name(version: &Version, target: &str) -> String { - format!("stack-v{version}-{target}.tar.gz") -} - -pub(super) fn extract_binary( - archive_bytes: &[u8], - version: &Version, - target: &str, - source_date_epoch: u64, -) -> Result, String> { - let root = format!("stack-v{version}-{target}"); - let expected = [ - root.clone(), - format!("{root}/LICENSE"), - format!("{root}/NOTICE"), - format!("{root}/THIRD_PARTY_LICENSES.md"), - format!("{root}/share/bash-completion/completions/stack"), - format!("{root}/share/fish/vendor_completions.d/stack.fish"), - format!("{root}/share/man/man1/stack.1"), - format!("{root}/share/zsh/site-functions/_stack"), - format!("{root}/stack"), - ]; - let decoder = GzDecoder::new(Cursor::new(archive_bytes)); - let mut archive = tar::Archive::new(decoder); - let entries = match archive.entries() { - Ok(entries) => entries, - Err(_) => return Err("release archive cannot be read".to_owned()), - }; - let mut names = Vec::new(); - let mut binary = None; - let mut expanded_bytes = 0_u64; - - for entry in entries { - let mut entry = match entry { - Ok(entry) => entry, - Err(_) => return Err("release archive contains an invalid entry".to_owned()), - }; - let entry_path = match entry.path() { - Ok(path) => path, - Err(_) => return Err("release archive contains an invalid path".to_owned()), - }; - let Some(name) = entry_path.to_str() else { - return Err("release archive path is not valid UTF-8".to_owned()); - }; - names.push(name.to_owned()); - let size = entry.size(); - expanded_bytes = match expanded_bytes.checked_add(size) { - Some(total) => total, - None => return Err("release archive expanded size overflowed".to_owned()), - }; - if expanded_bytes > MAX_ARCHIVE_BYTES { - return Err("release archive expands beyond the 256 MiB limit".to_owned()); - } - let header = entry.header(); - let mode = match header.mode() { - Ok(mode) => mode, - Err(_) => return Err("release archive entry mode is invalid".to_owned()), - }; - let uid = match header.uid() { - Ok(uid) => uid, - Err(_) => return Err("release archive entry owner is invalid".to_owned()), - }; - let gid = match header.gid() { - Ok(gid) => gid, - Err(_) => return Err("release archive entry group is invalid".to_owned()), - }; - let mtime = match header.mtime() { - Ok(mtime) => mtime, - Err(_) => return Err("release archive entry timestamp is invalid".to_owned()), - }; - if uid != 0 || gid != 0 || mtime != source_date_epoch { - return Err("release archive ownership or timestamp is invalid".to_owned()); - } - if name == root { - if !header.entry_type().is_dir() || mode != 0o755 || size != 0 { - return Err("release archive root metadata is invalid".to_owned()); - } - continue; - } - if !header.entry_type().is_file() { - return Err("release archive links and special files are forbidden".to_owned()); - } - let expected_mode = if name == format!("{root}/stack") { - 0o755 - } else { - 0o644 - }; - if mode != expected_mode { - return Err("release archive entry mode is invalid".to_owned()); - } - if name == format!("{root}/stack") { - if size == 0 || size > MAX_BINARY_BYTES { - return Err("release archive binary size is invalid".to_owned()); - } - let mut bytes = Vec::with_capacity(size as usize); - if entry.read_to_end(&mut bytes).is_err() { - return Err("release archive binary cannot be read".to_owned()); - } - if bytes.len() as u64 != size { - return Err("release archive binary is truncated".to_owned()); - } - binary = Some(bytes); - } - } - if names != expected { - return Err("release archive entries or bytewise order are invalid".to_owned()); - } - match binary { - Some(binary) => Ok(binary), - None => Err("release archive does not contain the stack binary".to_owned()), - } -} - -pub(super) fn replace_binary_and_receipt( - runtime: &Runtime, - candidate_bytes: &[u8], - new_receipt: &InstallationReceipt, - binary_verifier: &dyn BinaryVerifier, -) -> Result, String> { - let current_metadata = match fs::symlink_metadata(&runtime.current_executable) { - Ok(metadata) => metadata, - Err(error) => { - return Err(format!( - "cannot inspect running executable '{}': {}", - runtime.current_executable.display(), - io_error(error) - )); - } - }; - if current_metadata.file_type().is_symlink() || !current_metadata.is_file() { - return Err("the running executable must be a regular file, not a symlink".to_owned()); - } - let receipt_metadata = match fs::symlink_metadata(&runtime.receipt_path) { - Ok(metadata) => metadata, - Err(error) => { - return Err(format!( - "cannot inspect install receipt '{}': {}", - runtime.receipt_path.display(), - io_error(error) - )); - } - }; - if receipt_metadata.file_type().is_symlink() || !receipt_metadata.is_file() { - return Err("the install receipt must remain a regular file during replacement".to_owned()); - } - let executable_parent = parent_directory(&runtime.current_executable)?; - let receipt_parent = parent_directory(&runtime.receipt_path)?; - let candidate = TemporaryFile::write( - executable_parent, - "update-binary", - candidate_bytes, - Some(current_metadata.permissions()), - )?; - binary_verifier.verify( - candidate.path(), - &parse_version(OsStr::new(&new_receipt.version))?, - )?; - - let mut receipt_bytes = match serde_json::to_vec_pretty(new_receipt) { - Ok(bytes) => bytes, - Err(_) => return Err("cannot serialize the updated install receipt".to_owned()), - }; - receipt_bytes.push(b'\n'); - let receipt = TemporaryFile::write( - receipt_parent, - "update-receipt", - &receipt_bytes, - Some(receipt_metadata.permissions()), - )?; - let backup = create_backup_link(executable_parent, &runtime.current_executable)?; - - if let Err(error) = fs::rename(candidate.path(), &runtime.current_executable) { - let _ = fs::remove_file(&backup); - return Err(format!( - "cannot replace the running executable: {}; the existing binary was not changed", - io_error(error) - )); - } - if let Err(error) = fs::rename(receipt.path(), &runtime.receipt_path) { - let rollback = fs::rename(&backup, &runtime.current_executable); - return match rollback { - Ok(()) => Err(format!( - "cannot commit the updated install receipt: {}; the original binary was restored", - io_error(error) - )), - Err(rollback_error) => Err(format!( - "cannot commit the updated install receipt ({}) or restore the original binary ({}); backup remains at '{}'", - io_error(error), - io_error(rollback_error), - backup.display() - )), - }; - } - - let warning = match fs::remove_file(&backup) { - Ok(()) => None, - Err(error) => Some(format!( - "the update succeeded but backup '{}' could not be removed: {}", - backup.display(), - io_error(error) - )), - }; - Ok(warning) -} - -pub(super) fn create_backup_link(parent: &Path, executable: &Path) -> Result { - for attempt in 0..128_u8 { - let candidate = parent.join(format!( - ".stack-update-backup-{}-{attempt}", - std::process::id() - )); - match fs::hard_link(executable, &candidate) { - Ok(()) => return Ok(candidate), - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} - Err(error) => { - return Err(format!( - "cannot create an update rollback link: {}; the existing binary was not changed", - io_error(error) - )); - } - } - } - Err("cannot reserve an update rollback path; the existing binary was not changed".to_owned()) -} - -pub(super) struct TemporaryFile { - path: PathBuf, -} - -impl TemporaryFile { - pub(super) fn write( - parent: &Path, - label: &str, - bytes: &[u8], - permissions: Option, - ) -> Result { - for attempt in 0..128_u8 { - let candidate = parent.join(format!(".stack-{label}-{}-{attempt}", std::process::id())); - match OpenOptions::new() - .write(true) - .create_new(true) - .open(&candidate) - { - Ok(mut file) => { - let prepared = file - .write_all(bytes) - .and_then(|()| match permissions { - Some(permissions) => file.set_permissions(permissions), - None => Ok(()), - }) - .and_then(|()| file.sync_all()); - drop(file); - if let Err(error) = prepared { - let _ = fs::remove_file(&candidate); - return Err(format!( - "cannot prepare verified update material: {}", - io_error(error) - )); - } - return Ok(Self { path: candidate }); - } - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} - Err(error) => { - return Err(format!( - "cannot create update material in '{}': {}", - parent.display(), - io_error(error) - )); - } - } - } - Err("cannot reserve a temporary update file".to_owned()) - } - - pub(super) fn path(&self) -> &Path { - &self.path - } -} - -impl Drop for TemporaryFile { - fn drop(&mut self) { - let _ = fs::remove_file(&self.path); - } -} - -pub(super) fn parent_directory(file: &Path) -> Result<&Path, String> { - match file.parent() { - Some(parent) if !parent.as_os_str().is_empty() => Ok(parent), - _ => Err(format!("'{}' has no parent directory", file.display())), - } -} - -pub(super) fn sha256_bytes(bytes: &[u8]) -> String { - digest_hex(&Sha256::digest(bytes)) -} - -pub(super) fn sha256_file(file: &Path, limit: u64) -> Result { - let metadata = match fs::symlink_metadata(file) { - Ok(metadata) => metadata, - Err(error) => { - return Err(format!( - "cannot inspect '{}': {}", - file.display(), - io_error(error) - )); - } - }; - if metadata.file_type().is_symlink() || !metadata.is_file() { - return Err(format!("'{}' must be a regular file", file.display())); - } - if metadata.len() == 0 || metadata.len() > limit { - return Err(format!("'{}' has an invalid size", file.display())); - } - let mut input = match File::open(file) { - Ok(input) => input, - Err(error) => { - return Err(format!( - "cannot read '{}': {}", - file.display(), - io_error(error) - )); - } - }; - let mut hash = Sha256::new(); - let mut buffer = [0_u8; 1024 * 1024]; - let mut total = 0_u64; - loop { - let read = match input.read(&mut buffer) { - Ok(read) => read, - Err(error) => { - return Err(format!( - "cannot read '{}': {}", - file.display(), - io_error(error) - )); - } - }; - if read == 0 { - break; - } - total = match total.checked_add(read as u64) { - Some(total) if total <= limit => total, - _ => return Err(format!("'{}' exceeds the size limit", file.display())), - }; - hash.update(&buffer[..read]); - } - Ok(digest_hex(&hash.finalize())) -} - -pub(super) fn digest_hex(digest: &[u8]) -> String { - let mut output = String::with_capacity(digest.len() * 2); - for byte in digest { - let _ = std::fmt::Write::write_fmt(&mut output, format_args!("{byte:02x}")); - } - output -} - -pub(super) fn validate_digest(digest: &str, label: &str) -> Result<(), String> { - if digest.len() == 64 - && digest - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - Ok(()) - } else { - Err(format!("{label} SHA-256 digest is invalid")) - } -} - -pub(super) fn validate_commit(commit: &str, label: &str) -> Result<(), String> { - if commit.len() == 40 - && commit - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) - { - Ok(()) - } else { - Err(format!("{label} is invalid")) - } -} - -pub(super) fn io_error(error: io::Error) -> &'static str { - match error.kind() { - io::ErrorKind::NotFound => "file not found", - io::ErrorKind::PermissionDenied => "permission denied", - io::ErrorKind::AlreadyExists => "already exists", - io::ErrorKind::InvalidInput => "invalid input", - _ => "I/O error", - } -} diff --git a/src/update/tests.rs b/src/update/tests.rs deleted file mode 100644 index 2bf7832..0000000 --- a/src/update/tests.rs +++ /dev/null @@ -1,1159 +0,0 @@ -use super::*; -use std::error::Error; -use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use std::thread::{self, JoinHandle}; -use std::time::Duration; - -use flate2::Compression; -use flate2::write::GzEncoder; -use serde_json::{Value, json}; - -type TestResult = Result>; - -static CASE_ID: AtomicU64 = AtomicU64::new(0); -const CURRENT_COMMIT: &str = "1111111111111111111111111111111111111111"; -const RELEASE_COMMIT: &str = "2222222222222222222222222222222222222222"; -const TARGET: &str = "x86_64-unknown-linux-gnu"; -const EPOCH: u64 = 1_788_566_400; - -fn boxed(message: String) -> Box { - Box::new(io::Error::other(message)) -} - -fn result_error(result: Result) -> TestResult { - match result { - Ok(_) => Err(io::Error::other("operation unexpectedly succeeded").into()), - Err(error) => Ok(error), - } -} - -struct TestDirectory { - root: PathBuf, -} - -impl TestDirectory { - fn new(label: &str) -> io::Result { - let id = CASE_ID.fetch_add(1, Ordering::Relaxed); - let root = env::temp_dir().join(format!( - "stack-update-test-{}-{id}-{label}", - std::process::id() - )); - fs::create_dir(&root)?; - Ok(Self { root }) - } - - fn path(&self, relative: &str) -> PathBuf { - self.root.join(relative) - } -} - -impl Drop for TestDirectory { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.root); - } -} - -struct LocalServer { - base: String, - hits: Arc>>, - stop: Arc, - thread: Option>, -} - -impl LocalServer { - fn start(routes: impl FnOnce(&str) -> BTreeMap>) -> io::Result { - let listener = TcpListener::bind("127.0.0.1:0")?; - listener.set_nonblocking(true)?; - let base = format!("http://{}", listener.local_addr()?); - let routes = Arc::new(routes(&base)); - let hits = Arc::new(Mutex::new(Vec::new())); - let stop = Arc::new(AtomicBool::new(false)); - let server_hits = Arc::clone(&hits); - let server_stop = Arc::clone(&stop); - let thread = thread::spawn(move || { - while !server_stop.load(Ordering::Relaxed) { - match listener.accept() { - Ok((stream, _)) => serve(stream, &routes, &server_hits), - Err(error) if error.kind() == io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(2)); - } - Err(_) => break, - } - } - }); - Ok(Self { - base, - hits, - stop, - thread: Some(thread), - }) - } - - fn base(&self) -> &str { - &self.base - } - - fn hits(&self) -> Vec { - match self.hits.lock() { - Ok(hits) => hits.clone(), - Err(_) => Vec::new(), - } - } -} - -impl Drop for LocalServer { - fn drop(&mut self) { - self.stop.store(true, Ordering::Relaxed); - if let Some(thread) = self.thread.take() { - let _ = thread.join(); - } - } -} - -fn serve(mut stream: TcpStream, routes: &BTreeMap>, hits: &Mutex>) { - let _ = stream.set_nonblocking(false); - let _ = stream.set_read_timeout(Some(Duration::from_secs(1))); - let mut request = Vec::new(); - let mut buffer = [0_u8; 1024]; - while request.len() < 8192 && !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { - match stream.read(&mut buffer) { - Ok(0) | Err(_) => break, - Ok(read) => request.extend_from_slice(&buffer[..read]), - } - } - let route = std::str::from_utf8(&request) - .ok() - .and_then(|request| request.lines().next()) - .and_then(|line| line.split_whitespace().nth(1)) - .unwrap_or("/") - .to_owned(); - if let Ok(mut hits) = hits.lock() { - hits.push(route.clone()); - } - let (status, body) = match routes.get(&route) { - Some(body) => ("200 OK", body.as_slice()), - None => ("404 Not Found", b"missing".as_slice()), - }; - let response = format!( - "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() - ); - let _ = stream.write_all(response.as_bytes()); - let _ = stream.write_all(body); -} - -fn add_archive_entry( - builder: &mut tar::Builder>>, - name: &str, - bytes: &[u8], - mode: u32, - kind: tar::EntryType, -) -> io::Result<()> { - let mut header = tar::Header::new_ustar(); - header.set_size(bytes.len() as u64); - header.set_mode(mode); - header.set_uid(0); - header.set_gid(0); - header.set_mtime(EPOCH); - header.set_entry_type(kind); - header.set_cksum(); - builder.append_data(&mut header, name, bytes) -} - -fn release_archive(version: &Version, candidate: &[u8]) -> TestResult> { - let encoder = GzEncoder::new(Vec::new(), Compression::default()); - let mut builder = tar::Builder::new(encoder); - let root = format!("stack-v{version}-{TARGET}"); - add_archive_entry(&mut builder, &root, &[], 0o755, tar::EntryType::Directory)?; - for (name, bytes) in [ - ("LICENSE", b"license".as_slice()), - ("NOTICE", b"notice".as_slice()), - ("THIRD_PARTY_LICENSES.md", b"third party".as_slice()), - ( - "share/bash-completion/completions/stack", - b"bash completion".as_slice(), - ), - ( - "share/fish/vendor_completions.d/stack.fish", - b"fish completion".as_slice(), - ), - ("share/man/man1/stack.1", b"manual page".as_slice()), - ( - "share/zsh/site-functions/_stack", - b"zsh completion".as_slice(), - ), - ] { - add_archive_entry( - &mut builder, - &format!("{root}/{name}"), - bytes, - 0o644, - tar::EntryType::Regular, - )?; - } - add_archive_entry( - &mut builder, - &format!("{root}/stack"), - candidate, - 0o755, - tar::EntryType::Regular, - )?; - let encoder = builder.into_inner()?; - Ok(encoder.finish()?) -} - -fn manifest_value(version: &Version, archive_digest: &str) -> Value { - let targets: Vec = SUPPORTED_TARGETS - .iter() - .map(|target| { - let archive_digest = if *target == TARGET { - archive_digest.to_owned() - } else { - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned() - }; - json!({ - "target": target, - "archive": { - "name": archive_name(version, target), - "sha256": archive_digest, - }, - "sbom": { - "name": format!("stack-v{version}-{target}.spdx.json"), - "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - }, - "provenance": { - "name": format!("stack-v{version}-{target}.provenance.sigstore.json"), - "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - }, - "sbomAttestation": { - "name": format!("stack-v{version}-{target}.sbom.sigstore.json"), - "sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", - }, - }) - }) - .collect(); - json!({ - "$schema": format!( - "https://raw.githubusercontent.com/{REPOSITORY}/{RELEASE_COMMIT}/distribution/release-manifest.schema.json" - ), - "schemaVersion": 1, - "version": version.to_string(), - "tag": format!("v{version}"), - "source": { "repository": REPOSITORY, "commit": RELEASE_COMMIT }, - "minimumSupportedCliVersion": "1.0.0", - "sourceDateEpoch": EPOCH, - "builderWorkflow": RELEASE_WORKFLOW, - "verifiedChannels": ["github-release", "self-update"], - "targets": targets, - }) -} - -fn release_response( - base: &str, - version: &Version, - manifest: &[u8], - archive_name: &str, - archive_size: usize, - archive_digest: &str, -) -> Vec { - let manifest_name = format!("stack-v{version}-release-manifest.json"); - serde_json::to_vec(&json!({ - "tag_name": format!("v{version}"), - "draft": false, - "prerelease": !version.pre.is_empty(), - "assets": [ - { - "name": manifest_name, - "state": "uploaded", - "size": manifest.len(), - "digest": format!("sha256:{}", sha256_bytes(manifest)), - "browser_download_url": format!("{base}/download/v{version}/{manifest_name}"), - }, - { - "name": archive_name, - "state": "uploaded", - "size": archive_size, - "digest": format!("sha256:{archive_digest}"), - "browser_download_url": format!("{base}/download/v{version}/{archive_name}"), - } - ] - })) - .unwrap_or_default() -} - -fn write_receipt(runtime: &Runtime) -> TestResult { - let receipt = InstallationReceipt::for_release( - runtime, - &runtime.current_version, - CURRENT_COMMIT, - &archive_name(&runtime.current_version, &runtime.target), - "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - &sha256_file(&runtime.current_executable, MAX_BINARY_BYTES).map_err(boxed)?, - ) - .map_err(boxed)?; - let parent = parent_directory(&runtime.receipt_path).map_err(boxed)?; - fs::create_dir_all(parent)?; - fs::write(&runtime.receipt_path, serde_json::to_vec_pretty(&receipt)?)?; - Ok(receipt) -} - -struct UpdateFixture { - _directory: TestDirectory, - runtime: Runtime, - client: ReleaseClient, - server: LocalServer, - candidate: Vec, - manifest: Vec, - archive: Vec, -} - -impl UpdateFixture { - fn new(tampered_archive: bool) -> TestResult { - let directory = TestDirectory::new("fixture")?; - let binary = directory.path("bin/stack"); - let receipt = directory.path("config/stack/install-receipt.json"); - let binary_parent = binary - .parent() - .ok_or_else(|| io::Error::other("binary parent is missing"))?; - fs::create_dir_all(binary_parent)?; - fs::write(&binary, b"old binary")?; - #[cfg(unix)] - fs::set_permissions(&binary, fs::Permissions::from_mode(0o755))?; - let runtime = Runtime { - current_version: Version::parse("1.0.0")?, - current_executable: fs::canonicalize(binary)?, - receipt_path: receipt, - target: TARGET.to_owned(), - }; - write_receipt(&runtime)?; - - let release_version = Version::parse("1.1.0")?; - let candidate = b"new verified binary".to_vec(); - let archive = release_archive(&release_version, &candidate)?; - let archive_digest = sha256_bytes(&archive); - let manifest = serde_json::to_vec(&manifest_value(&release_version, &archive_digest))?; - let archive_name = archive_name(&release_version, TARGET); - let mut served_archive = archive.clone(); - if tampered_archive { - served_archive.extend_from_slice(b"tampered"); - } - let response_archive_size = served_archive.len(); - let server = LocalServer::start(|base| { - let response = release_response( - base, - &release_version, - &manifest, - &archive_name, - response_archive_size, - &archive_digest, - ); - BTreeMap::from([ - ( - format!("/repos/{REPOSITORY}/releases/latest"), - response.clone(), - ), - ( - format!("/repos/{REPOSITORY}/releases/tags/v{release_version}"), - response, - ), - ( - format!( - "/download/v{release_version}/stack-v{release_version}-release-manifest.json" - ), - manifest.clone(), - ), - ( - format!("/download/v{release_version}/{archive_name}"), - served_archive, - ), - ]) - })?; - let client = ReleaseClient::for_test(server.base()); - Ok(Self { - _directory: directory, - runtime, - client, - server, - candidate, - manifest, - archive, - }) - } -} - -struct RecordingAttestation { - fail_on: Option, - subjects: Mutex>>, -} - -impl RecordingAttestation { - fn passing() -> Self { - Self { - fail_on: None, - subjects: Mutex::new(Vec::new()), - } - } - - fn subjects(&self) -> Vec> { - match self.subjects.lock() { - Ok(subjects) => subjects.clone(), - Err(_) => Vec::new(), - } - } -} - -impl AttestationVerifier for RecordingAttestation { - fn verify( - &self, - subject: &Path, - _version: &Version, - _source_commit: &str, - ) -> Result<(), String> { - let bytes = fs::read(subject) - .map_err(|error| format!("cannot read test subject: {}", io_error(error)))?; - let index = match self.subjects.lock() { - Ok(mut subjects) => { - let index = subjects.len(); - subjects.push(bytes); - index - } - Err(_) => return Err("test attestation recorder is unavailable".to_owned()), - }; - if self.fail_on == Some(index) { - Err("test attestation rejected the subject".to_owned()) - } else { - Ok(()) - } - } -} - -struct AcceptBinary(Vec); - -impl BinaryVerifier for AcceptBinary { - fn verify(&self, candidate: &Path, version: &Version) -> Result<(), String> { - if version != &Version::new(1, 1, 0) { - return Err("test candidate version differs".to_owned()); - } - let actual = fs::read(candidate) - .map_err(|error| format!("cannot read test candidate: {}", io_error(error)))?; - if actual != self.0 { - return Err("test candidate bytes differ".to_owned()); - } - Ok(()) - } -} - -#[test] -fn version_policy_accepts_stable_and_exact_release_candidates() -> TestResult { - for value in ["0.0.0", "1.2.3", "1.2.3-rc.1", "1.2.3-rc.42"] { - assert_eq!( - parse_version(OsStr::new(value)).map_err(boxed)?.to_string(), - value - ); - } - for value in [ - "1.2", - "v1.2.3", - "1.2.3+build", - "1.2.3-beta.1", - "1.2.3-rc.0", - "1.2.3-rc.01", - "1.2.3-rc.x", - ] { - assert!(parse_version(OsStr::new(value)).is_err(), "{value}"); - } - Ok(()) -} - -#[test] -fn local_server_update_replaces_binary_and_receipt() -> TestResult { - let fixture = UpdateFixture::new(false)?; - let attestation = RecordingAttestation::passing(); - let output = execute( - &Options { - check_only: false, - requested_version: None, - }, - &fixture.runtime, - &fixture.client, - &attestation, - &AcceptBinary(fixture.candidate.clone()), - ) - .map_err(boxed)?; - - assert!(output.contains("Updated stack 1.0.0 -> 1.1.0")); - assert_eq!( - fs::read(&fixture.runtime.current_executable)?, - fixture.candidate - ); - let receipt: InstallationReceipt = - serde_json::from_slice(&fs::read(&fixture.runtime.receipt_path)?)?; - assert_eq!(receipt.version, "1.1.0"); - assert_eq!(receipt.source_commit, RELEASE_COMMIT); - assert_eq!(receipt.binary.sha256, sha256_bytes(&fixture.candidate)); - assert_eq!( - attestation.subjects(), - vec![fixture.manifest, fixture.archive] - ); - assert_eq!(fixture.server.hits().len(), 3); - assert_eq!( - fs::read_dir(parent_directory(&fixture.runtime.current_executable).map_err(boxed)?)? - .count(), - 1 - ); - Ok(()) -} - -#[test] -fn check_only_resolves_metadata_without_receipt_or_download() -> TestResult { - let fixture = UpdateFixture::new(false)?; - fs::remove_file(&fixture.runtime.receipt_path)?; - let output = execute( - &Options { - check_only: true, - requested_version: None, - }, - &fixture.runtime, - &fixture.client, - &RecordingAttestation::passing(), - &AcceptBinary(fixture.candidate.clone()), - ) - .map_err(boxed)?; - assert!(output.contains("Update available: 1.0.0 -> 1.1.0")); - assert_eq!( - fs::read(&fixture.runtime.current_executable)?, - b"old binary" - ); - assert_eq!(fixture.server.hits().len(), 1); - Ok(()) -} - -#[test] -fn release_metadata_rejects_untrusted_api_values() -> TestResult { - let valid_digest = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - let cases = [ - (b"{".to_vec(), "invalid release metadata"), - ( - serde_json::to_vec(&json!({ - "tag_name": "v1.1.0", - "draft": true, - "prerelease": false, - "assets": [] - }))?, - "still a draft", - ), - ( - serde_json::to_vec(&json!({ - "tag_name": "1.1.0", - "draft": false, - "prerelease": false, - "assets": [] - }))?, - "tag is invalid", - ), - ( - serde_json::to_vec(&json!({ - "tag_name": "v1.1.0-rc.1", - "draft": false, - "prerelease": true, - "assets": [] - }))?, - "unexpectedly selected a prerelease", - ), - ( - serde_json::to_vec(&json!({ - "tag_name": "v1.1.0", - "draft": false, - "prerelease": true, - "assets": [] - }))?, - "prerelease metadata does not match", - ), - ( - serde_json::to_vec(&json!({ - "tag_name": "v1.1.0", - "draft": false, - "prerelease": false, - "assets": [{ - "name": "artifact", - "state": "uploaded", - "size": 1, - "digest": "not-sha256", - "browser_download_url": "https://example.com/artifact" - }] - }))?, - "has no SHA-256 digest", - ), - ( - serde_json::to_vec(&json!({ - "tag_name": "v1.1.0", - "draft": false, - "prerelease": false, - "assets": [{ - "name": "artifact", - "state": "new", - "size": 1, - "digest": format!("sha256:{valid_digest}"), - "browser_download_url": "https://example.com/artifact" - }] - }))?, - "is not available", - ), - ( - serde_json::to_vec(&json!({ - "tag_name": "v1.1.0", - "draft": false, - "prerelease": false, - "assets": [{ - "name": "artifact", - "state": "uploaded", - "size": 1, - "digest": format!("sha256:{valid_digest}"), - "browser_download_url": "https://example.com/artifact" - }] - }))?, - "unexpected download URL", - ), - ]; - - for (body, expected) in cases { - let server = LocalServer::start(move |_| { - BTreeMap::from([(format!("/repos/{REPOSITORY}/releases/latest"), body)]) - })?; - let error = result_error(ReleaseClient::for_test(server.base()).resolve_release(None))?; - assert!(error.contains(expected), "{expected}: {error}"); - assert_eq!(server.hits().len(), 1); - } - Ok(()) -} - -#[test] -fn release_resolution_handles_same_newer_and_explicit_rollback_directions() -> TestResult { - let fixture = UpdateFixture::new(false)?; - for (release_version, requested_version, check_only, expected) in [ - ("1.0.0", None, false, "already installed"), - ("0.9.0", None, false, "newer than latest stable"), - ( - "0.9.0", - Some(Version::new(0, 9, 0)), - true, - "Requested rollback available", - ), - ] { - let release_version = Version::parse(release_version)?; - let response_version = release_version.clone(); - let server = LocalServer::start(move |base| { - let response = serde_json::to_vec(&json!({ - "tag_name": format!("v{response_version}"), - "draft": false, - "prerelease": false, - "assets": [] - })) - .unwrap_or_default(); - BTreeMap::from([ - ( - format!("/repos/{REPOSITORY}/releases/latest"), - response.clone(), - ), - ( - format!("/repos/{REPOSITORY}/releases/tags/v{response_version}"), - response, - ), - (format!("{base}/unused"), Vec::new()), - ]) - })?; - let output = execute( - &Options { - check_only, - requested_version, - }, - &fixture.runtime, - &ReleaseClient::for_test(server.base()), - &RecordingAttestation::passing(), - &AcceptBinary(fixture.candidate.clone()), - ) - .map_err(boxed)?; - assert!(output.contains(expected), "{release_version}: {output}"); - assert_eq!(server.hits().len(), 1); - } - Ok(()) -} - -#[test] -fn tampered_archive_and_failed_attestations_preserve_the_binary() -> TestResult { - let tampered = UpdateFixture::new(true)?; - let error = result_error(execute( - &Options { - check_only: false, - requested_version: None, - }, - &tampered.runtime, - &tampered.client, - &RecordingAttestation::passing(), - &AcceptBinary(tampered.candidate.clone()), - ))?; - assert!( - error.contains("failed SHA-256 verification"), - "{error}; hits: {:?}", - tampered.server.hits() - ); - assert_eq!( - fs::read(&tampered.runtime.current_executable)?, - b"old binary" - ); - - for fail_on in [0, 1] { - let fixture = UpdateFixture::new(false)?; - let error = result_error(execute( - &Options { - check_only: false, - requested_version: None, - }, - &fixture.runtime, - &fixture.client, - &RecordingAttestation { - fail_on: Some(fail_on), - subjects: Mutex::new(Vec::new()), - }, - &AcceptBinary(fixture.candidate.clone()), - ))?; - assert!(error.contains("test attestation rejected")); - assert_eq!( - fs::read(&fixture.runtime.current_executable)?, - b"old binary" - ); - } - Ok(()) -} - -#[test] -fn package_manager_receipts_refuse_before_network_access() -> TestResult { - for (owner, guidance) in [ - ("homebrew", "brew upgrade"), - ("aqua", "aqua install"), - ("cargo", "Cargo"), - ("unknown", "unsupported owner"), - ] { - let fixture = UpdateFixture::new(false)?; - let mut receipt: InstallationReceipt = - serde_json::from_slice(&fs::read(&fixture.runtime.receipt_path)?)?; - receipt.owner = owner.to_owned(); - fs::write(&fixture.runtime.receipt_path, serde_json::to_vec(&receipt)?)?; - let error = result_error(execute( - &Options { - check_only: false, - requested_version: None, - }, - &fixture.runtime, - &fixture.client, - &RecordingAttestation::passing(), - &AcceptBinary(fixture.candidate.clone()), - ))?; - assert!(error.contains(guidance), "{owner}: {error}"); - assert!(fixture.server.hits().is_empty()); - assert_eq!( - fs::read(&fixture.runtime.current_executable)?, - b"old binary" - ); - } - - for (binary, owner, guidance) in [ - ("Cellar/stack/1.0.0/bin/stack", "homebrew", "brew upgrade"), - ("aquaproj-aqua/pkgs/stack", "aqua", "aqua install"), - ("user/.cargo/bin/stack", "cargo", "Cargo"), - ] { - let mut fixture = UpdateFixture::new(false)?; - let managed_binary = fixture._directory.path(binary); - let parent = parent_directory(&managed_binary).map_err(boxed)?; - fs::create_dir_all(parent)?; - fs::rename(&fixture.runtime.current_executable, &managed_binary)?; - fixture.runtime.current_executable = managed_binary; - let mut receipt: InstallationReceipt = - serde_json::from_slice(&fs::read(&fixture.runtime.receipt_path)?)?; - receipt.owner = "github-release".to_owned(); - receipt.binary.path = fixture - .runtime - .current_executable - .to_str() - .ok_or("managed test path is not UTF-8")? - .to_owned(); - fs::write(&fixture.runtime.receipt_path, serde_json::to_vec(&receipt)?)?; - - let error = result_error(execute( - &Options { - check_only: false, - requested_version: None, - }, - &fixture.runtime, - &fixture.client, - &RecordingAttestation::passing(), - &AcceptBinary(fixture.candidate.clone()), - ))?; - assert!(error.contains(guidance), "{owner}: {error}"); - assert!(fixture.server.hits().is_empty()); - assert_eq!( - fs::read(&fixture.runtime.current_executable)?, - b"old binary" - ); - } - Ok(()) -} - -struct ReplaceReceiptWithDirectory(PathBuf); - -impl BinaryVerifier for ReplaceReceiptWithDirectory { - fn verify(&self, _candidate: &Path, _version: &Version) -> Result<(), String> { - fs::remove_file(&self.0).map_err(|error| io_error(error).to_owned())?; - fs::create_dir(&self.0).map_err(|error| io_error(error).to_owned()) - } -} - -#[test] -fn receipt_commit_failure_rolls_back_the_original_binary() -> TestResult { - let fixture = UpdateFixture::new(false)?; - let new_receipt = InstallationReceipt::for_release( - &fixture.runtime, - &Version::new(1, 1, 0), - RELEASE_COMMIT, - &archive_name(&Version::new(1, 1, 0), TARGET), - &sha256_bytes(&fixture.archive), - &sha256_bytes(&fixture.candidate), - ) - .map_err(boxed)?; - let error = result_error(replace_binary_and_receipt( - &fixture.runtime, - &fixture.candidate, - &new_receipt, - &ReplaceReceiptWithDirectory(fixture.runtime.receipt_path.clone()), - ))?; - assert!(error.contains("original binary was restored")); - assert_eq!( - fs::read(&fixture.runtime.current_executable)?, - b"old binary" - ); - Ok(()) -} - -#[cfg(unix)] -#[test] -fn permission_failure_preserves_binary_and_receipt() -> TestResult { - let fixture = UpdateFixture::new(false)?; - let old_receipt = fs::read(&fixture.runtime.receipt_path)?; - let binary_parent = parent_directory(&fixture.runtime.current_executable).map_err(boxed)?; - let original_permissions = fs::metadata(binary_parent)?.permissions(); - fs::set_permissions(binary_parent, fs::Permissions::from_mode(0o555))?; - let new_receipt = InstallationReceipt::for_release( - &fixture.runtime, - &Version::new(1, 1, 0), - RELEASE_COMMIT, - &archive_name(&Version::new(1, 1, 0), TARGET), - &sha256_bytes(&fixture.archive), - &sha256_bytes(&fixture.candidate), - ) - .map_err(boxed)?; - let result = replace_binary_and_receipt( - &fixture.runtime, - &fixture.candidate, - &new_receipt, - &AcceptBinary(fixture.candidate.clone()), - ); - fs::set_permissions(binary_parent, original_permissions)?; - let error = result_error(result)?; - assert!(error.contains("permission denied")); - assert_eq!( - fs::read(&fixture.runtime.current_executable)?, - b"old binary" - ); - assert_eq!(fs::read(&fixture.runtime.receipt_path)?, old_receipt); - Ok(()) -} - -#[test] -fn receipt_validation_rejects_tampering_and_improves_missing_guidance() -> TestResult { - let fixture = UpdateFixture::new(false)?; - let receipt = read_and_validate_receipt(&fixture.runtime).map_err(boxed)?; - assert_eq!(receipt.version, "1.0.0"); - - let mut cases = Vec::new(); - let mut candidate = receipt.clone(); - candidate.schema_version = 2; - cases.push((candidate, "unsupported schema")); - let mut candidate = receipt.clone(); - candidate.version = "1.0.1".to_owned(); - cases.push((candidate, "running binary is stack 1.0.0")); - let mut candidate = receipt.clone(); - candidate.target = "aarch64-apple-darwin".to_owned(); - cases.push((candidate, "target does not match")); - let mut candidate = receipt.clone(); - candidate.source_commit = "bad".to_owned(); - cases.push((candidate, "source commit is invalid")); - let mut candidate = receipt.clone(); - candidate.schema = "https://example.com/schema.json".to_owned(); - cases.push((candidate, "schema URL does not match")); - let mut candidate = receipt.clone(); - candidate.archive.name = "wrong.tar.gz".to_owned(); - cases.push((candidate, "archive name is inconsistent")); - let mut candidate = receipt.clone(); - candidate.archive.sha256 = "bad".to_owned(); - cases.push((candidate, "archive SHA-256 digest is invalid")); - let mut candidate = receipt.clone(); - candidate.binary.sha256 = "bad".to_owned(); - cases.push((candidate, "binary SHA-256 digest is invalid")); - let mut candidate = receipt.clone(); - candidate.binary.path = "/other/stack".to_owned(); - cases.push((candidate, "not the running executable")); - for (candidate, expected) in cases { - let error = result_error(validate_receipt(&fixture.runtime, &candidate))?; - assert!(error.contains(expected), "{expected}: {error}"); - } - - fs::write(&fixture.runtime.current_executable, b"modified")?; - let error = result_error(read_and_validate_receipt(&fixture.runtime))?; - assert!(error.contains("differs from its direct-install receipt")); - - let missing_runtime = Runtime { - current_version: Version::new(1, 0, 0), - current_executable: PathBuf::from("/opt/homebrew/Cellar/stack/1.0.0/bin/stack"), - receipt_path: fixture._directory.path("missing.json"), - target: TARGET.to_owned(), - }; - assert!(result_error(read_and_validate_receipt(&missing_runtime))?.contains("brew upgrade")); - for (binary, guidance) in [ - ("/tmp/aquaproj-aqua/pkgs/stack", "aqua install"), - ("/tmp/user/.cargo/bin/stack", "Cargo"), - ("/tmp/custom/stack", "verified direct installer"), - ] { - let runtime = Runtime { - current_executable: PathBuf::from(binary), - ..missing_runtime.clone() - }; - assert!( - result_error(read_and_validate_receipt(&runtime))?.contains(guidance), - "{binary}" - ); - } - Ok(()) -} - -#[test] -fn manifest_validation_is_strict_and_target_complete() -> TestResult { - let fixture = UpdateFixture::new(false)?; - let version = Version::new(1, 1, 0); - let archive_digest = sha256_bytes(&fixture.archive); - let release = Release { - version: version.clone(), - assets: BTreeMap::new(), - }; - let validate = |value: &Value, runtime: &Runtime| { - let bytes = serde_json::to_vec(value).map_err(|error| error.to_string())?; - validate_manifest( - &bytes, - &release, - runtime, - &archive_name(&version, TARGET), - &archive_digest, - ) - .map(|_| ()) - }; - let valid = manifest_value(&version, &archive_digest); - validate(&valid, &fixture.runtime).map_err(boxed)?; - - let mut cases = Vec::new(); - let mut value = valid.clone(); - value["source"]["repository"] = json!("other/repository"); - cases.push((value, "unexpected source repository")); - let mut value = valid.clone(); - value["source"]["commit"] = json!("bad"); - cases.push((value, "source commit is invalid")); - let mut value = valid.clone(); - value["builderWorkflow"] = json!("other.yaml"); - cases.push((value, "source or workflow evidence")); - let mut value = valid.clone(); - value["minimumSupportedCliVersion"] = json!("2.0.0"); - cases.push((value, "newer than the release")); - let mut value = valid.clone(); - value["verifiedChannels"] = json!(["self-update"]); - cases.push((value, "has not activated")); - let mut value = valid.clone(); - value["verifiedChannels"] = json!(["self-update", "github-release"]); - cases.push((value, "verified channels are invalid")); - let mut value = valid.clone(); - value["verifiedChannels"] = json!(["github-release", "unknown"]); - cases.push((value, "verified channels are invalid")); - let mut value = valid.clone(); - value["targets"][0]["target"] = json!("unsupported-target"); - cases.push((value, "archive name is invalid")); - let mut value = valid.clone(); - value["targets"][0]["archive"]["sha256"] = json!("bad"); - cases.push((value, "archive SHA-256 digest is invalid")); - let mut value = valid.clone(); - value["targets"][0]["sbom"]["name"] = json!("wrong.json"); - cases.push((value, "SBOM name is invalid")); - let mut value = valid.clone(); - let first = value["targets"][0].clone(); - value["targets"][1] = first; - cases.push((value, "duplicate targets")); - let mut value = valid.clone(); - value["unexpected"] = json!(true); - cases.push((value, "unsupported fields")); - - for (value, expected) in cases { - let error = result_error(validate(&value, &fixture.runtime))?; - assert!(error.contains(expected), "{expected}: {error}"); - } - - let mut too_old = fixture.runtime.clone(); - too_old.current_version = Version::new(0, 9, 0); - assert!(result_error(validate(&valid, &too_old))?.contains("minimum supported updater")); - Ok(()) -} - -#[test] -fn archive_validation_checks_metadata_and_exact_layout() -> TestResult { - let version = Version::new(1, 1, 0); - let candidate = b"candidate"; - let archive = release_archive(&version, candidate)?; - assert_eq!( - extract_binary(&archive, &version, TARGET, EPOCH).map_err(boxed)?, - candidate - ); - assert!( - result_error(extract_binary(&archive, &version, TARGET, EPOCH + 1))?.contains("timestamp") - ); - let mut damaged = archive; - damaged.truncate(damaged.len() / 2); - assert!(extract_binary(&damaged, &version, TARGET, EPOCH).is_err()); - Ok(()) -} - -#[cfg(unix)] -#[test] -fn command_verifiers_enforce_identity_and_embedded_version() -> TestResult { - let directory = TestDirectory::new("commands")?; - let log = directory.path("arguments.txt"); - let gh = directory.path("gh"); - fs::write( - &gh, - format!( - "#!/bin/sh\nprintf 'GH_HOST=%s\\nGH_PROMPT_DISABLED=%s\\n' \"$GH_HOST\" \"$GH_PROMPT_DISABLED\" > '{}'\nprintf '%s\\n' \"$@\" >> '{}'\n", - log.display(), - log.display() - ), - )?; - fs::set_permissions(&gh, fs::Permissions::from_mode(0o755))?; - let subject = directory.path("subject"); - fs::write(&subject, b"subject")?; - GitHubAttestationVerifier { - command: gh.clone(), - } - .verify(&subject, &Version::new(1, 2, 3), RELEASE_COMMIT) - .map_err(boxed)?; - let arguments = fs::read_to_string(log)?; - assert_eq!( - arguments, - format!( - "GH_HOST=github.com\nGH_PROMPT_DISABLED=1\nattestation\nverify\n{}\n--repo\nstack-sh/cli\n--cert-identity\nhttps://github.com/stack-sh/cli/.github/workflows/release.yaml@refs/tags/v1.2.3\n--cert-oidc-issuer\nhttps://token.actions.githubusercontent.com\n--deny-self-hosted-runners\n--source-ref\nrefs/tags/v1.2.3\n--source-digest\n{RELEASE_COMMIT}\n--predicate-type\nhttps://slsa.dev/provenance/v1\n--limit\n5\n", - subject.display() - ) - ); - - let missing = GitHubAttestationVerifier { - command: directory.path("missing-gh"), - }; - assert!( - result_error(missing.verify(&subject, &Version::new(1, 2, 3), RELEASE_COMMIT))? - .contains("GitHub CLI") - ); - - let binary = directory.path("candidate"); - fs::write(&binary, b"#!/bin/sh\nprintf 'stack 1.2.3\\n'\n")?; - fs::set_permissions(&binary, fs::Permissions::from_mode(0o755))?; - ExecutableVersionVerifier - .verify(&binary, &Version::new(1, 2, 3)) - .map_err(boxed)?; - assert!( - result_error(ExecutableVersionVerifier.verify(&binary, &Version::new(1, 2, 4)))? - .contains("did not report") - ); - - let fixture = UpdateFixture::new(false)?; - let error = result_error(run_integration_test( - "1.0.0", - &fixture.runtime.current_executable, - &fixture.runtime.receipt_path, - TARGET, - fixture.server.base(), - &gh, - ))?; - assert!(error.contains("verified update candidate"), "{error}"); - Ok(()) -} - -#[test] -fn exact_current_version_is_a_network_free_noop() -> TestResult { - let fixture = UpdateFixture::new(false)?; - let output = execute( - &Options { - check_only: false, - requested_version: Some(fixture.runtime.current_version.clone()), - }, - &fixture.runtime, - &fixture.client, - &RecordingAttestation::passing(), - &AcceptBinary(fixture.candidate.clone()), - ) - .map_err(boxed)?; - assert!(output.contains("already installed")); - assert!(fixture.server.hits().is_empty()); - assert!( - run_integration_noop() - .map_err(boxed)? - .contains("already installed") - ); - Ok(()) -} - -#[test] -fn helper_validation_is_fail_closed() -> TestResult { - assert!(validate_digest(&"a".repeat(64), "test").is_ok()); - assert!(validate_digest(&"A".repeat(64), "test").is_err()); - assert!(validate_commit(&"a".repeat(40), "test").is_ok()); - assert!(validate_commit(&"g".repeat(40), "test").is_err()); - assert_eq!(digest_hex(&[0, 15, 255]), "000fff"); - assert_eq!( - io_error(io::Error::from(io::ErrorKind::NotFound)), - "file not found" - ); - assert_eq!( - io_error(io::Error::from(io::ErrorKind::PermissionDenied)), - "permission denied" - ); - assert_eq!( - io_error(io::Error::from(io::ErrorKind::AlreadyExists)), - "already exists" - ); - assert_eq!( - io_error(io::Error::from(io::ErrorKind::InvalidInput)), - "invalid input" - ); - assert_eq!(io_error(io::Error::other("private")), "I/O error"); - assert_eq!( - managed_path_owner(Path::new("/opt/homebrew/Cellar/stack/1/bin/stack")), - Some("homebrew") - ); - assert_eq!( - managed_path_owner(Path::new("/tmp/aquaproj-aqua/pkgs/stack")), - Some("aqua") - ); - assert_eq!( - managed_path_owner(Path::new("/tmp/user/.cargo/bin/stack")), - Some("cargo") - ); - assert_eq!(managed_path_owner(Path::new("/opt/stack/bin/stack")), None); - Ok(()) -} diff --git a/tests/cli.rs b/tests/cli.rs index 80b11fd..bff1e7d 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -12,6 +12,39 @@ use serde_json::{Value, json}; static CASE_ID: AtomicU64 = AtomicU64::new(0); +#[test] +fn removed_updater_is_not_executable_or_advertised() -> Result<(), Box> { + let directory = TestDirectory::new("removed-update")?; + let receipt = directory.file("install-receipt.json", b"preserve user data")?; + for arguments in [ + vec!["update"], + vec!["update", "--check"], + vec!["help", "update"], + vec!["update", "--version", "0.4.0"], + ] { + let output = stack_in(&directory.path, arguments)?; + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + assert!(String::from_utf8(output.stderr)?.contains("unknown command")); + } + assert_eq!(fs::read(receipt)?, b"preserve user data"); + assert_eq!(fs::read_dir(&directory.path)?.count(), 1); + for arguments in [ + vec!["help"], + vec!["manpage"], + vec!["completions", "bash"], + vec!["completions", "zsh"], + vec!["completions", "fish"], + ] { + let output = stack(arguments)?; + assert!(output.status.success()); + let text = String::from_utf8(output.stdout)?; + assert!(!text.contains("stack update")); + assert!(!text.contains("direct-install update")); + } + Ok(()) +} + #[test] fn agent_skill_commands_validate_and_render_source() -> Result<(), Box> { let directory = TestDirectory::new("agent-skill")?; @@ -675,14 +708,6 @@ fn help_snapshots_and_aliases_are_stdout_only() -> Result<(), Box> { &["help", "render"], include_bytes!("snapshots/render-help.txt"), ), - ( - &["update", "--help"], - include_bytes!("snapshots/update-help.txt"), - ), - ( - &["help", "update"], - include_bytes!("snapshots/update-help.txt"), - ), (&["lsp", "--help"], include_bytes!("snapshots/lsp-help.txt")), (&["help", "lsp"], include_bytes!("snapshots/lsp-help.txt")), ( diff --git a/tests/snapshots/help-help.txt b/tests/snapshots/help-help.txt index 2caeca0..26611a1 100644 --- a/tests/snapshots/help-help.txt +++ b/tests/snapshots/help-help.txt @@ -7,7 +7,7 @@ Usage: stack help icons Arguments: - init, check, fmt, render, update, lsp, doctor, config, icons, + init, check, fmt, render, lsp, doctor, config, icons, completions, manpage, help, or version Options: diff --git a/tests/snapshots/help.txt b/tests/snapshots/help.txt index 7b66456..edc2825 100644 --- a/tests/snapshots/help.txt +++ b/tests/snapshots/help.txt @@ -9,7 +9,6 @@ Commands: check Validate a Stack source file without modifying it fmt Format a file in place or read from standard input render Render standalone SVG to standard output or a file - update Check for or install a verified direct-install update lsp Run the Stack language server over standard input and output doctor Diagnose CLI configuration and provider icon packs config Inspect effective read-only configuration @@ -29,7 +28,6 @@ Examples: stack check arch.stack stack fmt --check arch.stack stack render arch.stack -o arch.svg - stack update --check stack lsp stack doctor stack config get default_icons_path diff --git a/tests/snapshots/update-help.txt b/tests/snapshots/update-help.txt deleted file mode 100644 index e34642e..0000000 --- a/tests/snapshots/update-help.txt +++ /dev/null @@ -1,23 +0,0 @@ -Check for or install a verified direct-install update - -Usage: - stack update - stack update --check - stack update --version - -Options: - --check Resolve an update without downloading or changing files - --version Select an exact stable or MAJOR.MINOR.PATCH-rc.N release - -h, --help Print help - -Safety: - Replacement requires a matching direct-install receipt and a GitHub CLI - artifact-attestation check for the exact repository, workflow, tag, commit, - and GitHub-hosted runner. Homebrew, Aqua, Cargo, and unknown installs are - never replaced. - -Examples: - stack update --check - stack update - stack update --version 0.4.0 - stack update --version 0.4.0-rc.1 diff --git a/tests/update.rs b/tests/update.rs deleted file mode 100644 index 4258276..0000000 --- a/tests/update.rs +++ /dev/null @@ -1,472 +0,0 @@ -use std::collections::BTreeMap; -use std::env; -use std::error::Error; -use std::ffi::OsStr; -use std::fs; -use std::io::{self, Read, Write}; -use std::net::{TcpListener, TcpStream}; -use std::path::PathBuf; -use std::process::{Command, Output}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex}; -use std::thread::{self, JoinHandle}; -use std::time::Duration; - -use flate2::Compression; -use flate2::write::GzEncoder; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; - -static CASE_ID: AtomicU64 = AtomicU64::new(0); - -struct TestDirectory { - path: PathBuf, -} - -impl TestDirectory { - fn new(label: &str) -> Result> { - let case_id = CASE_ID.fetch_add(1, Ordering::Relaxed); - let path = env::temp_dir().join(format!( - "stack-cli-{}-{label}-{case_id}", - std::process::id() - )); - fs::create_dir(&path)?; - Ok(Self { path }) - } -} - -impl Drop for TestDirectory { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.path); - } -} - -fn stack(arguments: impl IntoIterator>) -> Result> { - Ok(Command::new(env!("CARGO_BIN_EXE_stack")) - .args(arguments) - .env( - "XDG_CONFIG_HOME", - env::temp_dir().join(format!("stack-cli-empty-config-{}", std::process::id())), - ) - .output()?) -} -fn assert_stdout_only(arguments: &[&str], expected: &[u8]) -> Result<(), Box> { - let output = stack(arguments.iter().copied())?; - assert_eq!(output.status.code(), Some(0), "arguments: {arguments:?}"); - assert_eq!(output.stdout, expected, "arguments: {arguments:?}"); - assert!(output.stderr.is_empty(), "arguments: {arguments:?}"); - Ok(()) -} - -fn sha256(bytes: &[u8]) -> String { - let mut output = String::with_capacity(64); - for byte in Sha256::digest(bytes) { - let _ = std::fmt::Write::write_fmt(&mut output, format_args!("{byte:02x}")); - } - output -} - -fn append_tar_entry( - builder: &mut tar::Builder>>, - name: &str, - bytes: &[u8], - mode: u32, - kind: tar::EntryType, - epoch: u64, -) -> Result<(), Box> { - let mut header = tar::Header::new_ustar(); - header.set_size(bytes.len() as u64); - header.set_mode(mode); - header.set_uid(0); - header.set_gid(0); - header.set_mtime(epoch); - header.set_entry_type(kind); - header.set_cksum(); - builder.append_data(&mut header, name, bytes)?; - Ok(()) -} - -fn update_archive( - version: &str, - target: &str, - candidate: &[u8], - epoch: u64, -) -> Result, Box> { - let encoder = GzEncoder::new(Vec::new(), Compression::default()); - let mut builder = tar::Builder::new(encoder); - let root = format!("stack-v{version}-{target}"); - append_tar_entry( - &mut builder, - &root, - &[], - 0o755, - tar::EntryType::Directory, - epoch, - )?; - for (name, bytes) in [ - ("LICENSE", b"license".as_slice()), - ("NOTICE", b"notice".as_slice()), - ("THIRD_PARTY_LICENSES.md", b"third party".as_slice()), - ( - "share/bash-completion/completions/stack", - b"bash completion".as_slice(), - ), - ( - "share/fish/vendor_completions.d/stack.fish", - b"fish completion".as_slice(), - ), - ("share/man/man1/stack.1", b"manual page".as_slice()), - ( - "share/zsh/site-functions/_stack", - b"zsh completion".as_slice(), - ), - ] { - append_tar_entry( - &mut builder, - &format!("{root}/{name}"), - bytes, - 0o644, - tar::EntryType::Regular, - epoch, - )?; - } - append_tar_entry( - &mut builder, - &format!("{root}/stack"), - candidate, - 0o755, - tar::EntryType::Regular, - epoch, - )?; - let encoder = builder.into_inner()?; - Ok(encoder.finish()?) -} - -fn update_target() -> Result<&'static str, Box> { - match (env::consts::OS, env::consts::ARCH) { - ("macos", "aarch64") => Ok("aarch64-apple-darwin"), - ("macos", "x86_64") => Ok("x86_64-apple-darwin"), - ("linux", "aarch64") => Ok("aarch64-unknown-linux-gnu"), - ("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu"), - _ => Err("unsupported update integration-test host".into()), - } -} - -struct UpdateServer { - base: String, - hits: Arc>>, - stop: Arc, - thread: Option>, -} - -impl UpdateServer { - fn start( - routes: impl FnOnce(&str) -> BTreeMap>, - ) -> Result> { - let listener = TcpListener::bind("127.0.0.1:0")?; - listener.set_nonblocking(true)?; - let base = format!("http://{}", listener.local_addr()?); - let routes = Arc::new(routes(&base)); - let hits = Arc::new(Mutex::new(Vec::new())); - let stop = Arc::new(AtomicBool::new(false)); - let server_hits = Arc::clone(&hits); - let server_stop = Arc::clone(&stop); - let thread = thread::spawn(move || { - while !server_stop.load(Ordering::Relaxed) { - match listener.accept() { - Ok((stream, _)) => serve_update_route(stream, &routes, &server_hits), - Err(error) if error.kind() == io::ErrorKind::WouldBlock => { - thread::sleep(Duration::from_millis(2)); - } - Err(_) => break, - } - } - }); - Ok(Self { - base, - hits, - stop, - thread: Some(thread), - }) - } - - fn hits(&self) -> Vec { - match self.hits.lock() { - Ok(hits) => hits.clone(), - Err(_) => Vec::new(), - } - } -} - -impl Drop for UpdateServer { - fn drop(&mut self) { - self.stop.store(true, Ordering::Relaxed); - if let Some(thread) = self.thread.take() { - let _ = thread.join(); - } - } -} - -fn serve_update_route( - mut stream: TcpStream, - routes: &BTreeMap>, - hits: &Mutex>, -) { - let _ = stream.set_nonblocking(false); - let _ = stream.set_read_timeout(Some(Duration::from_secs(1))); - let mut request = Vec::new(); - let mut buffer = [0_u8; 1024]; - while request.len() < 8192 && !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { - match stream.read(&mut buffer) { - Ok(0) | Err(_) => break, - Ok(read) => request.extend_from_slice(&buffer[..read]), - } - } - let route = std::str::from_utf8(&request) - .ok() - .and_then(|request| request.lines().next()) - .and_then(|line| line.split_whitespace().nth(1)) - .unwrap_or("/") - .to_owned(); - if let Ok(mut hits) = hits.lock() { - hits.push(route.clone()); - } - let (status, body) = match routes.get(&route) { - Some(body) => ("200 OK", body.as_slice()), - None => ("404 Not Found", b"missing".as_slice()), - }; - let header = format!( - "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() - ); - let _ = stream.write_all(header.as_bytes()); - let _ = stream.write_all(body); -} -#[test] -fn update_argument_contract_is_local_and_fail_closed() -> Result<(), Box> { - assert_stdout_only( - &["update", "--check", "--version", env!("CARGO_PKG_VERSION")], - format!( - "stack {} is already installed; no files were changed.\n", - env!("CARGO_PKG_VERSION") - ) - .as_bytes(), - )?; - - let cases: &[(&[&str], &str)] = &[ - ( - &["update", "--check", "--check"], - "duplicate '--check' option", - ), - ( - &["update", "--version", "1.0.0", "--version", "1.0.1"], - "duplicate '--version' option", - ), - ( - &["update", "--version"], - "missing version after '--version'", - ), - ( - &["update", "--version", "--check"], - "missing version after '--version'", - ), - (&["update", "--version", "1.0"], "update version must be"), - (&["update", "--version", "1.0.0-beta.1"], "only an exact"), - (&["update", "extra"], "unexpected argument 'extra'"), - ( - &["update", "--help", "extra"], - "unexpected argument 'extra'", - ), - ]; - for (arguments, expected) in cases { - let output = stack(arguments.iter().copied())?; - assert_eq!(output.status.code(), Some(2), "arguments: {arguments:?}"); - assert!(output.stdout.is_empty(), "arguments: {arguments:?}"); - assert!( - String::from_utf8(output.stderr)?.contains(expected), - "arguments: {arguments:?}" - ); - } - Ok(()) -} - -#[cfg(unix)] -#[test] -fn update_binary_integrates_local_release_verification_and_atomic_replacement() --> Result<(), Box> { - use std::os::unix::fs::PermissionsExt; - - let directory = TestDirectory::new("update-process")?; - let install_directory = directory.path.join("install"); - let tool_directory = directory.path.join("tools"); - let config_directory = directory.path.join("config"); - fs::create_dir_all(&install_directory)?; - fs::create_dir_all(&tool_directory)?; - fs::create_dir_all(config_directory.join("stack"))?; - - let installed = install_directory.join("stack"); - fs::copy(env!("CARGO_BIN_EXE_stack"), &installed)?; - fs::set_permissions(&installed, fs::Permissions::from_mode(0o755))?; - let installed = installed.canonicalize()?; - let current_bytes = fs::read(&installed)?; - let target = update_target()?; - let current_version = env!("CARGO_PKG_VERSION"); - let update_version = "0.4.1"; - let epoch = 1_788_566_400_u64; - let current_commit = "1111111111111111111111111111111111111111"; - let release_commit = "2222222222222222222222222222222222222222"; - - let receipt_path = config_directory.join("stack/install-receipt.json"); - fs::write( - &receipt_path, - serde_json::to_vec_pretty(&json!({ - "$schema": format!( - "https://raw.githubusercontent.com/stack-sh/cli/{current_commit}/distribution/install-receipt.schema.json" - ), - "schemaVersion": 1, - "owner": "github-release", - "repository": "stack-sh/cli", - "version": current_version, - "target": target, - "sourceCommit": current_commit, - "archive": { - "name": format!("stack-v{current_version}-{target}.tar.gz"), - "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - }, - "binary": { - "path": installed.to_str().ok_or("installed path is not UTF-8")?, - "sha256": sha256(¤t_bytes) - } - }))?, - )?; - - let candidate = b"#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then printf 'stack 0.4.1\\n'; exit 0; fi\nexit 2\n"; - let archive = update_archive(update_version, target, candidate, epoch)?; - let archive_digest = sha256(&archive); - let supported_targets = [ - "aarch64-apple-darwin", - "aarch64-unknown-linux-gnu", - "x86_64-apple-darwin", - "x86_64-unknown-linux-gnu", - ]; - let targets: Vec = supported_targets - .iter() - .map(|release_target| { - json!({ - "target": release_target, - "archive": { - "name": format!("stack-v{update_version}-{release_target}.tar.gz"), - "sha256": if *release_target == target { - archive_digest.clone() - } else { - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_owned() - } - }, - "sbom": { - "name": format!("stack-v{update_version}-{release_target}.spdx.json"), - "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - }, - "provenance": { - "name": format!("stack-v{update_version}-{release_target}.provenance.sigstore.json"), - "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - }, - "sbomAttestation": { - "name": format!("stack-v{update_version}-{release_target}.sbom.sigstore.json"), - "sha256": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" - } - }) - }) - .collect(); - let manifest = serde_json::to_vec(&json!({ - "$schema": format!( - "https://raw.githubusercontent.com/stack-sh/cli/{release_commit}/distribution/release-manifest.schema.json" - ), - "schemaVersion": 1, - "version": update_version, - "tag": format!("v{update_version}"), - "source": { "repository": "stack-sh/cli", "commit": release_commit }, - "minimumSupportedCliVersion": current_version, - "sourceDateEpoch": epoch, - "builderWorkflow": "stack-sh/cli/.github/workflows/release.yaml", - "verifiedChannels": ["github-release", "self-update"], - "targets": targets - }))?; - let manifest_name = format!("stack-v{update_version}-release-manifest.json"); - let archive_name = format!("stack-v{update_version}-{target}.tar.gz"); - let server = UpdateServer::start(|base| { - let response = serde_json::to_vec(&json!({ - "tag_name": format!("v{update_version}"), - "draft": false, - "prerelease": false, - "assets": [ - { - "name": manifest_name, - "state": "uploaded", - "size": manifest.len(), - "digest": format!("sha256:{}", sha256(&manifest)), - "browser_download_url": format!("{base}/download/v{update_version}/{manifest_name}") - }, - { - "name": archive_name, - "state": "uploaded", - "size": archive.len(), - "digest": format!("sha256:{archive_digest}"), - "browser_download_url": format!("{base}/download/v{update_version}/{archive_name}") - } - ] - })) - .unwrap_or_default(); - BTreeMap::from([ - ("/repos/stack-sh/cli/releases/latest".to_owned(), response), - ( - format!("/download/v{update_version}/{manifest_name}"), - manifest, - ), - ( - format!("/download/v{update_version}/{archive_name}"), - archive, - ), - ]) - })?; - - let gh_log = directory.path.join("gh-calls.txt"); - let gh = tool_directory.join("gh"); - fs::write( - &gh, - format!( - "#!/bin/sh\nprintf 'verified\\n' >> '{}'\n", - gh_log.display() - ), - )?; - fs::set_permissions(&gh, fs::Permissions::from_mode(0o755))?; - let existing_paths = env::var_os("PATH") - .map(|value| env::split_paths(&value).collect::>()) - .unwrap_or_default(); - let command_paths = env::join_paths(std::iter::once(tool_directory).chain(existing_paths))?; - - let output = Command::new(&installed) - .arg("update") - .env("XDG_CONFIG_HOME", &config_directory) - .env("STACK_CLI_TEST_UPDATE_BASE_URL", &server.base) - .env("PATH", command_paths) - .output()?; - assert_eq!(output.status.code(), Some(0)); - assert!( - output.stderr.is_empty(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - assert!(String::from_utf8(output.stdout)?.contains("Updated stack 0.4.0 -> 0.4.1")); - assert_eq!(fs::read(&installed)?, candidate); - assert_eq!(fs::read_to_string(&gh_log)?, "verified\nverified\n"); - assert_eq!(server.hits().len(), 3); - - let version = Command::new(&installed).arg("--version").output()?; - assert_eq!(version.status.code(), Some(0)); - assert_eq!(version.stdout, b"stack 0.4.1\n"); - let receipt: Value = serde_json::from_slice(&fs::read(receipt_path)?)?; - assert_eq!(receipt["version"], "0.4.1"); - assert_eq!(receipt["sourceCommit"], release_commit); - assert_eq!(receipt["binary"]["sha256"], sha256(candidate)); - Ok(()) -}