From b65e6b2cd16b622050671d5aa04394410e44c417 Mon Sep 17 00:00:00 2001 From: konojunya Date: Sat, 5 Sep 2026 17:12:11 +0900 Subject: [PATCH] Add verified CLI self-update --- .github/workflows/ci.yaml | 8 + .github/workflows/release.yaml | 28 +- Cargo.lock | 29 + Cargo.toml | 3 + README.md | 3 + THIRD_PARTY_LICENSES.md | 4 +- distribution/distribution-contract.json | 13 +- .../distribution-contract.schema.json | 49 +- distribution/install-receipt.schema.json | 67 + docs/distribution.md | 6 +- docs/self-update.md | 51 + docs/supply-chain.md | 13 +- scripts/distribution-contract.test.mjs | 14 + scripts/release-workflow.test.mjs | 18 +- scripts/resolve-release-context.mjs | 41 +- scripts/resolve-release-context.test.mjs | 49 + scripts/validate-distribution-contract.mjs | 18 + scripts/validate-release-workflow.mjs | 16 +- scripts/verify_release_binary.py | 8 + src/config.rs | 12 + src/lib.rs | 99 +- src/update.rs | 736 +++++++++++ src/update/install.rs | 660 ++++++++++ src/update/tests.rs | 1146 +++++++++++++++++ tests/cli.rs | 8 + tests/snapshots/help-help.txt | 2 +- tests/snapshots/help.txt | 2 + tests/snapshots/update-help.txt | 23 + tests/update.rs | 459 +++++++ 29 files changed, 3564 insertions(+), 21 deletions(-) create mode 100644 distribution/install-receipt.schema.json create mode 100644 docs/self-update.md create mode 100644 src/update.rs create mode 100644 src/update/install.rs create mode 100644 src/update/tests.rs create mode 100644 tests/snapshots/update-help.txt create mode 100644 tests/update.rs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index e83b13e..07fef86 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -124,6 +124,7 @@ 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 icons --help ./target/release/stack icons list @@ -139,6 +140,7 @@ jobs: test -s CONTRIBUTING.md test -s SECURITY.md test -s docs/language-server.md + test -s docs/self-update.md test -s docs/publication-audit.md test -s docs/provider-icon-import.md test -s docs/distribution.md @@ -147,11 +149,15 @@ jobs: test -s aqua/registry.yaml test -s distribution/distribution-contract.json test -s distribution/distribution-contract.schema.json + test -s distribution/install-receipt.schema.json test -s distribution/release-manifest.schema.json test -s Cargo.toml test -s Cargo.lock test -s src/config.rs test -s src/lsp.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 @@ -190,6 +196,8 @@ jobs: test -s tests/specification-revision test -s tests/fixtures/render.stack test -s tests/snapshots/lsp-help.txt + test -s tests/snapshots/update-help.txt + test -s tests/update.rs msrv: name: Minimum supported Rust diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1ccd637..7c84147 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -26,6 +26,7 @@ jobs: contents: read outputs: publish: ${{ steps.release.outputs.publish }} + minimum-supported-cli-version: ${{ steps.release.outputs.minimum-supported-cli-version }} source-date-epoch: ${{ steps.release.outputs.source-date-epoch }} source-ref: ${{ steps.release.outputs.source-ref }} tag: ${{ steps.release.outputs.tag }} @@ -341,7 +342,7 @@ jobs: permissions: contents: read id-token: write - attestations: read + attestations: write steps: - name: Check out source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -356,6 +357,7 @@ jobs: - name: Generate release manifest and checksums env: SOURCE_DATE_EPOCH: ${{ needs.context.outputs.source-date-epoch }} + MINIMUM_SUPPORTED_CLI_VERSION: ${{ needs.context.outputs.minimum-supported-cli-version }} VERIFIED_CHANNELS: ${{ needs.context.outputs.verified-channels }} VERSION: ${{ needs.context.outputs.version }} shell: bash @@ -365,10 +367,14 @@ jobs: --directory dist/release \ --version "$VERSION" \ --commit "$GITHUB_SHA" \ - --minimum-supported-version "$VERSION" \ + --minimum-supported-version "$MINIMUM_SUPPORTED_CLI_VERSION" \ --source-date-epoch "$SOURCE_DATE_EPOCH" \ --builder-workflow stack-sh/cli/.github/workflows/release.yaml \ --verified-channels "$VERIFIED_CHANNELS" + - name: Attest release manifest + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: dist/release/stack-v${{ needs.context.outputs.version }}-release-manifest.json - name: Install Cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 with: @@ -399,6 +405,12 @@ jobs: --certificate-identity "$identity" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ "dist/release/stack-v${VERSION}-checksums.txt" + gh attestation verify "dist/release/stack-v${VERSION}-release-manifest.json" \ + --repo stack-sh/cli \ + --signer-workflow stack-sh/cli/.github/workflows/release.yaml \ + --source-ref "$SOURCE_REF" \ + --predicate-type https://slsa.dev/provenance/v1 \ + --deny-self-hosted-runners ( cd dist/release sha256sum --check "stack-v${VERSION}-checksums.txt" @@ -474,6 +486,12 @@ jobs: --certificate-identity "$identity" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ "dist/release/stack-v${VERSION}-checksums.txt" + gh attestation verify "dist/release/stack-v${VERSION}-release-manifest.json" \ + --repo stack-sh/cli \ + --signer-workflow stack-sh/cli/.github/workflows/release.yaml \ + --source-ref "$SOURCE_REF" \ + --predicate-type https://slsa.dev/provenance/v1 \ + --deny-self-hosted-runners for target in \ aarch64-apple-darwin \ x86_64-apple-darwin \ @@ -552,6 +570,12 @@ jobs: --certificate-identity "$identity" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ "$downloaded/stack-v${VERSION}-checksums.txt" + gh attestation verify "$downloaded/stack-v${VERSION}-release-manifest.json" \ + --repo stack-sh/cli \ + --signer-workflow stack-sh/cli/.github/workflows/release.yaml \ + --source-ref "$SOURCE_REF" \ + --predicate-type https://slsa.dev/provenance/v1 \ + --deny-self-hosted-runners for target in \ aarch64-apple-darwin \ x86_64-apple-darwin \ diff --git a/Cargo.lock b/Cargo.lock index 3e49f8c..0bf9243 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -109,6 +109,16 @@ 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" @@ -294,6 +304,12 @@ 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" @@ -371,7 +387,9 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" name = "stack-cli" version = "0.3.0" dependencies = [ + "flate2", "roxmltree", + "semver", "serde", "serde_json", "serde_yaml_ng", @@ -379,6 +397,7 @@ dependencies = [ "stack-compiler", "stack-engine", "stack-theme", + "tar", "ureq", "zip", ] @@ -446,6 +465,16 @@ 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 e134481..705b75b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,9 @@ 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" @@ -22,6 +24,7 @@ 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 6801182..2f58788 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ stack fmt --check arch.stack stack fmt - stack render arch.stack stack render arch.stack -o arch.svg +stack update --check stack lsp stack icons list stack icons list aws s3 @@ -58,6 +59,8 @@ stack render arch.stack -o arch.svg --notice arch.NOTICE.md `stack lsp` runs a native [Language Server Protocol 3.18 adapter](./docs/language-server.md) over standard input and output. It provides incremental document synchronization, versioned diagnostics, completion, hover, hierarchical document symbols, and whole-document formatting for `.stack` files. The adapter negotiates UTF-8, UTF-16, or UTF-32 positions and delegates language semantics and formatting to the pinned compiler and engine rather than reimplementing them. Standard output is reserved for framed JSON-RPC messages. +`stack update` is implemented 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 published 0.3.0 binary does not contain this command and its manual installation has no receipt, so the self-update channel remains planned. See the [self-update contract](./docs/self-update.md). + `stack icons list [PROVIDER] [QUERY]` searches the asset-free catalog by ID, product name, or category. The catalog currently contains 1,051 IDs: 305 AWS, 45 Google Cloud, 639 Azure, and 62 curated developer and collaboration tool icons. This command reads only metadata embedded in the CLI. `stack icons import --accept-terms` downloads the audited official archive set, verifies every complete SHA-256 before ZIP processing, reads allowlisted SVG entries with fixed size limits, sanitizes active and external content, preserves official colors and geometry, and writes the manifest, notice, and processed SVGs atomically. The default store is `$XDG_CONFIG_HOME/stack/icons`, falling back to `$HOME/.config/stack/icons`. `$XDG_CONFIG_HOME/stack/config.yaml` can set an absolute `default_icons_path`. Use `-o ` to put provider child directories below a project-local root. See [the provider icon guide](./docs/provider-icon-import.md) for configuration, project-local usage, sources, hashes, and rights. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 7384197..f5215a6 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -12,7 +12,9 @@ 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 ZIP entries. | +| `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. | | `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.json b/distribution/distribution-contract.json index f089144..ed5eaca 100644 --- a/distribution/distribution-contract.json +++ b/distribution/distribution-contract.json @@ -19,7 +19,7 @@ "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 minimumSupportedCliVersion field in the stable release manifest", + "minimumSupportedVersionSource": "The self-update channel's minimumSupportedCliVersion floor, copied into each release manifest", "preOneSupportWindow": "latest stable release only", "stableSupportWindow": "latest two minor lines after 1.0.0" }, @@ -33,6 +33,7 @@ "THIRD_PARTY_LICENSES.md" ], "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", @@ -164,9 +165,10 @@ "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu" ], - "owns": "verified atomic replacement for direct GitHub installations carrying a Stack installation receipt", + "owns": "GitHub-attested release-manifest and archive verification, then atomic replacement for direct GitHub installations carrying a Stack installation receipt", "source": "github-release", - "updatePolicy": "refuse without a direct-install receipt and print the owning package manager command" + "minimumSupportedCliVersion": null, + "updatePolicy": "refuse without a direct-install receipt matching the running binary and print the detected or possible owning package manager command" } ], "verification": { @@ -178,6 +180,11 @@ "stack --version, help, init, check, and render pass on each target", "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/distribution-contract.schema.json b/distribution/distribution-contract.schema.json index 8da3b5c..692566c 100644 --- a/distribution/distribution-contract.schema.json +++ b/distribution/distribution-contract.schema.json @@ -72,6 +72,7 @@ "archiveRootTemplate", "requiredEntries", "releaseManifestNameTemplate", + "installReceiptSchema", "checksumNameTemplate", "signatureBundleNameTemplate", "sbomNameTemplate", @@ -90,6 +91,7 @@ "items": { "type": "string", "minLength": 1 } }, "releaseManifestNameTemplate": { "$ref": "#/$defs/template" }, + "installReceiptSchema": { "const": "distribution/install-receipt.schema.json" }, "checksumNameTemplate": { "$ref": "#/$defs/template" }, "signatureBundleNameTemplate": { "$ref": "#/$defs/template" }, "sbomNameTemplate": { "$ref": "#/$defs/template" }, @@ -135,7 +137,7 @@ "verification": { "type": "object", "additionalProperties": false, - "required": ["releaseActivation", "rollback"], + "required": ["releaseActivation", "selfUpdateActivation", "rollback"], "properties": { "releaseActivation": { "type": "array", @@ -143,6 +145,12 @@ "uniqueItems": true, "items": { "type": "string", "minLength": 1 } }, + "selfUpdateActivation": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 } + }, "rollback": { "type": "string", "minLength": 1 } } } @@ -174,6 +182,42 @@ "type": "object", "additionalProperties": false, "required": ["id", "state", "targets", "owns", "source", "updatePolicy"], + "allOf": [ + { + "if": { + "properties": { "id": { "const": "self-update" } }, + "required": ["id"] + }, + "then": { "required": ["minimumSupportedCliVersion"] }, + "else": { "not": { "required": ["minimumSupportedCliVersion"] } } + }, + { + "if": { + "properties": { + "id": { "const": "self-update" }, + "state": { "const": "planned" } + }, + "required": ["id", "state"] + }, + "then": { + "properties": { "minimumSupportedCliVersion": { "type": "null" } } + } + }, + { + "if": { + "properties": { + "id": { "const": "self-update" }, + "state": { "const": "available" } + }, + "required": ["id", "state"] + }, + "then": { + "properties": { + "minimumSupportedCliVersion": { "$ref": "#/$defs/version" } + } + } + } + ], "properties": { "id": { "enum": ["github-release", "homebrew", "cargo", "aqua", "self-update"] }, "state": { "enum": ["planned", "available"] }, @@ -184,6 +228,9 @@ }, "owns": { "type": "string", "minLength": 1 }, "source": { "type": "string", "minLength": 1 }, + "minimumSupportedCliVersion": { + "anyOf": [{ "$ref": "#/$defs/version" }, { "type": "null" }] + }, "updatePolicy": { "type": "string", "minLength": 1 } } } diff --git a/distribution/install-receipt.schema.json b/distribution/install-receipt.schema.json new file mode 100644 index 0000000..78186b7 --- /dev/null +++ b/distribution/install-receipt.schema.json @@ -0,0 +1,67 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/stack-sh/cli/main/distribution/install-receipt.schema.json", + "title": "Stack CLI installation receipt", + "type": "object", + "additionalProperties": false, + "required": [ + "$schema", + "schemaVersion", + "owner", + "repository", + "version", + "target", + "sourceCommit", + "archive", + "binary" + ], + "properties": { + "$schema": { + "type": "string", + "pattern": "^https://raw\\.githubusercontent\\.com/stack-sh/cli/[0-9a-f]{40}/distribution/install-receipt\\.schema\\.json$" + }, + "schemaVersion": { "const": 1 }, + "owner": { "enum": ["github-release", "homebrew", "cargo", "aqua"] }, + "repository": { "const": "stack-sh/cli" }, + "version": { "$ref": "#/$defs/version" }, + "target": { + "enum": [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "aarch64-unknown-linux-gnu", + "x86_64-unknown-linux-gnu" + ] + }, + "sourceCommit": { "$ref": "#/$defs/commit" }, + "archive": { "$ref": "#/$defs/artifact" }, + "binary": { + "type": "object", + "additionalProperties": false, + "required": ["path", "sha256"], + "properties": { + "path": { "type": "string", "pattern": "^/", "minLength": 2 }, + "sha256": { "$ref": "#/$defs/digest" } + } + } + }, + "$defs": { + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+(?:-rc\\.[1-9][0-9]*)?$" + }, + "commit": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["name", "sha256"], + "properties": { + "name": { + "type": "string", + "pattern": "^stack-v[0-9]+\\.[0-9]+\\.[0-9]+(?:-rc\\.[1-9][0-9]*)?-(?:aarch64-apple-darwin|x86_64-apple-darwin|aarch64-unknown-linux-gnu|x86_64-unknown-linux-gnu)\\.tar\\.gz$" + }, + "sha256": { "$ref": "#/$defs/digest" } + } + } + } +} diff --git a/docs/distribution.md b/docs/distribution.md index 1cb33eb..25f6e77 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -24,7 +24,7 @@ Windows, musl-based Linux distributions such as Alpine, BSD, and 32-bit architec - 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. - 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`. This is the only input used by update clients and documentation to describe the minimum supported version. +- 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. - 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. @@ -136,7 +136,7 @@ install -m 0755 "stack-v0.3.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, and `stack` self-update remains unavailable until that channel is separately activated. +Add `$HOME/.local/bin` to `PATH` if it is not already present. This manual installation has no self-update receipt. The source tree after 0.3.0 contains `stack update`, but the published 0.3.0 binary does not, and this installation cannot be claimed retroactively without risking a package-manager-owned binary. 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). ## Channel ownership @@ -148,7 +148,7 @@ Add `$HOME/.local/bin` to `PATH` if it is not already present. This manual insta | 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, and artifact digest. Self-update refuses to write when that receipt is absent or names another owner and prints the appropriate package-manager upgrade command. This keeps ownership deterministic instead of guessing from an executable path. +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. 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/self-update.md b/docs/self-update.md new file mode 100644 index 0000000..30a89e9 --- /dev/null +++ b/docs/self-update.md @@ -0,0 +1,51 @@ +# Verified self-update + +`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. + +The command is implemented in the source tree after Stack CLI 0.3.0. The published 0.3.0 binary does not contain it, and the documented 0.3.0 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.3.0 as self-updatable. + +## Commands + +```sh +stack update --check +stack update +stack update --version 0.4.0 +stack update --version 0.4.0-rc.1 +``` + +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. + +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: + +- 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 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. + +## Installation receipt + +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. + +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. diff --git a/docs/supply-chain.md b/docs/supply-chain.md index 7313208..a815f0c 100644 --- a/docs/supply-chain.md +++ b/docs/supply-chain.md @@ -4,7 +4,7 @@ This guide defines how Stack CLI release artifacts are inventoried, signed, atte ## Trust and threat model -The release boundary contains four independently built target archives, their SPDX SBOMs, GitHub artifact attestations, a release manifest, a complete checksum inventory, and a keyless signature over that inventory. A release must fail closed when a material is missing, unexpected, malformed, associated with another artifact, or modified after signing. +The release boundary contains four independently built target archives, their SPDX SBOMs, GitHub artifact attestations, an attested release manifest, a complete checksum inventory, and a keyless signature over that inventory. A release must fail closed when a material is missing, unexpected, malformed, associated with another artifact, or modified after signing. The trusted identities are GitHub-hosted runners and the exact workflow in `stack-sh/cli`. Release jobs use GitHub OIDC and short-lived certificates. They do not store a Cosign private key, personal access token, registry credential, or other long-lived signing secret. Every third-party action in a signing workflow must be pinned to a full commit SHA. @@ -31,6 +31,8 @@ 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. + ## Release-side generation Build and smoke-test all target archives first. Generate each SPDX 2.3 document with the pinned Syft version, then create provenance and SBOM attestations for each archive with `actions/attest`. Run the generator from the matching tagged source checkout; it rejects a version that differs from `Cargo.toml` and the distribution contract. Place only the 16 target materials in the staging directory before running: @@ -53,7 +55,7 @@ cosign sign-blob \ node scripts/release-security.mjs verify --directory dist/release ``` -Generation refuses to replace existing metadata. Verification rejects missing or extra files, digest drift, malformed SPDX or Sigstore structures, attestations for another subject, and a checksum signature bundle for another checksum file. The release workflow must additionally run the cryptographic checks below before publishing. +Generation refuses to replace existing metadata. The release workflow creates SLSA provenance for the generated manifest before signing the checksum inventory. Verification rejects missing or extra files, digest drift, malformed SPDX or Sigstore structures, attestations for another subject, and a checksum signature bundle for another checksum file. The release workflow must additionally run the cryptographic checks below before publishing. ## User verification @@ -76,6 +78,13 @@ cosign verify-blob \ sha256sum --check "stack-v${version}-checksums.txt" ) node scripts/release-security.mjs verify --directory "$asset_dir" + +gh attestation verify "$asset_dir/stack-v${version}-release-manifest.json" \ + --repo stack-sh/cli \ + --signer-workflow stack-sh/cli/.github/workflows/release.yaml \ + --source-ref "$tag_ref" \ + --predicate-type https://slsa.dev/provenance/v1 \ + --deny-self-hosted-runners ``` On macOS, use `shasum -a 256 -c` instead of `sha256sum --check`. Verify each archive's provenance bundle with the exact tag and signer workflow: diff --git a/scripts/distribution-contract.test.mjs b/scripts/distribution-contract.test.mjs index de466dd..f267248 100644 --- a/scripts/distribution-contract.test.mjs +++ b/scripts/distribution-contract.test.mjs @@ -86,3 +86,17 @@ 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("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/); +}); diff --git a/scripts/release-workflow.test.mjs b/scripts/release-workflow.test.mjs index f095183..96ec4c5 100644 --- a/scripts/release-workflow.test.mjs +++ b/scripts/release-workflow.test.mjs @@ -10,7 +10,7 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const workflow = fs.readFileSync(path.join(root, ".github", "workflows", "release.yaml"), "utf8"); test("the checked-in release workflow has the reviewed target and trust boundaries", () => { - assert.deepEqual(validateReleaseWorkflow(workflow), { actions: 18, jobs: 6, permissions: 11, targets: 4 }); + assert.deepEqual(validateReleaseWorkflow(workflow), { actions: 19, jobs: 6, permissions: 11, targets: 4 }); }); test("an additional automatic trigger is rejected", () => { @@ -72,3 +72,19 @@ test("publication without post-upload SBOM verification is rejected", () => { const candidate = workflow.slice(0, lastIndex) + workflow.slice(lastIndex + marker.length); assert.throws(() => validateReleaseWorkflow(candidate), /SBOM attestations must verify/); }); + +test("publication without post-upload manifest attestation verification is rejected", () => { + const marker = "--predicate-type https://slsa.dev/provenance/v1"; + const lastIndex = workflow.lastIndexOf(marker); + assert.notEqual(lastIndex, -1); + const candidate = workflow.slice(0, lastIndex) + workflow.slice(lastIndex + marker.length); + assert.throws(() => validateReleaseWorkflow(candidate), /manifest attestations must verify/); +}); + +test("release generation cannot silently advance the updater compatibility floor", () => { + const candidate = workflow.replace( + '--minimum-supported-version "$MINIMUM_SUPPORTED_CLI_VERSION"', + '--minimum-supported-version "$VERSION"', + ); + assert.throws(() => validateReleaseWorkflow(candidate), /release assembly requirement is missing/); +}); diff --git a/scripts/resolve-release-context.mjs b/scripts/resolve-release-context.mjs index 5432175..2dfe6c0 100644 --- a/scripts/resolve-release-context.mjs +++ b/scripts/resolve-release-context.mjs @@ -16,11 +16,43 @@ 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; + } if (eventName === "workflow_dispatch") { invariant(ref === "refs/heads/main", "manual release verification must run from main"); @@ -31,18 +63,24 @@ export function resolveReleaseContext({ eventName, ref, refName, sha, requestedV sourceRef: "refs/heads/main", publish: false, verifiedChannels: "", + minimumSupportedCliVersion, }; } invariant(eventName === "push", `unsupported release event: ${eventName}`); 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, sourceRef: ref, publish: true, - verifiedChannels: "github-release", + verifiedChannels: verifiedChannels.join(","), + minimumSupportedCliVersion, }; } @@ -62,6 +100,7 @@ function run() { "source-ref": context.sourceRef, publish: String(context.publish), "verified-channels": context.verifiedChannels, + "minimum-supported-cli-version": context.minimumSupportedCliVersion, })) { console.log(`${name}=${value}`); } diff --git a/scripts/resolve-release-context.test.mjs b/scripts/resolve-release-context.test.mjs index 2baf347..61140bc 100644 --- a/scripts/resolve-release-context.test.mjs +++ b/scripts/resolve-release-context.test.mjs @@ -30,6 +30,7 @@ test("main dispatch resolves a non-publishing verification run", () => { sourceRef: "refs/heads/main", publish: false, verifiedChannels: "", + minimumSupportedCliVersion: "0.3.0", }, ); }); @@ -51,10 +52,58 @@ test("an exact version tag resolves a publishing run", () => { sourceRef: "refs/tags/v0.3.0", publish: true, verifiedChannels: "github-release", + minimumSupportedCliVersion: "0.3.0", }, ); }); +test("an activated self-update channel is recorded in a tagged release", () => { + 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.3.0", + refName: "v0.3.0", + sha, + requestedVersion: "", + cargoToml, + contract: activated, + }), + { + version: "0.3.0", + tag: "v0.3.0", + sourceRef: "refs/tags/v0.3.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.4.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.3.0", + refName: "v0.3.0", + sha, + requestedVersion: "", + cargoToml, + contract: activated, + }), + /minimum supported CLI version|invalid updater compatibility version/, + ); + } +}); + test("manual runs from another ref or version are rejected", () => { const common = { eventName: "workflow_dispatch", diff --git a/scripts/validate-distribution-contract.mjs b/scripts/validate-distribution-contract.mjs index f8cc86d..b06e1ad 100644 --- a/scripts/validate-distribution-contract.mjs +++ b/scripts/validate-distribution-contract.mjs @@ -135,6 +135,16 @@ export function validateDistributionContract(contract, cargoToml) { 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)), + `${id} must not own the self-update compatibility floor`, + ); + } for (const [name, template] of Object.entries(contract.artifacts ?? {})) { if (!name.endsWith("Template")) continue; @@ -151,6 +161,10 @@ export function validateDistributionContract(contract, cargoToml) { } sameValues(contract.artifacts?.requiredEntries ?? [], requiredArchiveEntries, "archive entries"); 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( @@ -176,6 +190,10 @@ export function validateDistributionContract(contract, cargoToml) { for (const term of requiredActivationTerms) { 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/validate-release-workflow.mjs b/scripts/validate-release-workflow.mjs index cca097c..d1e5ea9 100644 --- a/scripts/validate-release-workflow.mjs +++ b/scripts/validate-release-workflow.mjs @@ -19,7 +19,7 @@ const expectedPermissions = new Map([ ["build-macos", ["contents: read"]], ["build-linux", ["contents: read"]], ["attest", ["attestations: write", "contents: read", "id-token: write"]], - ["assemble", ["attestations: read", "contents: read", "id-token: write"]], + ["assemble", ["attestations: write", "contents: read", "id-token: write"]], ["publish", ["attestations: read", "contents: write"]], ]); @@ -161,7 +161,7 @@ export function validateReleaseWorkflow(source) { invariant(linux.includes('test "$(rustc --version)" = "rustc 1.85.0 (4d91de4e4 2025-02-17)"'), "GNU/Linux Rust version must be exact"); const uses = [...workflow.matchAll(/^\s+uses: ([^\s#]+)(?:\s+#.*)?$/gm)].map((match) => match[1]); - invariant(uses.length === 18, "release workflow action count changed and requires review"); + invariant(uses.length === 19, "release workflow action count changed and requires review"); for (const action of uses) { const match = action.match(/^([^@]+)@([0-9a-f]{40})$/); invariant(match, `action must be pinned to a full commit: ${action}`); @@ -189,6 +189,11 @@ export function validateReleaseWorkflow(source) { } for (const requirement of [ "cosign-release: v3.1.3", + "MINIMUM_SUPPORTED_CLI_VERSION: ${{ needs.context.outputs.minimum-supported-cli-version }}", + '--minimum-supported-version "$MINIMUM_SUPPORTED_CLI_VERSION"', + "subject-path: dist/release/stack-v${{ needs.context.outputs.version }}-release-manifest.json", + 'gh attestation verify "dist/release/stack-v${VERSION}-release-manifest.json"', + "--predicate-type https://slsa.dev/provenance/v1", "--certificate-oidc-issuer https://token.actions.githubusercontent.com", "--signer-workflow stack-sh/cli/.github/workflows/release.yaml", "node scripts/release-security.mjs verify --directory dist/release", @@ -205,7 +210,12 @@ export function validateReleaseWorkflow(source) { invariant(occurrences(publish, 'cmp "$source"') === 2, "downloaded draft assets must match the assembled bytes"); invariant(occurrences(publish, "node scripts/release-security.mjs verify") === 2, "release metadata must verify before and after upload"); invariant(occurrences(publish, "cosign verify-blob") === 2, "checksum signatures must verify before and after upload"); - invariant(occurrences(publish, "gh attestation verify") === 4, "provenance and SBOM attestations must verify before and after upload"); + invariant(occurrences(publish, "gh attestation verify") === 6, "manifest, provenance, and SBOM attestations must verify before and after upload"); + invariant( + occurrences(publish, "release-manifest.json") >= 2 && + occurrences(publish, "--predicate-type https://slsa.dev/provenance/v1") === 2, + "release manifest attestations must verify before and after upload", + ); invariant( occurrences(publish, "--predicate-type https://spdx.dev/Document/v2.3") === 2, "SBOM attestations must verify before and after upload", diff --git a/scripts/verify_release_binary.py b/scripts/verify_release_binary.py index 8ac71a2..367eea8 100644 --- a/scripts/verify_release_binary.py +++ b/scripts/verify_release_binary.py @@ -93,6 +93,10 @@ 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", + ) with tempfile.TemporaryDirectory(prefix="stack-release-smoke-") as temporary: working_directory = Path(temporary) @@ -116,6 +120,10 @@ def verify_release_binary(binary, target, version): require(stat.S_ISREG(binary_stat.st_mode), "release binary must be a regular file, not a symlink") require(0 < binary_stat.st_size <= MAXIMUM_BINARY_BYTES, "release binary size is invalid") require(os.access(binary_path, os.X_OK), "release binary must be executable") + require( + b"STACK_CLI_TEST_UPDATE_BASE_URL" not in binary_path.read_bytes(), + "release binary contains the debug-only update endpoint override", + ) verify_architecture(binary_path, target) if target.endswith("linux-gnu"): verify_linux_runtime(binary_path, target) diff --git a/src/config.rs b/src/config.rs index 7ce8e5b..8c37280 100644 --- a/src/config.rs +++ b/src/config.rs @@ -63,6 +63,10 @@ pub(crate) fn icon_store_root( Ok(stack_root.join("icons")) } +pub(crate) fn installation_receipt_path(environment: &Environment) -> Result { + Ok(config_root(environment)?.join("stack/install-receipt.json")) +} + fn config_root(environment: &Environment) -> Result { if let Some(value) = &environment.xdg_config_home { if !value.is_empty() { @@ -199,6 +203,14 @@ mod tests { Ok(path) if path == home.join(".config/stack/icons") )); assert!(icon_store_root(None, &Environment::new(None, None)).is_err()); + 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 59be5a7..92bd6e4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ mod lsp; 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; @@ -39,6 +40,7 @@ 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 icons List catalogs and import audited provider icon archives help Print this message or the help of a subcommand @@ -54,6 +56,7 @@ Examples: stack check arch.stack stack fmt --check arch.stack stack render arch.stack -o arch.svg + stack update --check stack lsp stack icons list aws s3 "; @@ -165,6 +168,31 @@ Protocol: Examples: stack lsp "; +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 @@ -238,7 +266,7 @@ Usage: stack help icons Arguments: - init, check, fmt, render, lsp, icons, help, or version + init, check, fmt, render, update, lsp, icons, help, or version Examples: stack help @@ -322,6 +350,9 @@ 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); } @@ -333,7 +364,7 @@ pub fn run( "stack", &command, &[ - "init", "check", "fmt", "render", "lsp", "icons", "help", "version", + "init", "check", "fmt", "render", "update", "lsp", "icons", "help", "version", ], "stack help", stderr, @@ -369,6 +400,8 @@ 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("help") { @@ -380,7 +413,7 @@ fn run_help( "stack help", &command, &[ - "init", "check", "fmt", "render", "lsp", "icons", "help", "version", + "init", "check", "fmt", "render", "update", "lsp", "icons", "help", "version", ], "stack help", stderr, @@ -554,6 +587,66 @@ fn run_version( ) } +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 new file mode 100644 index 0000000..853bb38 --- /dev/null +++ b/src/update.rs @@ -0,0 +1,736 @@ +//! 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 new file mode 100644 index 0000000..39051d1 --- /dev/null +++ b/src/update/install.rs @@ -0,0 +1,660 @@ +//! 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}/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 new file mode 100644 index 0000000..cece4aa --- /dev/null +++ b/src/update/tests.rs @@ -0,0 +1,1146 @@ +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()), + ] { + 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 c2e49e6..b98c275 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -237,6 +237,14 @@ 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 cd6c2f6..7985e3f 100644 --- a/tests/snapshots/help-help.txt +++ b/tests/snapshots/help-help.txt @@ -6,7 +6,7 @@ Usage: stack help icons Arguments: - init, check, fmt, render, lsp, icons, help, or version + init, check, fmt, render, update, lsp, icons, help, or version Examples: stack help diff --git a/tests/snapshots/help.txt b/tests/snapshots/help.txt index a7cd360..f46cff0 100644 --- a/tests/snapshots/help.txt +++ b/tests/snapshots/help.txt @@ -9,6 +9,7 @@ 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 icons List catalogs and import audited provider icon archives help Print this message or the help of a subcommand @@ -24,5 +25,6 @@ Examples: stack check arch.stack stack fmt --check arch.stack stack render arch.stack -o arch.svg + stack update --check stack lsp stack icons list aws s3 diff --git a/tests/snapshots/update-help.txt b/tests/snapshots/update-help.txt new file mode 100644 index 0000000..e34642e --- /dev/null +++ b/tests/snapshots/update-help.txt @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000..bbbad23 --- /dev/null +++ b/tests/update.rs @@ -0,0 +1,459 @@ +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()), + ] { + 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.3.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.3.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.3.0 -> 0.3.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.3.1\n"); + let receipt: Value = serde_json::from_slice(&fs::read(receipt_path)?)?; + assert_eq!(receipt["version"], "0.3.1"); + assert_eq!(receipt["sourceCommit"], release_commit); + assert_eq!(receipt["binary"]["sha256"], sha256(candidate)); + Ok(()) +}