From ec3baff4a443440da872e56ad2e1b21260b4bc96 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 09:01:15 -0500 Subject: [PATCH 01/15] feat: harden global executable package profiles Rebuild the global package lifecycle on formatted current main, make profile replacement and removal rollback-safe, preflight every PATH collision before mutation, transact executable promotion and managed-state updates, preserve offline frozen restores, and remove the temporary write workflow. --- .github/workflows/global-packages.yml | 84 ++ .zpkg.lock | 1 + .zpkg.toml | 45 + docs/global-packages.md | 114 +++ src/completion.rs | 28 +- src/global.rs | 1332 +++++++++++++++++++++++++ src/lib.rs | 3 +- src/main.rs | 70 ++ src/ops.rs | 75 +- tests/global_cli.rs | 266 +++++ 10 files changed, 2001 insertions(+), 17 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..b3ef99ca --- /dev/null +++ b/.github/workflows/global-packages.yml @@ -0,0 +1,84 @@ +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 + - 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 + - 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: 45 + 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: Verify formatting and global profile tests + run: | + set -euo pipefail + cargo fmt --all --check + cargo test --locked --lib global::tests + cargo test --locked --test global_cli + 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 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..7fdc36b0 --- /dev/null +++ b/src/global.rs @@ -0,0 +1,1332 @@ +//! 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 8dd478b4..96d8b0fb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,18 +12,17 @@ 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; pub mod manifestless; -pub mod mise_lock; pub mod nix_bundle_write; pub mod nix_export_bundle; pub mod nix_export_plan; #[path = "ops_entry.rs"] pub mod ops; pub mod pack; -pub(crate) mod pack_guard; pub mod preflight; pub mod r2g; pub mod registry; diff --git a/src/main.rs b/src/main.rs index 773431b7..6866185c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +use std::ffi::{OsStr, OsString}; + use zed_cli::auth; use zed_cli::cli::EnvCmd; use zed_cli::cli::{AuthCmd, CacheCmd, Cli, Cmd, OrgCmd, ReleaseCmd, StoreCmd}; @@ -7,6 +9,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; @@ -24,6 +27,29 @@ 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, @@ -86,6 +112,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 cwd = std::env::current_dir()?; 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..5644be19 --- /dev/null +++ b/tests/global_cli.rs @@ -0,0 +1,266 @@ +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 e2176a80d96b6e6b4d40d3a138418cde251014c7 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 09:05:48 -0500 Subject: [PATCH 02/15] ci: format and verify hardened global packages once --- .../format-and-verify-global-final-once.yml | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 .github/workflows/format-and-verify-global-final-once.yml diff --git a/.github/workflows/format-and-verify-global-final-once.yml b/.github/workflows/format-and-verify-global-final-once.yml new file mode 100644 index 00000000..cf73d916 --- /dev/null +++ b/.github/workflows/format-and-verify-global-final-once.yml @@ -0,0 +1,64 @@ +name: Format and verify hardened global packages once + +on: + push: + branches: + - agent/global-zed-packages-final + pull_request: + branches: + - main + paths: + - .github/workflows/format-and-verify-global-final-once.yml + +permissions: + contents: write + +concurrency: + group: format-and-verify-global-final-once + cancel-in-progress: false + +jobs: + format-and-verify: + if: github.actor != 'github-actions[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - name: Check out exact feature branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/global-zed-packages-final + fetch-depth: 1 + persist-credentials: true + show-progress: false + + - name: Install Rust validation toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt,clippy + + - name: Format and verify the complete candidate + shell: bash + run: | + set -euo pipefail + cargo fmt --all + cargo fmt --all --check + cargo test --locked --lib global::tests + cargo test --locked --test global_cli + cargo clippy --locked --all-targets --all-features -- -D warnings + cargo test --all-targets --all-features --locked + + - name: Commit permanent formatted product changes only + shell: bash + run: | + set -euo pipefail + rm .github/workflows/format-and-verify-global-final-once.yml + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add -A + if git diff --cached --quiet; then + echo 'No formatted product change required.' + exit 0 + fi + git commit -m 'style: format and verify global package profiles' + git push origin HEAD:agent/global-zed-packages-final From 11bc0de6bf0a7806381c9a0487a1f448b50bd3e0 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 09:10:32 -0500 Subject: [PATCH 03/15] ci: materialize rustfmt before validation --- .../format-and-verify-global-final-once.yml | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/.github/workflows/format-and-verify-global-final-once.yml b/.github/workflows/format-and-verify-global-final-once.yml index cf73d916..8625361d 100644 --- a/.github/workflows/format-and-verify-global-final-once.yml +++ b/.github/workflows/format-and-verify-global-final-once.yml @@ -1,4 +1,4 @@ -name: Format and verify hardened global packages once +name: Format hardened global packages once on: push: @@ -14,14 +14,14 @@ permissions: contents: write concurrency: - group: format-and-verify-global-final-once + group: format-global-packages-final-once cancel-in-progress: false jobs: - format-and-verify: + format: if: github.actor != 'github-actions[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-24.04 - timeout-minutes: 60 + timeout-minutes: 20 steps: - name: Check out exact feature branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -31,22 +31,18 @@ jobs: persist-credentials: true show-progress: false - - name: Install Rust validation toolchain + - name: Install Rust formatter uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 with: toolchain: stable - components: rustfmt,clippy + components: rustfmt - - name: Format and verify the complete candidate + - name: Apply formatter shell: bash run: | set -euo pipefail cargo fmt --all cargo fmt --all --check - cargo test --locked --lib global::tests - cargo test --locked --test global_cli - cargo clippy --locked --all-targets --all-features -- -D warnings - cargo test --all-targets --all-features --locked - name: Commit permanent formatted product changes only shell: bash @@ -56,9 +52,5 @@ jobs: git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' git add -A - if git diff --cached --quiet; then - echo 'No formatted product change required.' - exit 0 - fi - git commit -m 'style: format and verify global package profiles' + git commit -m 'style: format hardened global package profiles' git push origin HEAD:agent/global-zed-packages-final From 355d9e18a443e587c892120c360ec43f83186648 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:10:55 +0000 Subject: [PATCH 04/15] style: format hardened global package profiles --- .../format-and-verify-global-final-once.yml | 56 ------------------ src/global.rs | 57 ++++++++----------- src/main.rs | 4 +- tests/global_cli.rs | 26 ++------- 4 files changed, 30 insertions(+), 113 deletions(-) delete mode 100644 .github/workflows/format-and-verify-global-final-once.yml diff --git a/.github/workflows/format-and-verify-global-final-once.yml b/.github/workflows/format-and-verify-global-final-once.yml deleted file mode 100644 index 8625361d..00000000 --- a/.github/workflows/format-and-verify-global-final-once.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Format hardened global packages once - -on: - push: - branches: - - agent/global-zed-packages-final - pull_request: - branches: - - main - paths: - - .github/workflows/format-and-verify-global-final-once.yml - -permissions: - contents: write - -concurrency: - group: format-global-packages-final-once - cancel-in-progress: false - -jobs: - format: - if: github.actor != 'github-actions[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) - runs-on: ubuntu-24.04 - timeout-minutes: 20 - steps: - - name: Check out exact feature branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/global-zed-packages-final - fetch-depth: 1 - persist-credentials: true - show-progress: false - - - name: Install Rust formatter - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 - with: - toolchain: stable - components: rustfmt - - - name: Apply formatter - shell: bash - run: | - set -euo pipefail - cargo fmt --all - cargo fmt --all --check - - - name: Commit permanent formatted product changes only - shell: bash - run: | - set -euo pipefail - rm .github/workflows/format-and-verify-global-final-once.yml - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git add -A - git commit -m 'style: format hardened global package profiles' - git push origin HEAD:agent/global-zed-packages-final diff --git a/src/global.rs b/src/global.rs index 7fdc36b0..c70d7412 100644 --- a/src/global.rs +++ b/src/global.rs @@ -289,20 +289,18 @@ fn route(args: &[OsString]) -> Route { 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", - } + "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 { + "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, @@ -427,7 +425,9 @@ fn parse_package_spec(spec: &str) -> Result<(String, Option)> { 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])"))?; + .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"); } @@ -730,7 +730,10 @@ fn stage_profile_replacement(root: &Path) -> Result { fn stage_profile_removal(root: &Path) -> Result { if !path_present(root) { - bail!("global profile disappeared before removal: {}", root.display()); + bail!( + "global profile disappeared before removal: {}", + root.display() + ); } let backup = unique_sibling(root, "profile-removal")?; fs::rename(root, &backup).with_context(|| { @@ -1020,10 +1023,7 @@ fn stage_executable(source: &Path, destination: &Path) -> Result { .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() - )); + let temporary = parent.join(format!(".{file_name}.zed-stage-{}", uuid::Uuid::new_v4())); fs::copy(source, &temporary).with_context(|| { format!( "staging global executable {} at {}", @@ -1071,7 +1071,10 @@ fn rollback_bin_transaction( if failures.is_empty() { Ok(()) } else { - bail!("rolling back global executable transaction: {}", failures.join("; ")) + bail!( + "rolling back global executable transaction: {}", + failures.join("; ") + ) } } @@ -1082,10 +1085,7 @@ fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> { .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() - )); + let temporary = parent.join(format!(".{file_name}.zed-tmp-{}", uuid::Uuid::new_v4())); fs::write(&temporary, bytes)?; let backup = if path_present(path) { @@ -1123,10 +1123,7 @@ fn unique_sibling(path: &Path, kind: &str) -> Result { .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() - ))) + Ok(parent.join(format!(".{name}.zed-{kind}-{}", uuid::Uuid::new_v4()))) } fn path_present(path: &Path) -> bool { @@ -1178,8 +1175,7 @@ fn print_path_guidance(bin_dir: &Path) { 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) + env::split_paths(&value).any(|entry| fs::canonicalize(&entry).unwrap_or(entry) == expected) }) } @@ -1236,11 +1232,8 @@ mod tests { #[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(); + 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")); } diff --git a/src/main.rs b/src/main.rs index 6866185c..7cf49764 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,9 +38,7 @@ fn main() { let argument = argument.as_os_str(); argument == OsStr::new("global") || argument == OsStr::new("--global") }); - if global_requested - && let Some(result) = global::dispatch(args.clone()) - { + if global_requested && let Some(result) = global::dispatch(args.clone()) { match result { Ok(0) => return, Ok(code) => std::process::exit(code), diff --git a/tests/global_cli.rs b/tests/global_cli.rs index 5644be19..d7fa561c 100644 --- a/tests/global_cli.rs +++ b/tests/global_cli.rs @@ -211,12 +211,7 @@ fn uninstall_global_alias_rejects_versioned_selectors() { 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 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"; @@ -224,11 +219,7 @@ fn uninstall_removes_an_unchanged_zed_owned_command() { fs::write(&destination, owned).unwrap(); write_managed_state(home.path(), &destination_name, owned); - let output = run( - &["global", "uninstall", "acme/tool"], - home.path(), - &bin_dir, - ); + let output = run(&["global", "uninstall", "acme/tool"], home.path(), &bin_dir); assert!(output.status.success(), "{}", stderr(&output)); assert!(!profile.exists()); assert!(!destination.exists()); @@ -238,23 +229,14 @@ fn uninstall_removes_an_unchanged_zed_owned_command() { 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 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, - ); + 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"); From a95defb14189ebe3ef0ebea55a807c93a6dbcf12 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 09:13:05 -0500 Subject: [PATCH 05/15] ci: split global package gates and emit format evidence --- .github/workflows/global-packages.yml | 85 ++++++++++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/.github/workflows/global-packages.yml b/.github/workflows/global-packages.yml index b3ef99ca..de61ee80 100644 --- a/.github/workflows/global-packages.yml +++ b/.github/workflows/global-packages.yml @@ -10,6 +10,7 @@ on: - src/lib.rs - src/main.rs - src/completion.rs + - src/ops.rs - tests/global_cli.rs - .github/workflows/global-packages.yml push: @@ -22,6 +23,7 @@ on: - src/lib.rs - src/main.rs - src/completion.rs + - src/ops.rs - tests/global_cli.rs - .github/workflows/global-packages.yml @@ -44,7 +46,7 @@ jobs: matrix: os: [ubuntu-24.04, macos-15, windows-2025] runs-on: ${{ matrix.os }} - timeout-minutes: 45 + timeout-minutes: 60 steps: - name: Check out exact candidate uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -59,12 +61,26 @@ jobs: rustup toolchain install stable --profile minimal --component rustfmt,clippy rustup default stable - - name: Verify formatting and global profile tests + - name: Check formatting + id: formatting + continue-on-error: true run: | set -euo pipefail cargo fmt --all --check + + - name: Run global profile unit tests + run: | + set -euo pipefail cargo test --locked --lib global::tests + + - 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 @@ -82,3 +98,68 @@ jobs: 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 From b74e3d6946d87ec75393af0644d9f4876c16514b Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 09:18:26 -0500 Subject: [PATCH 06/15] ci: retain focused global-profile test diagnostics --- .github/workflows/global-packages.yml | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/global-packages.yml b/.github/workflows/global-packages.yml index de61ee80..3ac2bb9e 100644 --- a/.github/workflows/global-packages.yml +++ b/.github/workflows/global-packages.yml @@ -69,9 +69,28 @@ jobs: cargo fmt --all --check - name: Run global profile unit tests + id: unit + continue-on-error: true run: | set -euo pipefail - cargo test --locked --lib global::tests + 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: | From 483f360d1188c8274ad8d8fe77e1041375feb058 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 09:21:08 -0500 Subject: [PATCH 07/15] fix: preserve current-main module exports --- src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 96d8b0fb..db981ea1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,12 +17,14 @@ pub mod install_graph; pub mod interactive; pub mod managed_install; pub mod manifestless; +pub mod mise_lock; pub mod nix_bundle_write; pub mod nix_export_bundle; pub mod nix_export_plan; #[path = "ops_entry.rs"] pub mod ops; pub mod pack; +pub(crate) mod pack_guard; pub mod preflight; pub mod r2g; pub mod registry; From 7ace18d6e5cc220d0b61d67566786f5e270231e0 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:32:40 -0500 Subject: [PATCH 08/15] chore: stage offline frozen prefetch repair --- .github/fix_offline_frozen_prefetch.py | 152 +++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 .github/fix_offline_frozen_prefetch.py diff --git a/.github/fix_offline_frozen_prefetch.py b/.github/fix_offline_frozen_prefetch.py new file mode 100644 index 00000000..ffad82fe --- /dev/null +++ b/.github/fix_offline_frozen_prefetch.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Make frozen prefetch use authenticated lock metadata for local bytes.""" + +from pathlib import Path + + +def replace_once(path: str, old: str, new: str, label: str) -> None: + file = Path(path) + text = file.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected one anchor, found {count}") + file.write_text(text.replace(old, new, 1), encoding="utf-8") + + +replace_once( + "src/install_graph/resolver.rs", + ''' let concurrency = install_concurrency(); + let registry = registry_for(&cfg.registry)?; + let report = prefetch_locked(project, cfg, registry.as_ref(), concurrency)?; +''', + ''' let concurrency = install_concurrency(); + let report = prefetch_locked(project, cfg, concurrency)?; +''', + "frozen prefetch registry construction", +) + +replace_once( + "src/install_graph/resolver.rs", + '''fn prefetch_locked( + project: &Path, + cfg: &Config, + registry: &dyn Registry, + concurrency: usize, +) -> Result { +''', + '''fn prefetch_locked( + project: &Path, + cfg: &Config, + concurrency: usize, +) -> Result { +''', + "frozen prefetch signature", +) + +replace_once( + "src/install_graph/resolver.rs", + ''' let lock = Lockfile::parse(&text)?; + let mut tasks = Vec::with_capacity(lock.packages.len()); + let mut seen = BTreeSet::new(); +''', + ''' 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(); +''', + "frozen prefetch local store", +) + +replace_once( + "src/install_graph/resolver.rs", + ''' let version = registry.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 + ); + } +''', + ''' let version = if store.has(&locked.sha256) + || store.cached_artifact(&locked.sha256).is_file() + { + // The lockfile already authenticates every immutable field needed + // to verify bytes that are present in the content store or cache. + // Frozen replay must not turn local restoration into a registry + // metadata availability check. + 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(), + download_url: String::new(), + published_at: "1970-01-01T00:00:00Z".to_string(), + yanked: false, + } + } 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)?; +''', + "lock-authoritative frozen metadata", +) + +replace_once( + "src/install_graph/tests.rs", + "fn frozen_prefetch_installs_every_locked_artifact_without_a_manifest() {", + "fn frozen_prefetch_replays_every_locked_artifact_without_registry_metadata() {", + "offline frozen prefetch test name", +) + +replace_once( + "src/install_graph/tests.rs", + ''' let report = prefetch(&project, &test_config(®istry, &home), true).unwrap(); + assert_eq!(report.resolved, 4); + assert_eq!(report.downloaded, 4); +} + +#[test] +fn a_corrupt_partial_cache_is_replaced_under_the_artifact_lock() { +''', + ''' let cfg = test_config(®istry, &home); + let cold = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(cold.resolved, 4); + assert_eq!(cold.downloaded, 4); + + fs::remove_dir_all(®istry).unwrap(); + let offline = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(offline.resolved, 4); + assert_eq!(offline.downloaded, 0); +} + +#[test] +fn a_corrupt_partial_cache_is_replaced_under_the_artifact_lock() { +''', + "offline frozen prefetch assertions", +) From 204c5e13602835d405ae62c901e4f2bddd0d9b61 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:32:55 -0500 Subject: [PATCH 09/15] ci: validate offline frozen prefetch repair --- .../apply-offline-frozen-prefetch.yml | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/apply-offline-frozen-prefetch.yml diff --git a/.github/workflows/apply-offline-frozen-prefetch.yml b/.github/workflows/apply-offline-frozen-prefetch.yml new file mode 100644 index 00000000..5560e26a --- /dev/null +++ b/.github/workflows/apply-offline-frozen-prefetch.yml @@ -0,0 +1,75 @@ +name: apply offline frozen prefetch repair + +on: + push: + branches: + - agent/global-zed-packages-final + paths: + - .github/fix_offline_frozen_prefetch.py + - .github/workflows/apply-offline-frozen-prefetch.yml + +permissions: + contents: read + +concurrency: + group: apply-offline-frozen-prefetch-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + repair: + if: github.actor != 'github-actions[bot]' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + permissions: + contents: write + steps: + - name: Check out exact global-package branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: agent/global-zed-packages-final + fetch-depth: 0 + 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 + + - name: Apply lock-authoritative frozen prefetch repair + run: | + set -euo pipefail + python3 .github/fix_offline_frozen_prefetch.py + cargo fmt --all + git diff --check + + - name: Validate offline replay and recursive installer + run: | + set -euo pipefail + cargo fmt --all --check + cargo test --locked --lib \ + install_graph::tests::frozen_prefetch_replays_every_locked_artifact_without_registry_metadata \ + -- --exact --nocapture + cargo test --locked --lib install_graph::tests + cargo test --locked --lib global::tests + cargo test --locked --test global_cli + cargo clippy --locked --all-targets --all-features -- -D warnings + + - name: Commit validated ordinary source and tests + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + git add src/install_graph/resolver.rs src/install_graph/tests.rs + git diff --cached --check + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m 'fix: restore frozen installs without registry metadata' + git push \ + "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ + HEAD:agent/global-zed-packages-final From 02ae5073d1004e8b5d1f77d8579ee400c96dfee2 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:35:48 -0500 Subject: [PATCH 10/15] fix: make frozen prefetch lock-authoritative for local artifacts --- src/install_graph/resolver.rs | 64 ++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/src/install_graph/resolver.rs b/src/install_graph/resolver.rs index 4d97a762..cde6cc47 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 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, - registry: &dyn Registry, 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 +119,35 @@ 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, From 695d3c8a58e362f880860edafbaf95056889de81 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:36:17 -0500 Subject: [PATCH 11/15] test: certify registry-free frozen prefetch replay --- tests/frozen_offline_prefetch.rs | 102 +++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 tests/frozen_offline_prefetch.rs diff --git a/tests/frozen_offline_prefetch.rs b/tests/frozen_offline_prefetch.rs new file mode 100644 index 00000000..af36f50a --- /dev/null +++ b/tests/frozen_offline_prefetch.rs @@ -0,0 +1,102 @@ +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_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_the_store_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 cold = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(cold.resolved, 1); + assert_eq!(cold.downloaded, 1); + + fs::remove_dir_all(®istry).unwrap(); + let offline = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(offline.resolved, 1); + assert_eq!(offline.downloaded, 0); +} From a4d51250af68c7c28b9d3df5d15680cc0fdf07cd Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:36:31 -0500 Subject: [PATCH 12/15] chore: remove completed offline prefetch patch helper --- .github/fix_offline_frozen_prefetch.py | 152 ------------------------- 1 file changed, 152 deletions(-) delete mode 100644 .github/fix_offline_frozen_prefetch.py diff --git a/.github/fix_offline_frozen_prefetch.py b/.github/fix_offline_frozen_prefetch.py deleted file mode 100644 index ffad82fe..00000000 --- a/.github/fix_offline_frozen_prefetch.py +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env python3 -"""Make frozen prefetch use authenticated lock metadata for local bytes.""" - -from pathlib import Path - - -def replace_once(path: str, old: str, new: str, label: str) -> None: - file = Path(path) - text = file.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected one anchor, found {count}") - file.write_text(text.replace(old, new, 1), encoding="utf-8") - - -replace_once( - "src/install_graph/resolver.rs", - ''' let concurrency = install_concurrency(); - let registry = registry_for(&cfg.registry)?; - let report = prefetch_locked(project, cfg, registry.as_ref(), concurrency)?; -''', - ''' let concurrency = install_concurrency(); - let report = prefetch_locked(project, cfg, concurrency)?; -''', - "frozen prefetch registry construction", -) - -replace_once( - "src/install_graph/resolver.rs", - '''fn prefetch_locked( - project: &Path, - cfg: &Config, - registry: &dyn Registry, - concurrency: usize, -) -> Result { -''', - '''fn prefetch_locked( - project: &Path, - cfg: &Config, - concurrency: usize, -) -> Result { -''', - "frozen prefetch signature", -) - -replace_once( - "src/install_graph/resolver.rs", - ''' let lock = Lockfile::parse(&text)?; - let mut tasks = Vec::with_capacity(lock.packages.len()); - let mut seen = BTreeSet::new(); -''', - ''' 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(); -''', - "frozen prefetch local store", -) - -replace_once( - "src/install_graph/resolver.rs", - ''' let version = registry.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 - ); - } -''', - ''' let version = if store.has(&locked.sha256) - || store.cached_artifact(&locked.sha256).is_file() - { - // The lockfile already authenticates every immutable field needed - // to verify bytes that are present in the content store or cache. - // Frozen replay must not turn local restoration into a registry - // metadata availability check. - 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(), - download_url: String::new(), - published_at: "1970-01-01T00:00:00Z".to_string(), - yanked: false, - } - } 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)?; -''', - "lock-authoritative frozen metadata", -) - -replace_once( - "src/install_graph/tests.rs", - "fn frozen_prefetch_installs_every_locked_artifact_without_a_manifest() {", - "fn frozen_prefetch_replays_every_locked_artifact_without_registry_metadata() {", - "offline frozen prefetch test name", -) - -replace_once( - "src/install_graph/tests.rs", - ''' let report = prefetch(&project, &test_config(®istry, &home), true).unwrap(); - assert_eq!(report.resolved, 4); - assert_eq!(report.downloaded, 4); -} - -#[test] -fn a_corrupt_partial_cache_is_replaced_under_the_artifact_lock() { -''', - ''' let cfg = test_config(®istry, &home); - let cold = prefetch(&project, &cfg, true).unwrap(); - assert_eq!(cold.resolved, 4); - assert_eq!(cold.downloaded, 4); - - fs::remove_dir_all(®istry).unwrap(); - let offline = prefetch(&project, &cfg, true).unwrap(); - assert_eq!(offline.resolved, 4); - assert_eq!(offline.downloaded, 0); -} - -#[test] -fn a_corrupt_partial_cache_is_replaced_under_the_artifact_lock() { -''', - "offline frozen prefetch assertions", -) From bcef6f0e5c0162e948e92c9ccb529ee95bdd6962 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:36:37 -0500 Subject: [PATCH 13/15] chore: remove completed offline prefetch validation workflow --- .../apply-offline-frozen-prefetch.yml | 75 ------------------- 1 file changed, 75 deletions(-) delete mode 100644 .github/workflows/apply-offline-frozen-prefetch.yml diff --git a/.github/workflows/apply-offline-frozen-prefetch.yml b/.github/workflows/apply-offline-frozen-prefetch.yml deleted file mode 100644 index 5560e26a..00000000 --- a/.github/workflows/apply-offline-frozen-prefetch.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: apply offline frozen prefetch repair - -on: - push: - branches: - - agent/global-zed-packages-final - paths: - - .github/fix_offline_frozen_prefetch.py - - .github/workflows/apply-offline-frozen-prefetch.yml - -permissions: - contents: read - -concurrency: - group: apply-offline-frozen-prefetch-${{ github.ref }} - cancel-in-progress: true - -defaults: - run: - shell: bash - -jobs: - repair: - if: github.actor != 'github-actions[bot]' - runs-on: ubuntu-24.04 - timeout-minutes: 45 - permissions: - contents: write - steps: - - name: Check out exact global-package branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: agent/global-zed-packages-final - fetch-depth: 0 - 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 - - - name: Apply lock-authoritative frozen prefetch repair - run: | - set -euo pipefail - python3 .github/fix_offline_frozen_prefetch.py - cargo fmt --all - git diff --check - - - name: Validate offline replay and recursive installer - run: | - set -euo pipefail - cargo fmt --all --check - cargo test --locked --lib \ - install_graph::tests::frozen_prefetch_replays_every_locked_artifact_without_registry_metadata \ - -- --exact --nocapture - cargo test --locked --lib install_graph::tests - cargo test --locked --lib global::tests - cargo test --locked --test global_cli - cargo clippy --locked --all-targets --all-features -- -D warnings - - - name: Commit validated ordinary source and tests - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - git add src/install_graph/resolver.rs src/install_graph/tests.rs - git diff --cached --check - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git commit -m 'fix: restore frozen installs without registry metadata' - git push \ - "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" \ - HEAD:agent/global-zed-packages-final From 9fa5065ed4fc291a08aa180d45f8640a3e63331f Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:43:21 -0500 Subject: [PATCH 14/15] style: apply rustfmt to frozen offline prefetch --- src/install_graph/resolver.rs | 63 ++++++++++++++++------------------- 1 file changed, 28 insertions(+), 35 deletions(-) diff --git a/src/install_graph/resolver.rs b/src/install_graph/resolver.rs index cde6cc47..452f2d8c 100644 --- a/src/install_graph/resolver.rs +++ b/src/install_graph/resolver.rs @@ -72,9 +72,7 @@ fn run_with_pool( } } -fn locked_version_metadata( - locked: &zed_interfaces::lockfile::LockedPackage, -) -> VersionMetadata { +fn locked_version_metadata(locked: &zed_interfaces::lockfile::LockedPackage) -> VersionMetadata { VersionMetadata { org: locked.org.clone(), name: locked.name.clone(), @@ -92,11 +90,7 @@ fn locked_version_metadata( } } -fn prefetch_locked( - project: &Path, - cfg: &Config, - concurrency: usize, -) -> Result { +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()))?; @@ -120,33 +114,32 @@ fn prefetch_locked( bail!("duplicate package `{key}` in {LOCKFILE_FILE}"); } - 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 - }; + 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)?; tasks.push(FetchTask { sequence: tasks.len(), From a2d9b55bea4ba4860d616c24f3e40e49ea8b28fe Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:54:15 -0500 Subject: [PATCH 15/15] merge: preserve global profiles and canonical asdf env routes --- src/main.rs | 51 ++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8c1ae18e..17d10942 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,11 @@ use std::ffi::{OsStr, OsString}; +use zed_cli::asdf_environment; use zed_cli::auth; use zed_cli::cli::EnvCmd; -use zed_cli::cli::{AuthCmd, CacheCmd, Cli, Cmd, OrgCmd, ReleaseCmd, StoreCmd}; +use zed_cli::cli::{ + AuthCmd, CacheCmd, Cli, Cmd, EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd, +}; use zed_cli::completion; use zed_cli::config::Config; use zed_cli::dev; @@ -200,27 +203,49 @@ fn run(cli: Cli) -> anyhow::Result<()> { Cmd::Uninstall { specs } => ops::uninstall(&cwd, &cfg, &specs), Cmd::Env { cmd } => match cmd { EnvCmd::Import { - manager: _, + manager, config, lock, frozen, json, - } => { - let imported = - environment::import_mise(&cwd, config.as_deref(), lock.as_deref(), frozen)?; - environment::print_import(&imported, json) - } + } => match manager { + EnvironmentManagerArg::Mise => { + let imported = + environment::import_mise(&cwd, config.as_deref(), lock.as_deref(), frozen)?; + environment::print_import(&imported, json) + } + EnvironmentManagerArg::Asdf => { + let imported = asdf_environment::import_asdf( + &cwd, + config.as_deref(), + lock.as_deref(), + frozen, + )?; + asdf_environment::print_import(&imported, json) + } + }, EnvCmd::Verify { - manager: _, + manager, config, lock, frozen, json, - } => { - let imported = - environment::import_mise(&cwd, config.as_deref(), lock.as_deref(), frozen)?; - environment::print_verification(&imported, json) - } + } => match manager { + EnvironmentManagerArg::Mise => { + let imported = + environment::import_mise(&cwd, config.as_deref(), lock.as_deref(), frozen)?; + environment::print_verification(&imported, json) + } + EnvironmentManagerArg::Asdf => { + let imported = asdf_environment::import_asdf( + &cwd, + config.as_deref(), + lock.as_deref(), + frozen, + )?; + asdf_environment::print_verification(&imported, json) + } + }, }, Cmd::Completions { shell } => { completion::print(shell.into());