From 748e92942523838bad499e093030a9b32890741e Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 16:14:06 -0500 Subject: [PATCH 1/3] fix: replay frozen artifacts from the local store before registry access Use immutable lock metadata when an extracted store object or verified artifact cache is present, query the registry only for genuinely missing artifacts, and preserve the existing worker/process-lock/hash/extraction path. Add permanent three-platform certification covering cold download, registry deletion, extracted-store replay, and cache-only store reconstruction. The identical resolver implementation passed the merged two-OS compiled-CLI canary in zed-pkg-test/zed-pkg-e2e#57. --- .github/workflows/frozen-offline-prefetch.yml | 60 ++++++++++ src/install_graph/resolver.rs | 67 ++++++++--- tests/frozen_offline_prefetch.rs | 113 ++++++++++++++++++ 3 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/frozen-offline-prefetch.yml create mode 100644 tests/frozen_offline_prefetch.rs diff --git a/.github/workflows/frozen-offline-prefetch.yml b/.github/workflows/frozen-offline-prefetch.yml new file mode 100644 index 00000000..21963925 --- /dev/null +++ b/.github/workflows/frozen-offline-prefetch.yml @@ -0,0 +1,60 @@ +name: frozen offline prefetch + +on: + pull_request: + paths: + - src/install_graph/** + - tests/frozen_offline_prefetch.rs + - .github/workflows/frozen-offline-prefetch.yml + push: + branches: [main] + paths: + - src/install_graph/** + - tests/frozen_offline_prefetch.rs + - .github/workflows/frozen-offline-prefetch.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: frozen-offline-prefetch-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + frozen-offline-prefetch: + name: frozen offline prefetch / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-2025] + steps: + - name: Check out exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Install Rust quality components + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + rustc --version --verbose + cargo --version --verbose + + - name: Format + run: cargo fmt --all --check + + - name: Real pack, cold prefetch, registry deletion, and offline replay + run: cargo test --locked --test frozen_offline_prefetch -- --nocapture + + - name: Strict Clippy + run: cargo clippy --locked --test frozen_offline_prefetch -- -D warnings diff --git a/src/install_graph/resolver.rs b/src/install_graph/resolver.rs index 4d97a762..452f2d8c 100644 --- a/src/install_graph/resolver.rs +++ b/src/install_graph/resolver.rs @@ -29,8 +29,7 @@ pub fn prefetch(project: &Path, cfg: &Config, frozen: bool) -> Result( } } -fn prefetch_locked( - project: &Path, - cfg: &Config, - registry: &dyn Registry, - concurrency: usize, -) -> Result { +fn locked_version_metadata(locked: &zed_interfaces::lockfile::LockedPackage) -> VersionMetadata { + VersionMetadata { + org: locked.org.clone(), + name: locked.name.clone(), + version: locked.version.clone(), + sha256: locked.sha256.clone(), + size: locked.size, + format: locked.format, + vcs_tag: locked.vcs_tag.clone(), + vcs_commit: locked.vcs_commit.clone(), + // The worker never consumes this URL while the authenticated bytes are + // already present in the store or verified artifact cache. + download_url: String::new(), + published_at: "1970-01-01T00:00:00Z".to_string(), + yanked: false, + } +} + +fn prefetch_locked(project: &Path, cfg: &Config, concurrency: usize) -> Result { let lock_path = project.join(LOCKFILE_FILE); let text = fs::read_to_string(&lock_path) .with_context(|| format!("--frozen requires {}", lock_path.display()))?; let lock = Lockfile::parse(&text)?; + let store = Store::new(&cfg.home); + let mut registry: Option> = None; let mut tasks = Vec::with_capacity(lock.packages.len()); let mut seen = BTreeSet::new(); @@ -99,17 +113,34 @@ fn prefetch_locked( if !seen.insert(key.clone()) { bail!("duplicate package `{key}` in {LOCKFILE_FILE}"); } - let version = registry.get_version(&locked.org, &locked.name, &locked.version)?; + + let version = + if store.has(&locked.sha256) || store.cached_artifact(&locked.sha256).is_file() { + // The lockfile authenticates every immutable field needed to + // verify locally owned bytes. Frozen replay must not turn a local + // restore into a registry metadata availability check. + locked_version_metadata(locked) + } else { + if registry.is_none() { + registry = Some(registry_for(&cfg.registry)?); + } + let version = registry + .as_deref() + .context("frozen prefetch registry was not initialized")? + .get_version(&locked.org, &locked.name, &locked.version)?; + validate_version_identity(&version, &locked.org, &locked.name, &locked.version)?; + if version.sha256 != locked.sha256 { + bail!( + "registry artifact for {}@{} changed (lock {} vs registry {}); refusing", + key, + locked.version, + locked.sha256, + version.sha256 + ); + } + version + }; validate_version_identity(&version, &locked.org, &locked.name, &locked.version)?; - if version.sha256 != locked.sha256 { - bail!( - "registry artifact for {}@{} changed (lock {} vs registry {}); refusing", - key, - locked.version, - locked.sha256, - version.sha256 - ); - } tasks.push(FetchTask { sequence: tasks.len(), key, diff --git a/tests/frozen_offline_prefetch.rs b/tests/frozen_offline_prefetch.rs new file mode 100644 index 00000000..83bc1c8f --- /dev/null +++ b/tests/frozen_offline_prefetch.rs @@ -0,0 +1,113 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use zed_cli::config::Config; +use zed_cli::install_graph::prefetch; +use zed_cli::pack::pack; +use zed_cli::store::Store; +use zed_interfaces::lockfile::{LockedPackage, Lockfile}; +use zed_interfaces::manifest::Manifest; +use zed_interfaces::paths::{LOCKFILE_FILE, MANIFEST_FILE}; +use zed_interfaces::registry::VersionMetadata; + +fn test_config(registry: &Path, home: PathBuf) -> Config { + Config { + registry: format!("file://{}", registry.display()), + home, + token: None, + auth_url: "http://127.0.0.1/unused".to_string(), + supabase_url: None, + supabase_key: None, + interactive: false, + } +} + +#[test] +fn frozen_prefetch_restores_from_store_and_cache_after_registry_metadata_disappears() { + let temp = tempfile::tempdir().unwrap(); + let registry = temp.path().join("registry"); + let source = temp.path().join("source"); + let packed_dir = temp.path().join("packed"); + let home = temp.path().join("home"); + let project = temp.path().join("project"); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(&project).unwrap(); + + let manifest_text = r#"[package] +org = "offline" +name = "tool" +version = "1.0.0" + +[package.repository] +vcs = "git" +url = "https://example.invalid/offline/tool" +"#; + fs::write(source.join(MANIFEST_FILE), manifest_text).unwrap(); + fs::write(source.join("payload.txt"), "offline/tool@1.0.0\n").unwrap(); + let manifest = Manifest::parse(manifest_text).unwrap(); + let packed = pack(&source, &manifest, Some(&packed_dir)).unwrap(); + + let artifact_dir = registry.join("artifacts"); + fs::create_dir_all(&artifact_dir).unwrap(); + let artifact = artifact_dir.join(format!("{}.tar.gz", packed.sha256)); + fs::copy(&packed.path, &artifact).unwrap(); + + let version_dir = registry + .join("packages") + .join("offline") + .join("tool") + .join("versions"); + fs::create_dir_all(&version_dir).unwrap(); + let version = VersionMetadata { + org: "offline".to_string(), + name: "tool".to_string(), + version: "1.0.0".to_string(), + sha256: packed.sha256.clone(), + size: packed.size, + format: packed.format, + vcs_tag: "v1.0.0".to_string(), + vcs_commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()), + download_url: format!("file://{}", artifact.display()), + published_at: "1970-01-01T00:00:00Z".to_string(), + yanked: false, + }; + fs::write( + version_dir.join("1.0.0.json"), + serde_json::to_string_pretty(&version).unwrap(), + ) + .unwrap(); + + let mut lock = Lockfile::default(); + lock.upsert(LockedPackage { + org: version.org, + name: version.name, + version: version.version, + sha256: version.sha256, + size: version.size, + format: version.format, + vcs_tag: version.vcs_tag, + vcs_commit: version.vcs_commit, + source: format!("file://{}", registry.display()), + }); + fs::write(project.join(LOCKFILE_FILE), lock.to_toml_string().unwrap()).unwrap(); + + let cfg = test_config(®istry, home); + let store = Store::new(&cfg.home); + let cold = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(cold.resolved, 1); + assert_eq!(cold.downloaded, 1); + assert!(store.has(&packed.sha256)); + assert!(store.cached_artifact(&packed.sha256).is_file()); + + fs::remove_dir_all(®istry).unwrap(); + let from_store = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(from_store.resolved, 1); + assert_eq!(from_store.downloaded, 0); + + fs::remove_dir_all(store.entry_dir(&packed.sha256)).unwrap(); + assert!(!store.has(&packed.sha256)); + let from_cache = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(from_cache.resolved, 1); + assert_eq!(from_cache.downloaded, 0); + assert!(store.has(&packed.sha256)); +} From 9789f40dadc56a18099e55c6d5c6076b7932c4c9 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 16:17:05 -0500 Subject: [PATCH 2/3] feat: add hardened global executable package profiles (#162) Add isolated global dependency profiles, symlink-first store materialization, owned executable publication, collision rollback, frozen offline restoration, compatibility routes, completion and documentation coverage, and permanent cross-platform certification. --- .github/workflows/global-packages.yml | 184 ++++ .zpkg.lock | 1 + .zpkg.toml | 45 + docs/global-packages.md | 114 +++ src/completion.rs | 28 +- src/global.rs | 1325 +++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 68 ++ src/ops.rs | 75 +- tests/global_cli.rs | 248 +++++ 10 files changed, 2074 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/global-packages.yml create mode 100644 .zpkg.lock create mode 100644 .zpkg.toml create mode 100644 docs/global-packages.md create mode 100644 src/global.rs create mode 100644 tests/global_cli.rs diff --git a/.github/workflows/global-packages.yml b/.github/workflows/global-packages.yml new file mode 100644 index 00000000..3ac2bb9e --- /dev/null +++ b/.github/workflows/global-packages.yml @@ -0,0 +1,184 @@ +name: Global executable package profiles + +on: + pull_request: + paths: + - .zpkg.toml + - .zpkg.lock + - docs/global-packages.md + - src/global.rs + - src/lib.rs + - src/main.rs + - src/completion.rs + - src/ops.rs + - tests/global_cli.rs + - .github/workflows/global-packages.yml + push: + branches: [main] + paths: + - .zpkg.toml + - .zpkg.lock + - docs/global-packages.md + - src/global.rs + - src/lib.rs + - src/main.rs + - src/completion.rs + - src/ops.rs + - tests/global_cli.rs + - .github/workflows/global-packages.yml + +permissions: + contents: read + +concurrency: + group: global-packages-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + certify: + name: Global profiles (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-2025] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + steps: + - name: Check out exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Install Rust validation toolchain + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Check formatting + id: formatting + continue-on-error: true + run: | + set -euo pipefail + cargo fmt --all --check + + - name: Run global profile unit tests + id: unit + continue-on-error: true + run: | + set -euo pipefail + mkdir -p diagnostics + cargo test --quiet --locked --lib global::tests -- --nocapture \ + 2>&1 | tee "diagnostics/global-unit-tests-${RUNNER_OS}.log" + + - name: Upload focused unit-test diagnostics + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: global-unit-tests-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }} + path: diagnostics/ + if-no-files-found: error + retention-days: 3 + + - name: Enforce global profile unit tests + if: always() && steps.unit.outcome == 'failure' + run: | + echo 'focused global profile unit tests failed; see retained diagnostics.' >&2 + exit 1 + + - name: Run compiled global CLI tests + run: | + set -euo pipefail + cargo test --locked --test global_cli + + - name: Run strict Clippy + run: | + set -euo pipefail + cargo clippy --locked --all-targets --all-features -- -D warnings + + - name: Build and invoke the packaged command + run: | + set -euo pipefail + cargo build --release --locked --bin zed + if [[ "$RUNNER_OS" == "Windows" ]]; then + ./target/release/zed.exe --version + else + ./target/release/zed --version + fi + + - name: Run the complete repository suite on Linux + if: matrix.os == 'ubuntu-24.04' + run: | + set -euo pipefail + cargo test --all-targets --all-features --locked + + - name: Produce exact rustfmt evidence + if: always() && runner.os == 'Linux' && steps.formatting.outcome == 'failure' + run: | + set -euo pipefail + cargo fmt --all + python3 - <<'PY' + import pathlib + import shutil + import subprocess + + allowed = { + 'src/completion.rs', + 'src/global.rs', + 'src/lib.rs', + 'src/main.rs', + 'src/ops.rs', + 'tests/global_cli.rs', + } + changed = { + line.strip() + for line in subprocess.check_output( + ['git', 'diff', '--name-only'], text=True + ).splitlines() + if line.strip() + } + unexpected = changed - allowed + if unexpected: + raise SystemExit( + 'rustfmt changed files outside the reviewed feature boundary: ' + + ', '.join(sorted(unexpected)) + ) + if not changed: + raise SystemExit('format check failed but rustfmt produced no changed files') + root = pathlib.Path('format-evidence') + for name in sorted(changed): + destination = root / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(name, destination) + (root / 'changed-files.txt').write_text('\n'.join(sorted(changed)) + '\n') + PY + git diff --binary -- \ + src/completion.rs \ + src/global.rs \ + src/lib.rs \ + src/main.rs \ + src/ops.rs \ + tests/global_cli.rs \ + > format-evidence/rustfmt.patch + find format-evidence -type f -print0 | sort -z | xargs -0 sha256sum > format-evidence/SHA256SUMS + + - name: Upload rustfmt evidence + if: always() && runner.os == 'Linux' && steps.formatting.outcome == 'failure' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: global-package-rustfmt-${{ github.run_id }}-${{ github.run_attempt }} + path: format-evidence/ + if-no-files-found: error + retention-days: 3 + + - name: Enforce formatting result + if: always() && steps.formatting.outcome == 'failure' + run: | + echo 'cargo fmt --all --check failed; see the Linux rustfmt evidence artifact.' >&2 + exit 1 diff --git a/.zpkg.lock b/.zpkg.lock new file mode 100644 index 00000000..d9914dfa --- /dev/null +++ b/.zpkg.lock @@ -0,0 +1 @@ +version = 1 diff --git a/.zpkg.toml b/.zpkg.toml new file mode 100644 index 00000000..30e85c34 --- /dev/null +++ b/.zpkg.toml @@ -0,0 +1,45 @@ +[package] +org = "zed-pkg" +name = "zed-cli" +version = "0.1.0" +description = "The zed universal package-manager CLI" +license = "MIT" +keywords = ["package-manager", "cli", "polyglot", "vcs", "global-tools"] +language = "rust" + +[package.repository] +vcs = "git" +url = "https://github.com/zed-pkg/zed-cli" + +[build] +command = "cargo build --release --locked" +outputs = ["target/release/zed"] + +[bin] +zed = "target/release/zed" + +[publish] +include_readme = true +tag_format = "v{version}" +smoke_test = "test -x \"$ZED_PKG_TEST_TARGET/target/release/zed\" && \"$ZED_PKG_TEST_TARGET/target/release/zed\" --version" +exclude = [ + ".env", + ".env.*", + ".direnv/**", + ".zed/**", + ".zed-pack/**", + "target/**", + "tmp/**", + "**/*.log", + ".DS_Store", +] + +[publish.native] +registry = "crates-io" +package = "zed-cli" + +[install] +adapter = "none" + +[scripts] +test = "cargo test --all-targets --locked" diff --git a/docs/global-packages.md b/docs/global-packages.md new file mode 100644 index 00000000..2091649c --- /dev/null +++ b/docs/global-packages.md @@ -0,0 +1,114 @@ +# Global executable packages + +Zed keeps project dependencies project-local by default. A command-line tool is different: its executable should normally be available from the user's `PATH`, independent of any one repository. + +Use the explicit global package namespace: + +```sh +zed global install acme/tool +zed global install acme/tool@^2 +``` + +The npm/cargo-style spelling is an exact compatibility route to the same implementation: + +```sh +zed install --global acme/tool +``` + +## Storage model + +Each requested top-level package gets an isolated profile: + +```text +$ZED_PKG_HOME/global/profiles/// +├── .zpkg.lock +├── .zed-global-profile.json +└── zed_modules/ + ├── // + └── .bin/ +``` + +A profile resolves and locks its own complete dependency graph. Two global tools may therefore use incompatible versions of the same transitive package without forcing one global dependency solution. + +The package artifact still lives once in Zed's content-addressed store. A profile normally symlinks package trees from that store, while exposed executables are independent, executable copies owned by the global installer. + +## PATH directory + +On Unix-like systems, executables are copied to `~/.local/bin` by default. On Windows, Zed uses a per-user directory below the local application-data root. Print the exact path with: + +```sh +zed global bin-dir +``` + +Override it for one invocation or permanently: + +```sh +zed --global-bin-dir "$HOME/bin" global install acme/tool +export ZED_PKG_GLOBAL_BIN_DIR="$HOME/bin" +``` + +Zed reports when the selected directory is not on `PATH`; it never silently edits shell startup files or the Windows user environment. + +## Builds and executable declarations + +A package exposes commands through its root manifest: + +```toml +[build] +command = "cargo build --release --locked" +outputs = ["target/release/acme"] + +[bin] +acme = "target/release/acme" +``` + +Package build hooks execute author-supplied code and remain opt-in: + +```sh +zed global install acme/tool --allow-build +``` + +Prebuilt packages do not need a build hook. Their `[bin]` values may point directly at executable files already present in the artifact. + +## Ownership and collisions + +Zed records the package owner and SHA-256 hash of every executable it places in the global bin directory. + +- An unrelated existing command is never overwritten. +- Two installed packages exposing the same command name fail closed. +- Uninstall removes an executable only when its current bytes still match the version Zed installed. +- A command changed after installation is retained with a warning rather than deleted. + +These rules make a shared directory such as `~/.local/bin` safe to use alongside manually installed tools and other package managers. + +## Lifecycle + +```sh +# Inspect profiles and their commands +zed global list + +# Re-materialize every exact lock after moving machines or clearing the store +zed global install --frozen --allow-build + +# Re-materialize one exact profile +zed global install --frozen acme/tool --allow-build + +# Remove one profile and its still-owned commands +zed global uninstall acme/tool +zed uninstall --global acme/tool + +# Remove all Zed-managed global profiles +zed global uninstall +``` + +The global lock serializes profile and PATH mutations across terminals. Package downloads and builds continue to use the normal store and build-cache locks. + +## Installing Zed with Zed + +The `zed-pkg/zed-cli` repository is itself a Zed package. Once a bootstrap `zed` binary is available, it can install or upgrade the CLI through the same contract: + +```sh +zed global install zed-pkg/zed-cli --allow-build +``` + +The resulting `zed` executable is managed in the configured global bin directory; the package source and build output remain reproducibly locked in the isolated `zed-pkg/zed-cli` profile. diff --git a/src/completion.rs b/src/completion.rs index cc7c1247..34525dda 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -2,19 +2,31 @@ use std::io; +use anyhow::{Context, Result}; use clap_complete::{Shell, generate}; use crate::cli_model; -use crate::{dev, fetch, git_submodules, nix_bundle_write, nix_export_plan}; +use crate::{dev, fetch, git_submodules, global, nix_bundle_write, nix_export_plan}; -fn root_command() -> clap::Command { - git_submodules::augment_root_command(nix_bundle_write::augment_root_command( - nix_export_plan::augment_root_command(fetch::augment_root_command( - dev::augment_root_command(cli_model::command()), +/// Build the complete public command tree shared by root help and completion +/// generation. Every modular command must compose here rather than maintaining +/// a second, partial root-help model. +pub fn root_command() -> clap::Command { + global::augment_root_command(git_submodules::augment_root_command( + nix_bundle_write::augment_root_command(nix_export_plan::augment_root_command( + fetch::augment_root_command(dev::augment_root_command(cli_model::command())), )), )) } +/// Print the complete top-level help tree. +pub fn print_root_help() -> Result<()> { + let mut command = root_command(); + command.print_help().context("printing zed help")?; + println!(); + Ok(()) +} + /// Write a static completion script for `zed` to stdout. pub fn print(shell: Shell) { let mut command = root_command(); @@ -59,6 +71,8 @@ mod tests { "develop", "dev", "overtake", + "global", + "bin-dir", "completions", "self-update", "r2g", @@ -71,6 +85,7 @@ mod tests { "--skip-manifest", "--git-submodules", "--install-mode", + "--global-bin-dir", "--frozen", "--json", "--target", @@ -105,6 +120,8 @@ mod tests { "develop", "dev", "overtake", + "global", + "bin-dir", "completions", "self-update", "r2g", @@ -117,6 +134,7 @@ mod tests { "--skip-manifest", "--git-submodules", "--install-mode", + "--global-bin-dir", "--frozen", "--json", "--target", diff --git a/src/global.rs b/src/global.rs new file mode 100644 index 00000000..c70d7412 --- /dev/null +++ b/src/global.rs @@ -0,0 +1,1325 @@ +//! Global executable package profiles. +//! +//! Project installs remain the default: dependencies belong beside the project +//! that declared them. Global installs are an explicit tool-distribution path. +//! Each requested top-level package gets an isolated manifestless profile under +//! `/global/profiles//`, while its hoisted executables +//! are copied transactionally into one user bin directory (`~/.local/bin` by +//! default). Isolated profiles let two tools retain different transitive +//! dependency versions without turning the global environment into one giant +//! resolver. + +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow, bail}; +use clap::{Args, Parser, Subcommand}; +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use zed_interfaces::lockfile::Lockfile; +use zed_interfaces::manifest::is_slug; +use zed_interfaces::paths::{BIN_DIR, LOCKFILE_FILE, MODULES_DIR}; + +use crate::cli::{Adapter, Globals, InstallMode}; +use crate::config::Config; +use crate::{interactive, manifestless}; + +const GLOBAL_DIR: &str = "global"; +const PROFILES_DIR: &str = "profiles"; +const PROFILE_FILE: &str = ".zed-global-profile.json"; +const STATE_FILE: &str = "managed-bins.json"; +const LOCK_FILE: &str = ".lock"; + +#[derive(Debug, Args)] +struct GlobalArgs { + #[command(subcommand)] + command: GlobalAction, +} + +#[derive(Debug, Subcommand)] +enum GlobalAction { + /// Install one or more executable packages into isolated global profiles. + Install(GlobalInstallArgs), + /// Remove global package profiles and their managed PATH entries. + #[command(alias = "un")] + Uninstall(GlobalUninstallArgs), + /// List globally installed top-level packages and exposed binaries. + List, + /// Print the directory where global executable copies are managed. + #[command(name = "bin-dir")] + BinDir, +} + +#[derive(Debug, Args)] +struct GlobalInstallArgs { + /// Package specs (`org/name[@requirement]`). With --frozen, these are + /// optional installed-profile selectors; omit them to restore every profile. + #[arg(value_name = "PACKAGE")] + specs: Vec, + + /// Reinstall exactly what each selected global profile lockfile pins. + #[arg(long, env = "ZED_PKG_FROZEN")] + frozen: bool, + + /// Materialize profile package trees by symlink or copy. Executables placed + /// on PATH are always independently owned copies. + #[arg( + long, + value_enum, + env = "ZED_PKG_INSTALL_MODE", + default_value = "symlink" + )] + install_mode: InstallMode, + + /// Run package-declared build hooks. Source-distributed CLIs generally need + /// this once so their declared [bin] output exists. + #[arg(long, env = "ZED_PKG_ALLOW_BUILD")] + allow_build: bool, + + /// Select a specific polyglot target before exposing its binaries. + #[arg(long, env = "ZED_PKG_TARGET")] + target: Option, +} + +#[derive(Debug, Args)] +struct GlobalUninstallArgs { + /// Installed top-level package identities (`org/name`). Omit to remove all. + #[arg(value_name = "PACKAGE")] + specs: Vec, +} + +#[derive(Debug, Parser)] +#[command( + name = "zed", + version, + about = "zed: the universal package manager backed by the VCS hosts you already use" +)] +struct GlobalCli { + #[command(flatten)] + globals: Globals, + + /// Directory placed on PATH for globally installed package executables. + #[arg( + long, + global = true, + env = "ZED_PKG_GLOBAL_BIN_DIR", + value_name = "PATH" + )] + global_bin_dir: Option, + + #[command(subcommand)] + command: GlobalCommand, +} + +#[derive(Debug, Subcommand)] +enum GlobalCommand { + /// Manage executable packages outside any one project. `zed install + /// --global ...` and `zed uninstall --global ...` route here too. + Global(GlobalArgs), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Route { + Global, + Alias { + command_index: usize, + global_index: usize, + action: &'static str, + }, + GlobalHelp { + help_index: usize, + target_index: usize, + }, + Existing, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ProfileMetadata { + package: String, + requested: String, +} + +#[derive(Debug, Clone)] +struct Profile { + root: PathBuf, + metadata: ProfileMetadata, +} + +#[derive(Debug, Clone)] +struct DesiredBin { + package: String, + source: PathBuf, + sha256: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ManagedBin { + package: String, + sha256: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct ManagedState { + #[serde(default)] + bins: BTreeMap, +} + +#[derive(Debug)] +struct StagedProfile { + root: PathBuf, + backup: Option, +} + +#[derive(Debug)] +struct BinBackup { + destination: PathBuf, + backup: PathBuf, +} + +#[derive(Debug)] +struct StagedExecutable { + destination_name: String, + destination: PathBuf, + temporary: PathBuf, + desired: DesiredBin, +} + +struct GlobalLock(fs::File); + +impl Drop for GlobalLock { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.0); + } +} + +/// Route the modular global-package command before the typed project command +/// parser. The canonical form is `zed global install`; the npm/cargo-style +/// `zed install --global` spelling is rewritten to the same parser and codepath. +pub fn dispatch(args: Vec) -> Option> { + match route(&args) { + Route::Global => Some(run_cli(args)), + Route::Alias { + command_index, + global_index, + action, + } => { + let mut rewritten = args; + rewritten.remove(global_index); + let command_index = if global_index < command_index { + command_index - 1 + } else { + command_index + }; + rewritten[command_index] = OsString::from("global"); + rewritten.insert(command_index + 1, OsString::from(action)); + Some(run_cli(rewritten)) + } + Route::GlobalHelp { + help_index, + target_index, + } => { + let mut rewritten = args; + rewritten[help_index] = OsString::from("global"); + rewritten.remove(target_index); + rewritten.push(OsString::from("--help")); + Some(run_cli(rewritten)) + } + Route::Existing => None, + } +} + +/// Add `zed global` to root help and generated completions without forcing the +/// established project command enum to own a second installation lifecycle. +pub fn augment_root_command(command: clap::Command) -> clap::Command { + if command + .get_subcommands() + .any(|subcommand| subcommand.get_name() == "global") + { + return command; + } + let global = ::augment_args( + clap::Command::new("global") + .about("Install executable packages globally and manage their PATH entries"), + ) + .arg( + clap::Arg::new("global-bin-dir") + .long("global-bin-dir") + .global(true) + .env("ZED_PKG_GLOBAL_BIN_DIR") + .value_name("PATH") + .help("Directory placed on PATH for global package executables"), + ); + command.subcommand(global) +} + +fn run_cli(args: Vec) -> Result { + let cli = match GlobalCli::try_parse_from(args) { + Ok(cli) => cli, + Err(error) => { + let code = error.exit_code(); + error + .print() + .context("printing zed global argument error")?; + return Ok(code); + } + }; + let cfg = Config::from_globals(&cli.globals)?; + let bin_dir = resolve_bin_dir(&cfg, cli.global_bin_dir.as_deref()); + match cli.command { + GlobalCommand::Global(args) => match args.command { + GlobalAction::Install(options) => install(&cfg, &bin_dir, options), + GlobalAction::Uninstall(options) => uninstall(&cfg, &bin_dir, options), + GlobalAction::List => list(&cfg, &bin_dir), + GlobalAction::BinDir => { + println!("{}", bin_dir.display()); + Ok(0) + } + }, + } +} + +fn route(args: &[OsString]) -> Route { + let Some((command_index, command)) = first_command(args) else { + return Route::Existing; + }; + + match command.as_str() { + "global" => Route::Global, + "install" | "i" => global_flag(args).map_or(Route::Existing, |global_index| Route::Alias { + command_index, + global_index, + action: "install", + }), + "uninstall" | "un" => { + global_flag(args).map_or(Route::Existing, |global_index| Route::Alias { + command_index, + global_index, + action: "uninstall", + }) + } + "help" => match next_positional(args, command_index + 1) { + Some((target_index, target)) if target == "global" => Route::GlobalHelp { + help_index: command_index, + target_index, + }, + _ => Route::Existing, + }, + _ => Route::Existing, + } +} + +fn first_command(args: &[OsString]) -> Option<(usize, String)> { + let mut index = 1; + while index < args.len() { + let token = args[index].to_string_lossy(); + if token == "--" { + return next_positional(args, index + 1); + } + if global_option_takes_value(&token) { + index += if token.contains('=') { 1 } else { 2 }; + continue; + } + if token.starts_with('-') { + index += 1; + continue; + } + return Some((index, token.into_owned())); + } + None +} + +fn next_positional(args: &[OsString], mut index: usize) -> Option<(usize, String)> { + while index < args.len() { + let token = args[index].to_string_lossy(); + if !token.starts_with('-') { + return Some((index, token.into_owned())); + } + index += 1; + } + None +} + +fn global_option_takes_value(token: &str) -> bool { + const OPTIONS: &[&str] = &[ + "--registry", + "--home", + "--token", + "--auth-url", + "--supabase-url", + "--supabase-key", + "--global-bin-dir", + ]; + OPTIONS.iter().any(|option| { + token == *option + || token + .strip_prefix(option) + .is_some_and(|remainder| remainder.starts_with('=')) + }) +} + +fn global_flag(args: &[OsString]) -> Option { + args.iter() + .position(|value| value == OsStr::new("--global")) +} + +fn acquire_lock(cfg: &Config) -> Result { + let root = global_root(cfg); + fs::create_dir_all(&root)?; + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(root.join(LOCK_FILE))?; + file.lock_exclusive() + .context("locking global package profiles")?; + Ok(GlobalLock(file)) +} + +fn global_root(cfg: &Config) -> PathBuf { + cfg.home.join(GLOBAL_DIR) +} + +fn profiles_root(cfg: &Config) -> PathBuf { + global_root(cfg).join(PROFILES_DIR) +} + +fn state_path(cfg: &Config) -> PathBuf { + global_root(cfg).join(STATE_FILE) +} + +#[cfg(windows)] +fn resolve_bin_dir(cfg: &Config, explicit: Option<&Path>) -> PathBuf { + explicit + .map(Path::to_path_buf) + .or_else(|| dirs::data_local_dir().map(|root| root.join("zed-pkg").join("bin"))) + .unwrap_or_else(|| cfg.home.join("bin")) +} + +#[cfg(not(windows))] +fn resolve_bin_dir(cfg: &Config, explicit: Option<&Path>) -> PathBuf { + explicit + .map(Path::to_path_buf) + .or_else(|| dirs::home_dir().map(|home| home.join(".local").join("bin"))) + .unwrap_or_else(|| cfg.home.join("bin")) +} + +fn parse_package_spec(spec: &str) -> Result<(String, Option)> { + let spec = spec.trim(); + if spec.is_empty() { + bail!("package spec cannot be empty"); + } + let (key, requirement) = match spec.rsplit_once('@') { + Some((key, requirement)) if key.contains('/') => { + if requirement.trim().is_empty() { + bail!("empty requirement in package spec `{spec}`"); + } + (key, Some(requirement.trim().to_string())) + } + _ => (spec, None), + }; + let (org, name) = key + .split_once('/') + .filter(|(org, name)| !org.is_empty() && !name.is_empty()) + .with_context(|| { + format!("invalid package spec `{spec}` (expected org/name[@requirement])") + })?; + if name.contains('/') || !is_slug(org) || !is_slug(name) { + bail!("invalid package identity `{key}`; expected lowercase slug org/name"); + } + Ok((format!("{org}/{name}"), requirement)) +} + +fn parse_install_specs(specs: &[String]) -> Result> { + let mut seen = BTreeSet::new(); + let mut parsed = Vec::with_capacity(specs.len()); + for requested in specs { + let (key, _) = parse_package_spec(requested)?; + if !seen.insert(key.clone()) { + bail!("global package `{key}` was requested more than once"); + } + parsed.push((requested.clone(), key)); + } + Ok(parsed) +} + +fn profile_root(cfg: &Config, key: &str) -> Result { + let (org, name) = key + .split_once('/') + .context("validated package identity contains a slash")?; + if !is_slug(org) || !is_slug(name) { + bail!("invalid global package identity `{key}`"); + } + Ok(profiles_root(cfg).join(org).join(name)) +} + +fn install(cfg: &Config, bin_dir: &Path, options: GlobalInstallArgs) -> Result { + let _lock = acquire_lock(cfg)?; + if options.frozen { + let profiles = selected_profiles(cfg, &options.specs)?; + if profiles.is_empty() { + bail!("no global package profiles are installed"); + } + for profile in &profiles { + manifestless::install( + &profile.root, + cfg, + &[], + true, + options.install_mode, + Adapter::None, + options.allow_build, + options.target.as_deref(), + true, + true, + )?; + } + let profiles = discover_profiles(cfg)?; + let installed = sync_bins(cfg, bin_dir, &profiles)?; + print_path_guidance(bin_dir); + println!( + "{} global package profile(s); {} executable(s) managed in {}", + profiles.len(), + installed, + bin_dir.display() + ); + return Ok(0); + } + + if options.specs.is_empty() { + bail!( + "global install needs one or more `org/name[@requirement]` package specs; use --frozen to restore existing profiles" + ); + } + + let requested = parse_install_specs(&options.specs)?; + let mut staged_profiles = Vec::with_capacity(requested.len()); + let result = (|| -> Result<(usize, usize)> { + for (spec, key) in &requested { + let root = profile_root(cfg, key)?; + staged_profiles.push(stage_profile_replacement(&root)?); + manifestless::install( + &root, + cfg, + std::slice::from_ref(spec), + false, + options.install_mode, + Adapter::None, + options.allow_build, + options.target.as_deref(), + true, + true, + )?; + write_metadata( + &root, + &ProfileMetadata { + package: key.clone(), + requested: spec.clone(), + }, + )?; + if profile_bins(&root)?.is_empty() { + eprintln!( + "warning: {key} currently exposes no built [bin] entries in this profile; if it declares a [build] step, reinstall with --allow-build" + ); + } + } + let profiles = discover_profiles(cfg)?; + let installed = sync_bins(cfg, bin_dir, &profiles)?; + Ok((profiles.len(), installed)) + })(); + + match result { + Ok((profile_count, installed)) => { + discard_profile_backups(&staged_profiles); + print_path_guidance(bin_dir); + println!( + "{profile_count} global package profile(s); {installed} executable(s) managed in {}", + bin_dir.display() + ); + Ok(0) + } + Err(error) => { + if let Err(rollback) = rollback_staged_profiles(&staged_profiles) { + return Err(anyhow!( + "global installation failed: {error:#}; profile rollback also failed: {rollback:#}" + )); + } + Err(error) + } + } +} + +fn uninstall(cfg: &Config, bin_dir: &Path, options: GlobalUninstallArgs) -> Result { + let _lock = acquire_lock(cfg)?; + let profiles = selected_profiles(cfg, &options.specs)?; + if profiles.is_empty() { + println!("no matching global package profiles"); + return Ok(0); + } + for profile in &profiles { + interactive::confirm( + cfg.interactive, + &format!("remove global package profile {}", profile.metadata.package), + )?; + } + + let mut staged_profiles = Vec::with_capacity(profiles.len()); + let result = (|| -> Result<(usize, usize)> { + for profile in &profiles { + staged_profiles.push(stage_profile_removal(&profile.root)?); + } + let remaining = discover_profiles(cfg)?; + let installed = sync_bins(cfg, bin_dir, &remaining)?; + Ok((remaining.len(), installed)) + })(); + + match result { + Ok((remaining, installed)) => { + discard_profile_backups(&staged_profiles); + for profile in &profiles { + println!("uninstalled {}", profile.metadata.package); + } + println!( + "{remaining} global package profile(s) remain; {installed} executable(s) managed in {}", + bin_dir.display() + ); + Ok(0) + } + Err(error) => { + if let Err(rollback) = rollback_staged_profiles(&staged_profiles) { + return Err(anyhow!( + "global uninstall failed: {error:#}; profile rollback also failed: {rollback:#}" + )); + } + Err(error) + } + } +} + +fn list(cfg: &Config, bin_dir: &Path) -> Result { + let profiles = discover_profiles(cfg)?; + if profiles.is_empty() { + println!("no global package profiles installed"); + } + for profile in profiles { + let version = locked_root_version(&profile).unwrap_or_else(|| "unknown".to_string()); + let mut bins: Vec = profile_bins(&profile.root)?.into_keys().collect(); + bins.sort(); + println!( + "{}@{} (requested `{}`; bins: {})", + profile.metadata.package, + version, + profile.metadata.requested, + if bins.is_empty() { + "none".to_string() + } else { + bins.join(", ") + } + ); + } + println!("global bin directory: {}", bin_dir.display()); + Ok(0) +} + +fn selected_profiles(cfg: &Config, specs: &[String]) -> Result> { + let profiles = discover_profiles(cfg)?; + if specs.is_empty() { + return Ok(profiles); + } + let mut by_key: BTreeMap = profiles + .into_iter() + .map(|profile| (profile.metadata.package.clone(), profile)) + .collect(); + let mut selected = Vec::new(); + let mut seen = BTreeSet::new(); + for spec in specs { + let (key, requirement) = parse_package_spec(spec)?; + if requirement.is_some() { + bail!( + "global profile selectors must be package identities without versions (got `{spec}`)" + ); + } + if !seen.insert(key.clone()) { + bail!("global package `{key}` was selected more than once"); + } + let profile = by_key + .remove(&key) + .with_context(|| format!("global package `{key}` is not installed"))?; + selected.push(profile); + } + Ok(selected) +} + +fn discover_profiles(cfg: &Config) -> Result> { + let root = profiles_root(cfg); + if !root.is_dir() { + return Ok(Vec::new()); + } + let mut profiles = Vec::new(); + for org in fs::read_dir(&root)? { + let org = org?; + if !org.file_type()?.is_dir() { + continue; + } + let org_name = org.file_name().to_string_lossy().to_string(); + if !is_slug(&org_name) { + continue; + } + for package in fs::read_dir(org.path())? { + let package = package?; + if !package.file_type()?.is_dir() { + continue; + } + let package_name = package.file_name().to_string_lossy().to_string(); + if !is_slug(&package_name) { + continue; + } + let metadata_path = package.path().join(PROFILE_FILE); + if !metadata_path.is_file() { + continue; + } + let metadata: ProfileMetadata = serde_json::from_slice(&fs::read(&metadata_path)?) + .with_context(|| format!("parsing {}", metadata_path.display()))?; + let expected = format!("{org_name}/{package_name}"); + if metadata.package != expected { + bail!( + "{} claims package `{}` but its managed path requires `{expected}`", + metadata_path.display(), + metadata.package + ); + } + profiles.push(Profile { + root: package.path(), + metadata, + }); + } + } + profiles.sort_by(|left, right| left.metadata.package.cmp(&right.metadata.package)); + Ok(profiles) +} + +fn stage_profile_replacement(root: &Path) -> Result { + let backup = if path_present(root) { + let backup = unique_sibling(root, "profile-backup")?; + fs::rename(root, &backup).with_context(|| { + format!( + "staging existing global profile {} at {}", + root.display(), + backup.display() + ) + })?; + Some(backup) + } else { + None + }; + if let Err(error) = fs::create_dir_all(root) { + if let Some(backup) = &backup { + let _ = fs::rename(backup, root); + } + return Err(error).with_context(|| format!("creating global profile {}", root.display())); + } + Ok(StagedProfile { + root: root.to_path_buf(), + backup, + }) +} + +fn stage_profile_removal(root: &Path) -> Result { + if !path_present(root) { + bail!( + "global profile disappeared before removal: {}", + root.display() + ); + } + let backup = unique_sibling(root, "profile-removal")?; + fs::rename(root, &backup).with_context(|| { + format!( + "staging global profile removal {} at {}", + root.display(), + backup.display() + ) + })?; + Ok(StagedProfile { + root: root.to_path_buf(), + backup: Some(backup), + }) +} + +fn rollback_staged_profiles(staged: &[StagedProfile]) -> Result<()> { + let mut failures = Vec::new(); + for profile in staged.iter().rev() { + if let Err(error) = remove_path_if_present(&profile.root) { + failures.push(format!("remove {}: {error:#}", profile.root.display())); + continue; + } + if let Some(backup) = &profile.backup + && path_present(backup) + && let Err(error) = fs::rename(backup, &profile.root) + { + failures.push(format!( + "restore {} from {}: {error}", + profile.root.display(), + backup.display() + )); + } + } + if failures.is_empty() { + Ok(()) + } else { + bail!("{}", failures.join("; ")) + } +} + +fn discard_profile_backups(staged: &[StagedProfile]) { + for profile in staged { + if let Some(backup) = &profile.backup + && let Err(error) = remove_path_if_present(backup) + { + eprintln!( + "warning: could not remove committed profile backup {}: {error:#}", + backup.display() + ); + } + } +} + +fn write_metadata(root: &Path, metadata: &ProfileMetadata) -> Result<()> { + fs::create_dir_all(root)?; + atomic_write( + &root.join(PROFILE_FILE), + &serde_json::to_vec_pretty(metadata)?, + ) +} + +fn locked_root_version(profile: &Profile) -> Option { + let text = fs::read_to_string(profile.root.join(LOCKFILE_FILE)).ok()?; + let lock = Lockfile::parse(&text).ok()?; + let (org, name) = profile.metadata.package.split_once('/')?; + lock.find(org, name).map(|package| package.version.clone()) +} + +fn profile_bins(root: &Path) -> Result> { + let bin_dir = root.join(MODULES_DIR).join(BIN_DIR); + if !bin_dir.is_dir() { + return Ok(BTreeMap::new()); + } + let mut bins = BTreeMap::new(); + for entry in fs::read_dir(&bin_dir)? { + let entry = entry?; + if !entry.file_type()?.is_file() { + continue; + } + let name = entry + .file_name() + .into_string() + .map_err(|_| anyhow!("global bin name is not valid UTF-8"))?; + validate_bin_name(&name)?; + bins.insert(name, entry.path()); + } + Ok(bins) +} + +fn validate_bin_name(name: &str) -> Result<()> { + if name.is_empty() + || name == "." + || name == ".." + || name.contains('/') + || name.contains('\\') + || !name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '+')) + { + bail!("unsafe global bin name `{name}`"); + } + Ok(()) +} + +fn collect_desired_bins(profiles: &[Profile]) -> Result> { + let mut desired: BTreeMap = BTreeMap::new(); + for profile in profiles { + for (logical_name, source) in profile_bins(&profile.root)? { + let destination = destination_name(&logical_name); + if let Some(existing) = desired.get(&destination) { + bail!( + "global bin collision for `{destination}` between {} and {}; uninstall one package or use separate ZED_PKG_GLOBAL_BIN_DIR values", + existing.package, + profile.metadata.package + ); + } + desired.insert( + destination, + DesiredBin { + package: profile.metadata.package.clone(), + sha256: hash_file(&source)?, + source, + }, + ); + } + } + Ok(desired) +} + +#[cfg(windows)] +fn destination_name(logical_name: &str) -> String { + if logical_name.to_ascii_lowercase().ends_with(".exe") { + logical_name.to_string() + } else { + format!("{logical_name}.exe") + } +} + +#[cfg(not(windows))] +fn destination_name(logical_name: &str) -> String { + logical_name.to_string() +} + +fn load_state(cfg: &Config) -> Result { + let path = state_path(cfg); + if !path.is_file() { + return Ok(ManagedState::default()); + } + serde_json::from_slice(&fs::read(&path)?) + .with_context(|| format!("parsing managed global-bin state at {}", path.display())) +} + +fn sync_bins(cfg: &Config, bin_dir: &Path, profiles: &[Profile]) -> Result { + let desired = collect_desired_bins(profiles)?; + let previous = load_state(cfg)?; + + let mut stale_owned = Vec::new(); + for (name, managed) in &previous.bins { + if desired.contains_key(name) { + continue; + } + let destination = bin_dir.join(name); + if !path_present(&destination) { + continue; + } + let current = hash_file(&destination)?; + if current == managed.sha256 { + stale_owned.push(destination); + } else { + eprintln!( + "warning: leaving {} because it changed after Zed installed it", + destination.display() + ); + } + } + + for (name, wanted) in &desired { + let destination = bin_dir.join(name); + if !path_present(&destination) { + continue; + } + let current = hash_file(&destination)?; + let owned = previous + .bins + .get(name) + .is_some_and(|managed| managed.sha256 == current); + if !owned { + bail!( + "refusing to replace unmanaged global executable {}; choose another --global-bin-dir or remove the collision explicitly", + destination.display() + ); + } + if wanted.sha256.is_empty() { + bail!("empty desired hash for global executable `{name}`"); + } + } + + fs::create_dir_all(bin_dir)?; + let mut staged = Vec::with_capacity(desired.len()); + for (name, wanted) in desired { + let destination = bin_dir.join(&name); + let temporary = stage_executable(&wanted.source, &destination)?; + staged.push(StagedExecutable { + destination_name: name, + destination, + temporary, + desired: wanted, + }); + } + + let mut backups = Vec::new(); + let mut promoted = Vec::new(); + let transaction = (|| -> Result { + let mut destinations = stale_owned; + destinations.extend( + staged + .iter() + .filter(|entry| path_present(&entry.destination)) + .map(|entry| entry.destination.clone()), + ); + destinations.sort(); + destinations.dedup(); + for destination in destinations { + let backup = unique_sibling(&destination, "bin-backup")?; + fs::rename(&destination, &backup).with_context(|| { + format!( + "staging managed executable {} at {}", + destination.display(), + backup.display() + ) + })?; + backups.push(BinBackup { + destination, + backup, + }); + } + + let mut next = ManagedState::default(); + for entry in &staged { + fs::rename(&entry.temporary, &entry.destination).with_context(|| { + format!( + "promoting global executable into {}", + entry.destination.display() + ) + })?; + promoted.push(entry.destination.clone()); + next.bins.insert( + entry.destination_name.clone(), + ManagedBin { + package: entry.desired.package.clone(), + sha256: entry.desired.sha256.clone(), + }, + ); + } + atomic_write(&state_path(cfg), &serde_json::to_vec_pretty(&next)?)?; + Ok(next) + })(); + + match transaction { + Ok(next) => { + for backup in &backups { + if let Err(error) = remove_path_if_present(&backup.backup) { + eprintln!( + "warning: could not remove committed executable backup {}: {error:#}", + backup.backup.display() + ); + } + } + for entry in &staged { + let _ = remove_path_if_present(&entry.temporary); + } + Ok(next.bins.len()) + } + Err(error) => { + rollback_bin_transaction(&staged, &promoted, &backups)?; + Err(error) + } + } +} + +fn stage_executable(source: &Path, destination: &Path) -> Result { + let parent = destination + .parent() + .context("global executable destination has no parent")?; + fs::create_dir_all(parent)?; + let file_name = destination + .file_name() + .and_then(|name| name.to_str()) + .context("global executable name is not valid UTF-8")?; + let temporary = parent.join(format!(".{file_name}.zed-stage-{}", uuid::Uuid::new_v4())); + fs::copy(source, &temporary).with_context(|| { + format!( + "staging global executable {} at {}", + source.display(), + temporary.display() + ) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(&temporary)?.permissions(); + permissions.set_mode(permissions.mode() | 0o111); + fs::set_permissions(&temporary, permissions)?; + } + Ok(temporary) +} + +fn rollback_bin_transaction( + staged: &[StagedExecutable], + promoted: &[PathBuf], + backups: &[BinBackup], +) -> Result<()> { + let mut failures = Vec::new(); + for destination in promoted.iter().rev() { + if let Err(error) = remove_path_if_present(destination) { + failures.push(format!("remove {}: {error:#}", destination.display())); + } + } + for backup in backups.iter().rev() { + if path_present(&backup.backup) + && let Err(error) = fs::rename(&backup.backup, &backup.destination) + { + failures.push(format!( + "restore {} from {}: {error}", + backup.destination.display(), + backup.backup.display() + )); + } + } + for entry in staged { + if let Err(error) = remove_path_if_present(&entry.temporary) { + failures.push(format!("remove {}: {error:#}", entry.temporary.display())); + } + } + if failures.is_empty() { + Ok(()) + } else { + bail!( + "rolling back global executable transaction: {}", + failures.join("; ") + ) + } +} + +fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path.parent().context("managed file has no parent")?; + fs::create_dir_all(parent)?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .context("managed file name is not valid UTF-8")?; + let temporary = parent.join(format!(".{file_name}.zed-tmp-{}", uuid::Uuid::new_v4())); + fs::write(&temporary, bytes)?; + + let backup = if path_present(path) { + let backup = unique_sibling(path, "write-backup")?; + if let Err(error) = fs::rename(path, &backup) { + let _ = remove_path_if_present(&temporary); + return Err(error).with_context(|| format!("staging {}", path.display())); + } + Some(backup) + } else { + None + }; + + if let Err(error) = fs::rename(&temporary, path) { + let _ = remove_path_if_present(&temporary); + if let Some(backup) = &backup { + let _ = fs::rename(backup, path); + } + return Err(error).with_context(|| format!("promoting {}", path.display())); + } + if let Some(backup) = backup + && let Err(error) = remove_path_if_present(&backup) + { + eprintln!( + "warning: could not remove committed write backup {}: {error:#}", + backup.display() + ); + } + Ok(()) +} + +fn unique_sibling(path: &Path, kind: &str) -> Result { + let parent = path.parent().context("managed path has no parent")?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .context("managed path name is not valid UTF-8")?; + Ok(parent.join(format!(".{name}.zed-{kind}-{}", uuid::Uuid::new_v4()))) +} + +fn path_present(path: &Path) -> bool { + fs::symlink_metadata(path).is_ok() +} + +fn remove_path_if_present(path: &Path) -> Result<()> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + if metadata.file_type().is_dir() && !metadata.file_type().is_symlink() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + Ok(()) +} + +fn hash_file(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("reading {}", path.display()))?; + Ok(hex::encode(Sha256::digest(bytes))) +} + +#[cfg(windows)] +fn print_path_guidance(bin_dir: &Path) { + if path_contains(bin_dir) { + return; + } + eprintln!( + "warning: {} is not currently on PATH; add it to the user PATH once", + bin_dir.display() + ); +} + +#[cfg(not(windows))] +fn print_path_guidance(bin_dir: &Path) { + if path_contains(bin_dir) { + return; + } + eprintln!( + "warning: {} is not currently on PATH; add it once, for example:\n export PATH=\"{}:$PATH\"", + bin_dir.display(), + bin_dir.display() + ); +} + +fn path_contains(bin_dir: &Path) -> bool { + let expected = fs::canonicalize(bin_dir).unwrap_or_else(|_| bin_dir.to_path_buf()); + env::var_os("PATH").is_some_and(|value| { + env::split_paths(&value).any(|entry| fs::canonicalize(&entry).unwrap_or(entry) == expected) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(home: &Path) -> Config { + Config { + registry: "file:///unused".to_string(), + home: home.to_path_buf(), + token: None, + auth_url: "https://localhost/shared-auth".to_string(), + supabase_url: None, + supabase_key: None, + interactive: false, + } + } + + fn add_profile(home: &Path, key: &str, bin_name: &str, contents: &[u8]) -> Profile { + let cfg = config(home); + let root = profile_root(&cfg, key).unwrap(); + let bin = root.join(MODULES_DIR).join(BIN_DIR).join(bin_name); + fs::create_dir_all(bin.parent().unwrap()).unwrap(); + fs::write(&bin, contents).unwrap(); + let metadata = ProfileMetadata { + package: key.to_string(), + requested: key.to_string(), + }; + write_metadata(&root, &metadata).unwrap(); + Profile { root, metadata } + } + + #[test] + fn package_specs_are_strict_and_traversal_safe() { + assert_eq!( + parse_package_spec("acme/tool@^1").unwrap(), + ("acme/tool".to_string(), Some("^1".to_string())) + ); + assert_eq!( + parse_package_spec("acme/tool").unwrap(), + ("acme/tool".to_string(), None) + ); + for invalid in [ + "tool", + "acme/tool/extra", + "../tool", + "acme/../tool", + "Acme/tool", + ] { + assert!(parse_package_spec(invalid).is_err(), "accepted {invalid}"); + } + } + + #[test] + fn duplicate_install_specs_fail_before_profile_mutation() { + let error = parse_install_specs(&["acme/tool@1".to_string(), "acme/tool@2".to_string()]) + .unwrap_err(); + assert!(error.to_string().contains("requested more than once")); + } + + #[test] + fn alias_route_rewrites_install_global() { + let args = vec![ + OsString::from("zed"), + OsString::from("install"), + OsString::from("--global"), + OsString::from("acme/tool"), + ]; + assert!(matches!( + route(&args), + Route::Alias { + action: "install", + .. + } + )); + } + + #[test] + fn global_bins_are_copied_and_tampered_files_are_preserved() { + let home = tempfile::tempdir().unwrap(); + let bin_dir = home.path().join("path-bin"); + let cfg = config(home.path()); + let profile = add_profile(home.path(), "acme/tool", "acme-tool", b"first"); + + assert_eq!( + sync_bins(&cfg, &bin_dir, std::slice::from_ref(&profile)).unwrap(), + 1 + ); + let installed = bin_dir.join(destination_name("acme-tool")); + assert_eq!(fs::read(&installed).unwrap(), b"first"); + + fs::write(&installed, b"user replacement").unwrap(); + assert_eq!(sync_bins(&cfg, &bin_dir, &[]).unwrap(), 0); + assert_eq!(fs::read(&installed).unwrap(), b"user replacement"); + } + + #[test] + fn bin_collisions_fail_before_path_mutation() { + let home = tempfile::tempdir().unwrap(); + let first = add_profile(home.path(), "acme/one", "tool", b"one"); + let second = add_profile(home.path(), "acme/two", "tool", b"two"); + let error = collect_desired_bins(&[first, second]).unwrap_err(); + assert!(error.to_string().contains("collision")); + } + + #[test] + fn unmanaged_collision_preflight_does_not_install_other_commands() { + let home = tempfile::tempdir().unwrap(); + let cfg = config(home.path()); + let bin_dir = home.path().join("bin"); + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(bin_dir.join(destination_name("tool")), b"unmanaged").unwrap(); + let first = add_profile(home.path(), "acme/one", "alpha", b"alpha"); + let second = add_profile(home.path(), "acme/two", "tool", b"managed candidate"); + + let error = sync_bins(&cfg, &bin_dir, &[first, second]).unwrap_err(); + assert!(error.to_string().contains("unmanaged global executable")); + assert!(!bin_dir.join(destination_name("alpha")).exists()); + assert_eq!( + fs::read(bin_dir.join(destination_name("tool"))).unwrap(), + b"unmanaged" + ); + } + + #[test] + fn profile_replacement_rolls_back_exact_previous_tree() { + let home = tempfile::tempdir().unwrap(); + let root = home.path().join("profiles").join("acme").join("tool"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("previous"), b"preserve me").unwrap(); + let staged = stage_profile_replacement(&root).unwrap(); + fs::write(root.join("new"), b"discard me").unwrap(); + + rollback_staged_profiles(&[staged]).unwrap(); + assert_eq!(fs::read(root.join("previous")).unwrap(), b"preserve me"); + assert!(!root.join("new").exists()); + } + + #[test] + fn explicit_global_bin_directory_wins() { + let home = tempfile::tempdir().unwrap(); + let cfg = config(home.path()); + let explicit = home.path().join("custom-bin"); + assert_eq!(resolve_bin_dir(&cfg, Some(&explicit)), explicit); + } +} diff --git a/src/lib.rs b/src/lib.rs index 811383da..8e822f71 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod environment; pub mod fetch; pub mod flags; pub mod git_submodules; +pub mod global; pub mod install_graph; pub mod interactive; pub mod managed_install; diff --git a/src/main.rs b/src/main.rs index 71cd6454..17d10942 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use std::ffi::{OsStr, OsString}; + use zed_cli::asdf_environment; use zed_cli::auth; use zed_cli::cli::EnvCmd; @@ -10,6 +12,7 @@ use zed_cli::dev; use zed_cli::environment; use zed_cli::fetch; use zed_cli::git_submodules as submodules; +use zed_cli::global; use zed_cli::managed_install; use zed_cli::nix_bundle_write; use zed_cli::nix_export_plan; @@ -27,6 +30,27 @@ fn main() { eprintln!("error: {error:#}"); std::process::exit(2); } + if root_help_requested(&args) { + if let Err(error) = completion::print_root_help() { + eprintln!("error: {error:#}"); + std::process::exit(1); + } + return; + } + let global_requested = args.iter().skip(1).any(|argument| { + let argument = argument.as_os_str(); + argument == OsStr::new("global") || argument == OsStr::new("--global") + }); + if global_requested && let Some(result) = global::dispatch(args.clone()) { + match result { + Ok(0) => return, + Ok(code) => std::process::exit(code), + Err(error) => { + eprintln!("error: {error:#}"); + std::process::exit(1); + } + } + } if let Some(result) = submodules::dispatch(args.clone()) { match result { Ok(0) => return, @@ -89,6 +113,50 @@ fn main() { } } +fn root_help_requested(args: &[OsString]) -> bool { + let mut index = 1; + while index < args.len() { + let token = args[index].to_string_lossy(); + if token == "--help" || token == "-h" { + return true; + } + if token == "help" { + return args + .iter() + .skip(index + 1) + .all(|argument| argument.to_string_lossy().starts_with('-')); + } + if root_global_option_takes_value(&token) { + index += if token.contains('=') { 1 } else { 2 }; + continue; + } + if token.starts_with('-') { + index += 1; + continue; + } + return false; + } + false +} + +fn root_global_option_takes_value(token: &str) -> bool { + const OPTIONS: &[&str] = &[ + "--registry", + "--home", + "--token", + "--auth-url", + "--supabase-url", + "--supabase-key", + "--global-bin-dir", + ]; + OPTIONS.iter().any(|option| { + token == *option + || token + .strip_prefix(option) + .is_some_and(|remainder| remainder.starts_with('=')) + }) +} + fn run(cli: Cli) -> anyhow::Result<()> { let cfg = Config::from_globals(&cli.globals)?; let git_submodules = cli.globals.git_submodules; diff --git a/src/ops.rs b/src/ops.rs index bd4b1cf9..907119b8 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -1063,6 +1063,52 @@ version = 1 assert_eq!(fs::read(&lock_path).unwrap(), original); } +fn locked_version_metadata(locked: &LockedPackage) -> VersionMetadata { + VersionMetadata { + org: locked.org.clone(), + name: locked.name.clone(), + version: locked.version.clone(), + sha256: locked.sha256.clone(), + size: locked.size, + format: locked.format, + vcs_tag: locked.vcs_tag.clone(), + vcs_commit: locked.vcs_commit.clone(), + // Never consumed while the store or verified cache owns the + // bytes. If local verification fails, ensure_artifact falls + // back to the configured registry and reports that failure. + download_url: String::new(), + published_at: "1970-01-01T00:00:00Z".to_string(), + yanked: false, + } +} + +#[cfg(test)] +#[test] +fn frozen_local_metadata_preserves_lock_identity_and_provenance() { + let locked = LockedPackage { + org: "acme".to_string(), + name: "tool".to_string(), + version: "1.2.3".to_string(), + sha256: "a".repeat(64), + size: 42, + format: zed_interfaces::artifact::ArtifactFormat::TarGz, + vcs_tag: "v1.2.3".to_string(), + vcs_commit: Some("b".repeat(40)), + source: "https://registry.invalid".to_string(), + }; + let metadata = locked_version_metadata(&locked); + assert_eq!(metadata.org, locked.org); + assert_eq!(metadata.name, locked.name); + assert_eq!(metadata.version, locked.version); + assert_eq!(metadata.sha256, locked.sha256); + assert_eq!(metadata.size, locked.size); + assert_eq!(metadata.format, locked.format); + assert_eq!(metadata.vcs_tag, locked.vcs_tag); + assert_eq!(metadata.vcs_commit, locked.vcs_commit); + assert!(metadata.download_url.is_empty()); + assert!(!metadata.yanked); +} + /// Install body, called with the store lock already held. Split out so the /// build-hook path can install `[build-dependencies]` into a staging dir /// under the same lock without deadlocking on a re-acquire. @@ -1130,16 +1176,25 @@ fn install_locked( ); } require_sha256(&locked.sha256)?; - let vm = reg.get_version(&locked.org, &locked.name, &locked.version)?; - if vm.sha256 != locked.sha256 { - bail!( - "registry artifact for {}@{} changed (lock {} vs registry {}); refusing", - locked.full_name(), - locked.version, - locked.sha256, - vm.sha256 - ); - } + let vm = if store.has(&locked.sha256) || store.cached_artifact(&locked.sha256).is_file() + { + // The lock already carries every immutable field needed + // to authenticate locally cached bytes. Avoid turning an + // exact frozen replay into a registry availability check. + locked_version_metadata(locked) + } else { + let vm = reg.get_version(&locked.org, &locked.name, &locked.version)?; + if vm.sha256 != locked.sha256 { + bail!( + "registry artifact for {}@{} changed (lock {} vs registry {}); refusing", + locked.full_name(), + locked.version, + locked.sha256, + vm.sha256 + ); + } + vm + }; validate_version_metadata(&vm)?; resolved.insert(locked.full_name(), vm); } diff --git a/tests/global_cli.rs b/tests/global_cli.rs new file mode 100644 index 00000000..d7fa561c --- /dev/null +++ b/tests/global_cli.rs @@ -0,0 +1,248 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; + +fn run(args: &[&str], home: &Path, bin_dir: &Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_zed")) + .args(args) + .env("ZED_PKG_HOME", home) + .env("ZED_PKG_GLOBAL_BIN_DIR", bin_dir) + .env("ZED_PKG_REGISTRY", "file:///unused") + .env_remove("ZED_PKG_TOKEN") + .env_remove("ZED_PKG_INTERACTIVE") + .output() + .expect("run zed") +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn profile_root(home: &Path) -> PathBuf { + home.join("global") + .join("profiles") + .join("acme") + .join("tool") +} + +fn add_profile(home: &Path, requested: &str, command: &str, bytes: &[u8]) -> PathBuf { + let root = profile_root(home); + let package_bin = root.join("zed_modules").join(".bin").join(command); + fs::create_dir_all(package_bin.parent().expect("profile bin parent")).unwrap(); + fs::write(&package_bin, bytes).unwrap(); + fs::write(root.join(".zpkg.lock"), "version = 1\n").unwrap(); + fs::write( + root.join(".zed-global-profile.json"), + serde_json::to_vec_pretty(&json!({ + "package": "acme/tool", + "requested": requested, + })) + .unwrap(), + ) + .unwrap(); + root +} + +fn sha256(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +#[cfg(windows)] +fn destination_name(command: &str) -> String { + if command.to_ascii_lowercase().ends_with(".exe") { + command.to_string() + } else { + format!("{command}.exe") + } +} + +#[cfg(not(windows))] +fn destination_name(command: &str) -> String { + command.to_string() +} + +fn write_managed_state(home: &Path, destination: &str, owned_bytes: &[u8]) { + let global = home.join("global"); + fs::create_dir_all(&global).unwrap(); + let mut bins = Map::new(); + bins.insert( + destination.to_string(), + json!({ + "package": "acme/tool", + "sha256": sha256(owned_bytes), + }), + ); + fs::write( + global.join("managed-bins.json"), + serde_json::to_vec_pretty(&json!({ "bins": Value::Object(bins) })).unwrap(), + ) + .unwrap(); +} + +#[test] +fn root_help_composes_every_modular_command_family() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + let output = run(&["--help"], home.path(), &bin_dir); + + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + for command in ["global", "fetch", "interop", "overtake", "develop"] { + assert!( + text.contains(command), + "root help omitted {command}: {text}" + ); + } +} + +#[test] +fn help_global_lists_the_complete_lifecycle_and_path_override() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + let output = run(&["help", "global"], home.path(), &bin_dir); + + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + for command in ["install", "uninstall", "list", "bin-dir"] { + assert!( + text.contains(command), + "global help omitted {command}: {text}" + ); + } + assert!(text.contains("--global-bin-dir")); +} + +#[test] +fn global_bin_dir_uses_environment_and_cli_overrides() { + let home = tempfile::tempdir().expect("temporary home"); + let environment_bin = home.path().join("environment-bin"); + let output = run(&["global", "bin-dir"], home.path(), &environment_bin); + assert!(output.status.success(), "{}", stderr(&output)); + assert_eq!( + stdout(&output).trim(), + environment_bin.display().to_string() + ); + + let cli_bin = home.path().join("cli-bin"); + let output = run( + &[ + "--global-bin-dir", + cli_bin.to_str().expect("UTF-8 test path"), + "global", + "bin-dir", + ], + home.path(), + &environment_bin, + ); + assert!(output.status.success(), "{}", stderr(&output)); + assert_eq!(stdout(&output).trim(), cli_bin.display().to_string()); +} + +#[test] +fn empty_global_list_is_network_free_and_deterministic() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + let output = run(&["global", "list"], home.path(), &bin_dir); + + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + assert!(text.contains("no global package profiles installed")); + assert!(text.contains(&bin_dir.display().to_string())); +} + +#[test] +fn global_list_reads_an_offline_profile_and_its_commands() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + add_profile(home.path(), "acme/tool@^1", "acme-tool", b"tool bytes"); + + let output = run(&["global", "list"], home.path(), &bin_dir); + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + assert!(text.contains("acme/tool@unknown"), "{text}"); + assert!(text.contains("requested `acme/tool@^1`"), "{text}"); + assert!(text.contains("bins: acme-tool"), "{text}"); +} + +#[test] +fn install_global_alias_rejects_an_invalid_identity_before_network_io() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + let output = run( + &["install", "--global", "not-a-package-identity"], + home.path(), + &bin_dir, + ); + + assert!(!output.status.success()); + let error = stderr(&output); + assert!( + error.contains("invalid package spec"), + "unexpected error for invalid global package identity: {error}" + ); +} + +#[test] +fn uninstall_global_alias_rejects_versioned_selectors() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + let output = run( + &["uninstall", "--global", "acme/tool@1.0.0"], + home.path(), + &bin_dir, + ); + + assert!(!output.status.success()); + let error = stderr(&output); + assert!( + error.contains("selectors must be package identities without versions"), + "unexpected versioned-selector error: {error}" + ); +} + +#[test] +fn uninstall_removes_an_unchanged_zed_owned_command() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + let profile = add_profile(home.path(), "acme/tool", "acme-tool", b"profile command"); + let destination_name = destination_name("acme-tool"); + let destination = bin_dir.join(&destination_name); + let owned = b"installed command"; + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(&destination, owned).unwrap(); + write_managed_state(home.path(), &destination_name, owned); + + let output = run(&["global", "uninstall", "acme/tool"], home.path(), &bin_dir); + assert!(output.status.success(), "{}", stderr(&output)); + assert!(!profile.exists()); + assert!(!destination.exists()); +} + +#[test] +fn uninstall_preserves_a_command_modified_after_installation() { + let home = tempfile::tempdir().expect("temporary home"); + let bin_dir = home.path().join("bin"); + let profile = add_profile(home.path(), "acme/tool", "acme-tool", b"profile command"); + let destination_name = destination_name("acme-tool"); + let destination = bin_dir.join(&destination_name); + fs::create_dir_all(&bin_dir).unwrap(); + fs::write(&destination, b"user replacement").unwrap(); + write_managed_state(home.path(), &destination_name, b"original zed bytes"); + + let output = run(&["global", "uninstall", "acme/tool"], home.path(), &bin_dir); + assert!(output.status.success(), "{}", stderr(&output)); + assert!(!profile.exists()); + assert_eq!(fs::read(&destination).unwrap(), b"user replacement"); + assert!( + stderr(&output).contains("changed after Zed installed it"), + "missing tamper-preservation warning: {}", + stderr(&output) + ); +} From 0c1931bcdc2065279aea06abe9d66a0eb9e03315 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 16:21:00 -0500 Subject: [PATCH 3/3] feat(DEN-1484): verify release-plan report integrity (#62) Bind release-plan JSON, rendered HTML, and a versioned integrity manifest with deterministic SHA-256 identities; add fail-closed atomic publication, tamper verification, cross-platform tests, and three-engine browser certification. --- .github/workflows/release-plan-browser.yml | 57 ++- docs/release-plan-report.md | 47 ++- scripts/release-plan-integrity.mjs | 392 +++++++++++++++++++ tests/browser/test_release_plan_integrity.py | 91 +++++ tests/release_plan_integrity_test.mjs | 223 +++++++++++ 5 files changed, 800 insertions(+), 10 deletions(-) create mode 100644 scripts/release-plan-integrity.mjs create mode 100644 tests/browser/test_release_plan_integrity.py create mode 100644 tests/release_plan_integrity_test.mjs diff --git a/.github/workflows/release-plan-browser.yml b/.github/workflows/release-plan-browser.yml index cf5a5f74..63023dc4 100644 --- a/.github/workflows/release-plan-browser.yml +++ b/.github/workflows/release-plan-browser.yml @@ -5,10 +5,13 @@ on: paths: - ".github/workflows/release-plan-browser.yml" - "scripts/render-release-plan-html.mjs" + - "scripts/release-plan-integrity.mjs" - "tests/release_plan_report_test.mjs" - "tests/release_plan_accessibility_test.mjs" + - "tests/release_plan_integrity_test.mjs" - "tests/browser/test_release_plan_report.py" - "tests/browser/test_release_plan_cross_browser.py" + - "tests/browser/test_release_plan_integrity.py" - "tests/fixtures/release-report/**" - "docs/release-plan-report.md" - "src/release.rs" @@ -21,10 +24,13 @@ on: paths: - ".github/workflows/release-plan-browser.yml" - "scripts/render-release-plan-html.mjs" + - "scripts/release-plan-integrity.mjs" - "tests/release_plan_report_test.mjs" - "tests/release_plan_accessibility_test.mjs" + - "tests/release_plan_integrity_test.mjs" - "tests/browser/test_release_plan_report.py" - "tests/browser/test_release_plan_cross_browser.py" + - "tests/browser/test_release_plan_integrity.py" - "tests/fixtures/release-report/**" - "docs/release-plan-report.md" - "src/release.rs" @@ -42,8 +48,29 @@ concurrency: cancel-in-progress: true jobs: + integrity-portability: + name: Integrity writer on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: "24" + + - name: Test canonical hashing and atomic publication + run: node --test tests/release_plan_integrity_test.mjs + browser: name: Generate and review in ${{ matrix.browser }} + needs: integrity-portability runs-on: ubuntu-latest timeout-minutes: 45 strategy: @@ -76,12 +103,13 @@ jobs: with: python-version: "3.13" - - name: Test the fail-closed renderer and accessibility structure + - name: Test renderer accessibility and integrity boundaries working-directory: zed-cli run: >- node --test tests/release_plan_report_test.mjs tests/release_plan_accessibility_test.mjs + tests/release_plan_integrity_test.mjs - name: Build the locked Rust CLI working-directory: zed-cli @@ -106,6 +134,20 @@ jobs: --input build/release-plan.json --output build/release-plan.html + - name: Bind and verify release-report integrity + working-directory: zed-cli + shell: bash + run: | + set -euo pipefail + node scripts/release-plan-integrity.mjs bind \ + --plan build/release-plan.json \ + --report build/release-plan.html \ + --integrity build/release-plan.integrity.json + node scripts/release-plan-integrity.mjs verify \ + --plan build/release-plan.json \ + --report build/release-plan.html \ + --integrity build/release-plan.integrity.json + - name: Install pinned Playwright ${{ matrix.browser }} working-directory: zed-cli run: | @@ -120,11 +162,20 @@ jobs: PLAYWRIGHT_BROWSER: ${{ matrix.browser }} run: .venv-release-browser/bin/python tests/browser/test_release_plan_cross_browser.py - - name: Upload the reviewed offline report + - name: Exercise integrity provenance in the browser + working-directory: zed-cli + env: + PLAYWRIGHT_BROWSER: ${{ matrix.browser }} + run: .venv-release-browser/bin/python tests/browser/test_release_plan_integrity.py + + - name: Upload the reviewed release-plan artifact set uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: zed-release-plan-report-${{ matrix.browser }} - path: zed-cli/build/release-plan.html + path: | + zed-cli/build/release-plan.json + zed-cli/build/release-plan.html + zed-cli/build/release-plan.integrity.json if-no-files-found: error retention-days: 7 diff --git a/docs/release-plan-report.md b/docs/release-plan-report.md index 8702e239..07beb246 100644 --- a/docs/release-plan-report.md +++ b/docs/release-plan-report.md @@ -18,18 +18,51 @@ zed release plan --json | \ Open `build/release-plan.html` directly from disk. The report includes source provenance, exact Zed/native/forge counts, artifact tables, explicit empty states, and progressive client-side filtering. Press `Escape` while the filter is focused to clear it and restore the complete plan. +## Integrity binding + +A retained report can be bound to the exact canonical release plan and verified without publishing or contacting a registry: + +```sh +node scripts/release-plan-integrity.mjs bind \ + --plan build/release-plan.json \ + --report build/release-plan.html \ + --integrity build/release-plan.integrity.json + +node scripts/release-plan-integrity.mjs verify \ + --plan build/release-plan.json \ + --report build/release-plan.html \ + --integrity build/release-plan.integrity.json +``` + +The binder validates the plan through the same `ReleasePlan` schema used by the renderer, serializes that validated model with fixed field order, and computes a lowercase SHA-256 digest. Insignificant JSON whitespace and input-object key order therefore do not change the canonical plan digest; artifact-array order remains significant because it is part of the release model. + +The plan digest is inserted into a machine-readable `` element and displayed in the source-provenance list. The final HTML is hashed after that insertion. A versioned `zed-release-plan-report-integrity/v1` manifest records the canonical plan digest, final report digest, and artifact basenames. + +The report is atomically replaced first and the integrity manifest is atomically published last. The manifest's presence therefore represents a completed binding sequence. Both output paths are preflighted and rechecked for symbolic links and directories, and private same-directory temporary files are removed on success or failure. + +Verification fails closed when: + +- the plan, report, or integrity manifest is modified; +- embedded and manifest plan digests disagree; +- the manifest uses an unsupported schema or algorithm; +- digest text is not lowercase 64-character SHA-256 hex; +- artifact filenames do not match the supplied files; +- required or unknown manifest fields are present incorrectly. + +The integrity manifest is not a digital signature and does not establish who approved a report. It provides deterministic tamper and mismatch detection for an artifact set; trusted provenance still comes from Git history, protected Actions, attestations, and the ordinary release approval process. + ## Accessible review - A visible-on-focus skip link moves keyboard users directly to the report. - The filter is associated with instructions and its live result status. - Every artifact table has a descriptive caption in addition to its visible section heading. - Heading order and semantic main/section/table landmarks remain stable. -- With JavaScript disabled, filtering is hidden and every release artifact remains readable. +- With JavaScript disabled, filtering is hidden and every release artifact—including its plan digest—remains readable. - Forced-colors styling retains visible borders and focus indication. ## Printing and PDF review -Use the browser's print command to archive or review the release plan. Print media intentionally hides the filter and skip link, restores every artifact row even when the screen view is filtered, repeats table headers, avoids splitting rows where practical, and retains provenance plus destination counts. +Use the browser's print command to archive or review the release plan. Print media intentionally hides the filter and skip link, restores every artifact row even when the screen view is filtered, repeats table headers, avoids splitting rows where practical, and retains provenance, the plan digest, and destination counts. The generated report remains a single local HTML file. Printing does not fetch remote fonts, styles, scripts, images, or analytics. @@ -39,12 +72,12 @@ The generated report remains a single local HTML file. Printing does not fetch r - The file contains no remote scripts, styles, fonts, images, analytics, or network calls. - Inline style and filter code are authorized by exact SHA-256 Content Security Policy hashes; broad `unsafe-inline` execution is not used. - The renderer validates the expected `ReleasePlan` shape and fails closed on missing or malformed fields and missing command-option values. -- Reports are staged in a same-directory private temporary file and atomically renamed into place. -- An existing symbolic-link output is refused rather than followed, so the renderer cannot overwrite the link target. -- The report is read-only. It does not load credentials, contact registries, or publish artifacts. +- Reports and integrity manifests are staged in same-directory private temporary files and atomically renamed into place. +- Existing symbolic-link outputs are refused rather than followed. +- The report and integrity tools are read-only with respect to package registries. They do not load credentials, contact registries, or publish artifacts. ## Validation -`tests/release_plan_report_test.mjs` checks determinism, schema validation, hostile-value escaping, counts, empty states, CSP construction, strict argument handling, atomic replacement, and symbolic-link refusal. `tests/release_plan_accessibility_test.mjs` locks skip navigation, table captions, progressive enhancement, print restoration, and forced-colors rules. +`tests/release_plan_report_test.mjs` checks determinism, schema validation, hostile-value escaping, counts, empty states, CSP construction, strict argument handling, atomic replacement, and symbolic-link refusal. `tests/release_plan_accessibility_test.mjs` locks skip navigation, table captions, progressive enhancement, print restoration, and forced-colors rules. `tests/release_plan_integrity_test.mjs` covers canonical digest stability, binding and verification, plan/report/manifest tampering, strict manifest schema, symlink refusal before report mutation, temporary-file cleanup, and CLI parsing. -The GitHub Actions browser matrix generates a real release plan through the locked Rust CLI and verifies the same offline file in Chromium, Firefox, and WebKit. It covers filtering and Escape reset, keyboard order, accessible names and captions, print media, no-JavaScript readability, forced-colors behavior, narrow layouts, console/page errors, and external network requests. +The GitHub Actions browser matrix generates a real release plan through the locked Rust CLI, renders it, binds and verifies the three-file artifact set, and exercises the report in Chromium, Firefox, and WebKit. Browser coverage includes displayed and machine-readable digest agreement, text selection, print visibility, no-JavaScript visibility, filtering and Escape reset, keyboard order, accessible names and captions, forced-colors behavior, narrow layouts, console/page errors, and external network requests. The plan JSON, HTML report, and integrity manifest are retained together as a per-engine artifact. diff --git a/scripts/release-plan-integrity.mjs b/scripts/release-plan-integrity.mjs new file mode 100644 index 00000000..d83148d1 --- /dev/null +++ b/scripts/release-plan-integrity.mjs @@ -0,0 +1,392 @@ +#!/usr/bin/env node +import { createHash, randomUUID } from "node:crypto"; +import { + lstat, + mkdir, + open, + readFile, + realpath, + rename, + rm, + stat, +} from "node:fs/promises"; +import { basename, dirname, resolve } from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +import { validateReleasePlan } from "./render-release-plan-html.mjs"; + +export const INTEGRITY_SCHEMA = "zed-release-plan-report-integrity/v1"; +const DIGEST = /^[0-9a-f]{64}$/; + +function assertString(value, label) { + if (typeof value !== "string" || value.length === 0) { + throw new TypeError(`${label} must be a non-empty string`); + } + return value; +} + +function assertDigest(value, label) { + if (typeof value !== "string" || !DIGEST.test(value)) { + throw new TypeError(`${label} must be a lowercase SHA-256 hex digest`); + } + return value; +} + +export function sha256Hex(value) { + return createHash("sha256").update(value).digest("hex"); +} + +export function canonicalReleasePlanJson(input) { + return `${JSON.stringify(validateReleasePlan(input))}\n`; +} + +export function releasePlanDigest(input) { + return sha256Hex(canonicalReleasePlanJson(input)); +} + +export function injectPlanDigest(html, digest) { + assertString(html, "release report HTML"); + assertDigest(digest, "plan digest"); + if ( + html.includes('name="zed-release-plan-sha256"') || + html.includes("data-plan-sha256") + ) { + throw new Error("release report already contains plan integrity metadata"); + } + + const titleAnchor = ""; + const provenanceAnchor = "</ul>\n</header>"; + if (!html.includes(titleAnchor) || !html.includes(provenanceAnchor)) { + throw new Error("release report is missing required integrity anchors"); + } + + const withMeta = html.replace( + titleAnchor, + `<meta name="zed-release-plan-sha256" content="${digest}">\n${titleAnchor}`, + ); + return withMeta.replace( + provenanceAnchor, + `<li data-plan-sha256><strong>Plan SHA-256</strong><code>${digest}</code></li>\n${provenanceAnchor}`, + ); +} + +export function extractEmbeddedPlanDigest(html) { + assertString(html, "release report HTML"); + const meta = html.match( + /<meta name="zed-release-plan-sha256" content="([0-9a-f]{64})">/, + )?.[1]; + const visible = html.match( + /<li data-plan-sha256><strong>Plan SHA-256<\/strong><code>([0-9a-f]{64})<\/code><\/li>/, + )?.[1]; + if (!meta || !visible || meta !== visible) { + throw new Error("release report plan digest metadata is missing or inconsistent"); + } + return meta; +} + +export function assertDistinctArtifactPaths({ planPath, reportPath, integrityPath }) { + const resolved = [ + resolve(assertString(planPath, "plan path")), + resolve(assertString(reportPath, "report path")), + resolve(assertString(integrityPath, "integrity path")), + ]; + if (new Set(resolved).size !== resolved.length) { + throw new Error("release plan, report, and integrity paths must be distinct"); + } + return { + planPath: resolved[0], + reportPath: resolved[1], + integrityPath: resolved[2], + }; +} + +async function existingArtifactIdentity(path) { + try { + const [canonical, metadata] = await Promise.all([realpath(path), stat(path)]); + return { + canonical: process.platform === "win32" ? canonical.toLowerCase() : canonical, + inode: + Number.isSafeInteger(metadata.dev) && + Number.isSafeInteger(metadata.ino) && + metadata.ino !== 0 + ? `${metadata.dev}:${metadata.ino}` + : null, + }; + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } +} + +export async function assertDistinctArtifactFiles({ + planPath, + reportPath, + integrityPath, +}) { + const artifacts = [ + ["release plan", planPath], + ["release report", reportPath], + ["release report integrity manifest", integrityPath], + ]; + const canonicalPaths = new Map(); + const inodes = new Map(); + + for (const [label, path] of artifacts) { + const identity = await existingArtifactIdentity(path); + if (!identity) continue; + + const canonicalAlias = canonicalPaths.get(identity.canonical); + if (canonicalAlias) { + throw new Error(`${canonicalAlias} and ${label} must be distinct files`); + } + canonicalPaths.set(identity.canonical, label); + + if (identity.inode) { + const inodeAlias = inodes.get(identity.inode); + if (inodeAlias) { + throw new Error(`${inodeAlias} and ${label} must be distinct files`); + } + inodes.set(identity.inode, label); + } + } + + return { planPath, reportPath, integrityPath }; +} + +async function metadataOrNull(path) { + try { + return await lstat(path); + } catch (error) { + if (error?.code === "ENOENT") return null; + throw error; + } +} + +export async function assertSafeIntegrityOutputPath(outputPath, label) { + assertString(outputPath, `${label} path`); + const destination = resolve(outputPath); + const existing = await metadataOrNull(destination); + if (existing?.isSymbolicLink()) { + throw new Error(`refusing to write ${label} through symbolic link: ${destination}`); + } + if (existing?.isDirectory()) { + throw new Error(`${label} output is a directory: ${destination}`); + } + return destination; +} + +export async function writeAtomicIntegrityOutput(outputPath, content, label) { + assertString(content, label); + const destination = await assertSafeIntegrityOutputPath(outputPath, label); + const parent = dirname(destination); + await mkdir(parent, { recursive: true }); + const temporary = resolve( + parent, + `.${basename(destination)}.${process.pid}.${randomUUID()}.tmp`, + ); + let handle; + try { + handle = await open(temporary, "wx", 0o600); + await handle.writeFile(content, "utf8"); + await handle.sync(); + await handle.close(); + handle = undefined; + + const beforePublish = await metadataOrNull(destination); + if (beforePublish?.isSymbolicLink()) { + throw new Error(`refusing to replace symbolic-link ${label}: ${destination}`); + } + await rename(temporary, destination); + } finally { + if (handle) await handle.close().catch(() => {}); + await rm(temporary, { force: true }).catch(() => {}); + } + return destination; +} + +export function buildIntegrityManifest({ planPath, reportPath, planDigest, reportDigest }) { + assertDigest(planDigest, "manifest plan digest"); + assertDigest(reportDigest, "manifest report digest"); + return `${JSON.stringify( + { + schema: INTEGRITY_SCHEMA, + algorithm: "sha256", + plan: { + file: basename(assertString(planPath, "plan path")), + canonical_sha256: planDigest, + }, + report: { + file: basename(assertString(reportPath, "report path")), + sha256: reportDigest, + }, + }, + null, + 2, + )}\n`; +} + +function parsePlan(source, label) { + try { + return JSON.parse(source); + } catch (error) { + throw new SyntaxError(`${label} is not valid JSON: ${error.message}`); + } +} + +export function validateIntegrityManifest(input, planPath, reportPath) { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new TypeError("integrity manifest must be an object"); + } + if (input.schema !== INTEGRITY_SCHEMA || input.algorithm !== "sha256") { + throw new Error("unsupported release report integrity manifest schema"); + } + const expectedTop = ["algorithm", "plan", "report", "schema"]; + if (Object.keys(input).sort().join("\0") !== expectedTop.join("\0")) { + throw new Error("integrity manifest contains unknown or missing top-level fields"); + } + if ( + !input.plan || + typeof input.plan !== "object" || + Array.isArray(input.plan) || + !input.report || + typeof input.report !== "object" || + Array.isArray(input.report) + ) { + throw new TypeError("integrity manifest plan and report entries must be objects"); + } + if ( + Object.keys(input.plan).sort().join("\0") !== + ["canonical_sha256", "file"].join("\0") || + Object.keys(input.report).sort().join("\0") !== ["file", "sha256"].join("\0") + ) { + throw new Error("integrity manifest contains unknown or missing artifact fields"); + } + if (input.plan.file !== basename(planPath) || input.report.file !== basename(reportPath)) { + throw new Error("integrity manifest artifact filenames do not match the supplied files"); + } + assertDigest(input.plan.canonical_sha256, "integrity manifest plan digest"); + assertDigest(input.report.sha256, "integrity manifest report digest"); + return input; +} + +export async function bindReleasePlanIntegrity(paths) { + const resolvedPaths = assertDistinctArtifactPaths(paths); + const { planPath, reportPath, integrityPath } = + await assertDistinctArtifactFiles(resolvedPaths); + await Promise.all([ + assertSafeIntegrityOutputPath(reportPath, "release report"), + assertSafeIntegrityOutputPath(integrityPath, "release report integrity manifest"), + ]); + const [planSource, reportSource] = await Promise.all([ + readFile(planPath, "utf8"), + readFile(reportPath, "utf8"), + ]); + const plan = parsePlan(planSource, "release plan"); + const planDigest = releasePlanDigest(plan); + const boundReport = injectPlanDigest(reportSource, planDigest); + const reportDigest = sha256Hex(boundReport); + const manifest = buildIntegrityManifest({ + planPath, + reportPath, + planDigest, + reportDigest, + }); + + await writeAtomicIntegrityOutput(reportPath, boundReport, "release report"); + await writeAtomicIntegrityOutput( + integrityPath, + manifest, + "release report integrity manifest", + ); + return { planDigest, reportDigest, manifestPath: integrityPath }; +} + +export async function verifyReleasePlanIntegrity(paths) { + const resolvedPaths = assertDistinctArtifactPaths(paths); + const { planPath, reportPath, integrityPath } = + await assertDistinctArtifactFiles(resolvedPaths); + const [planSource, reportSource, integritySource] = await Promise.all([ + readFile(planPath, "utf8"), + readFile(reportPath, "utf8"), + readFile(integrityPath, "utf8"), + ]); + const plan = parsePlan(planSource, "release plan"); + const manifest = validateIntegrityManifest( + parsePlan(integritySource, "integrity manifest"), + planPath, + reportPath, + ); + const planDigest = releasePlanDigest(plan); + const embeddedDigest = extractEmbeddedPlanDigest(reportSource); + const reportDigest = sha256Hex(reportSource); + + if (manifest.plan.canonical_sha256 !== planDigest || embeddedDigest !== planDigest) { + throw new Error("release plan digest does not match the report integrity metadata"); + } + if (manifest.report.sha256 !== reportDigest) { + throw new Error("release report digest does not match the integrity manifest"); + } + return { planDigest, reportDigest }; +} + +function optionValue(argv, index, option) { + if (index + 1 >= argv.length) throw new Error(`${option} requires a value`); + return argv[index + 1]; +} + +export function parseIntegrityArguments(argv) { + if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") { + return { help: true }; + } + const mode = argv[0]; + if (mode !== "bind" && mode !== "verify") { + throw new Error("first argument must be bind or verify"); + } + const result = { mode, planPath: null, reportPath: null, integrityPath: null }; + for (let index = 1; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--plan") { + result.planPath = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--report") { + result.reportPath = optionValue(argv, index, argument); + index += 1; + } else if (argument === "--integrity") { + result.integrityPath = optionValue(argv, index, argument); + index += 1; + } else { + throw new Error(`unknown argument: ${argument}`); + } + } + for (const [field, option] of [ + ["planPath", "--plan"], + ["reportPath", "--report"], + ["integrityPath", "--integrity"], + ]) { + if (!result[field]) throw new Error(`${option} is required`); + } + return result; +} + +async function main() { + const args = parseIntegrityArguments(process.argv.slice(2)); + if (args.help) { + process.stdout.write( + "usage: node scripts/release-plan-integrity.mjs <bind|verify> --plan PLAN.json --report REPORT.html --integrity INTEGRITY.json\n", + ); + return; + } + const operation = args.mode === "bind" ? bindReleasePlanIntegrity : verifyReleasePlanIntegrity; + const result = await operation(args); + process.stdout.write( + `${args.mode === "bind" ? "bound" : "verified"} release report plan=${result.planDigest} report=${result.reportDigest}\n`, + ); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`release-plan integrity failed: ${error.message}\n`); + process.exitCode = 1; + }); +} diff --git a/tests/browser/test_release_plan_integrity.py b/tests/browser/test_release_plan_integrity.py new file mode 100644 index 00000000..0478e7f4 --- /dev/null +++ b/tests/browser/test_release_plan_integrity.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[2] +REPORT = Path(os.environ.get("ZED_RELEASE_REPORT", ROOT / "build/release-plan.html")).resolve() +INTEGRITY = Path( + os.environ.get("ZED_RELEASE_INTEGRITY", ROOT / "build/release-plan.integrity.json") +).resolve() +RESULTS = ROOT / "build" / "release-plan-browser-results" + + +def main() -> None: + engine = os.environ.get("PLAYWRIGHT_BROWSER", "chromium") + if engine not in {"chromium", "firefox", "webkit"}: + raise SystemExit(f"unsupported PLAYWRIGHT_BROWSER: {engine}") + if not REPORT.is_file() or not INTEGRITY.is_file(): + raise SystemExit("release report or integrity manifest is missing") + + manifest = json.loads(INTEGRITY.read_text(encoding="utf8")) + expected = manifest["plan"]["canonical_sha256"] + assert len(expected) == 64 + + RESULTS.mkdir(parents=True, exist_ok=True) + with sync_playwright() as playwright: + browser = getattr(playwright, engine).launch() + context = browser.new_context(viewport={"width": 1280, "height": 900}) + context.tracing.start(screenshots=True, snapshots=True, sources=True) + page = context.new_page() + errors: list[str] = [] + external_requests: list[str] = [] + page.on( + "console", + lambda message: errors.append(message.text) + if message.type == "error" + else None, + ) + page.on("pageerror", lambda error: errors.append(str(error))) + page.on( + "request", + lambda request: external_requests.append(request.url) + if request.url.startswith(("http://", "https://")) + else None, + ) + + try: + page.goto(REPORT.as_uri(), wait_until="load") + meta = page.locator('meta[name="zed-release-plan-sha256"]') + assert meta.get_attribute("content") == expected + visible = page.locator("[data-plan-sha256] code") + assert visible.inner_text() == expected + visible.select_text() + assert expected in page.evaluate("window.getSelection().toString()") + + page.emulate_media(media="print") + assert visible.is_visible() + page.emulate_media(media="screen") + + no_script_context = browser.new_context(java_script_enabled=False) + no_script_page = no_script_context.new_page() + no_script_page.goto(REPORT.as_uri(), wait_until="load") + assert no_script_page.locator("[data-plan-sha256] code").inner_text() == expected + assert no_script_page.locator("tbody tr[data-search]").count() == 7 + no_script_context.close() + + assert not external_requests, external_requests + assert not errors, errors + context.tracing.stop() + except BaseException: + page.screenshot( + path=RESULTS / f"{engine}-integrity-failure.png", + full_page=True, + ) + context.tracing.stop( + path=RESULTS / f"{engine}-integrity-trace.zip" + ) + raise + finally: + context.close() + browser.close() + + print(f"zed release-plan integrity browser contract passed in {engine}") + + +if __name__ == "__main__": + main() diff --git a/tests/release_plan_integrity_test.mjs b/tests/release_plan_integrity_test.mjs new file mode 100644 index 00000000..d173e24d --- /dev/null +++ b/tests/release_plan_integrity_test.mjs @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import { + mkdtemp, + readFile, + readdir, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { renderReleasePlan } from "../scripts/render-release-plan-html.mjs"; +import { + bindReleasePlanIntegrity, + canonicalReleasePlanJson, + extractEmbeddedPlanDigest, + parseIntegrityArguments, + releasePlanDigest, + verifyReleasePlanIntegrity, +} from "../scripts/release-plan-integrity.mjs"; + +const fixture = { + release_set: "acme/clients@1.2.3#v1.2.3", + source: { + package: "acme/clients", + version: "1.2.3", + vcs_tag: "v1.2.3", + repository: "https://github.com/acme/clients", + }, + zed: [ + { target: "nodejs", package: "acme/node", version: "1.2.3", dir: "node" }, + ], + native: [ + { + target: "nodejs", + registry: "npm", + package: "@acme/client", + version: "1.2.3", + vcs_tag: "v1.2.3", + dir: "node", + }, + ], + forge: [ + { + target: "nodejs", + registry: "github-packages", + format: "npm", + package: "@acme/client", + version: "1.2.3", + vcs_tag: "v1.2.3", + dir: "node", + }, + ], +}; + +async function setup() { + const root = await mkdtemp(join(tmpdir(), "zed-integrity-")); + const planPath = join(root, "plan.json"); + const reportPath = join(root, "report.html"); + const integrityPath = join(root, "integrity.json"); + await writeFile(planPath, JSON.stringify(fixture, null, 2)); + await writeFile(reportPath, renderReleasePlan(fixture)); + return { root, planPath, reportPath, integrityPath }; +} + +test("canonical plan digest ignores insignificant key order and whitespace", () => { + const reordered = { + forge: fixture.forge, + native: fixture.native, + zed: fixture.zed, + source: { + repository: fixture.source.repository, + vcs_tag: fixture.source.vcs_tag, + version: fixture.source.version, + package: fixture.source.package, + }, + release_set: fixture.release_set, + }; + assert.equal(canonicalReleasePlanJson(fixture), canonicalReleasePlanJson(reordered)); + assert.equal(releasePlanDigest(fixture), releasePlanDigest(reordered)); +}); + +test("bind and verify publish a machine-readable and visible digest", async () => { + const paths = await setup(); + try { + const bound = await bindReleasePlanIntegrity(paths); + const verified = await verifyReleasePlanIntegrity(paths); + assert.deepEqual(verified, { + planDigest: bound.planDigest, + reportDigest: bound.reportDigest, + }); + const report = await readFile(paths.reportPath, "utf8"); + assert.equal(extractEmbeddedPlanDigest(report), bound.planDigest); + const manifest = JSON.parse(await readFile(paths.integrityPath, "utf8")); + assert.equal(manifest.plan.canonical_sha256, bound.planDigest); + assert.equal(manifest.report.sha256, bound.reportDigest); + } finally { + await rm(paths.root, { recursive: true, force: true }); + } +}); + +test("plan report and manifest tampering all fail verification", async () => { + const paths = await setup(); + try { + await bindReleasePlanIntegrity(paths); + const originalPlan = await readFile(paths.planPath, "utf8"); + const originalReport = await readFile(paths.reportPath, "utf8"); + const originalManifest = await readFile(paths.integrityPath, "utf8"); + + await writeFile(paths.planPath, originalPlan.replace("1.2.3", "1.2.4")); + await assert.rejects(verifyReleasePlanIntegrity(paths), /plan digest/); + await writeFile(paths.planPath, originalPlan); + + await writeFile(paths.reportPath, `${originalReport}\n<!-- tampered -->`); + await assert.rejects(verifyReleasePlanIntegrity(paths), /report digest/); + await writeFile(paths.reportPath, originalReport); + + const manifest = JSON.parse(originalManifest); + manifest.report.sha256 = "x".repeat(64); + await writeFile(paths.integrityPath, JSON.stringify(manifest)); + await assert.rejects(verifyReleasePlanIntegrity(paths), /lowercase SHA-256/); + } finally { + await rm(paths.root, { recursive: true, force: true }); + } +}); + +test("plan report and integrity paths must be distinct", async () => { + const paths = await setup(); + try { + const originalReport = await readFile(paths.reportPath, "utf8"); + await assert.rejects( + bindReleasePlanIntegrity({ + planPath: paths.planPath, + reportPath: paths.reportPath, + integrityPath: paths.reportPath, + }), + /must be distinct/, + ); + assert.equal(await readFile(paths.reportPath, "utf8"), originalReport); + } finally { + await rm(paths.root, { recursive: true, force: true }); + } +}); + +test( + "existing filesystem aliases are refused before report mutation", + { skip: process.platform === "win32" }, + async () => { + const paths = await setup(); + try { + const targetPlan = join(paths.root, "plan-target.json"); + await rename(paths.planPath, targetPlan); + await symlink(targetPlan, paths.planPath); + const originalPlan = await readFile(targetPlan, "utf8"); + const originalReport = await readFile(paths.reportPath, "utf8"); + + await assert.rejects( + bindReleasePlanIntegrity({ + planPath: paths.planPath, + reportPath: paths.reportPath, + integrityPath: targetPlan, + }), + /must be distinct/, + ); + + assert.equal(await readFile(targetPlan, "utf8"), originalPlan); + assert.equal(await readFile(paths.reportPath, "utf8"), originalReport); + } finally { + await rm(paths.root, { recursive: true, force: true }); + } + }, +); + +test( + "integrity output symlinks are refused before report mutation", + { skip: process.platform === "win32" }, + async () => { + const paths = await setup(); + try { + const protectedPath = join(paths.root, "protected.json"); + await writeFile(protectedPath, "protected"); + await symlink(protectedPath, paths.integrityPath); + const originalReport = await readFile(paths.reportPath, "utf8"); + await assert.rejects(bindReleasePlanIntegrity(paths), /symbolic link/); + assert.equal(await readFile(paths.reportPath, "utf8"), originalReport); + assert.equal(await readFile(protectedPath, "utf8"), "protected"); + assert.equal( + (await readdir(paths.root)).some((name) => name.endsWith(".tmp")), + false, + ); + } finally { + await rm(paths.root, { recursive: true, force: true }); + } + }, +); + +test("CLI parsing is strict", () => { + assert.deepEqual( + parseIntegrityArguments([ + "verify", + "--plan", + "p", + "--report", + "r", + "--integrity", + "i", + ]), + { mode: "verify", planPath: "p", reportPath: "r", integrityPath: "i" }, + ); + assert.throws(() => parseIntegrityArguments(["wat"]), /bind or verify/); + assert.throws(() => parseIntegrityArguments(["bind", "--plan"]), /requires a value/); + assert.throws( + () => parseIntegrityArguments(["bind", "--wat", "x"]), + /unknown argument/, + ); + assert.throws( + () => parseIntegrityArguments(["bind", "--plan", "p"]), + /--report is required/, + ); +});