diff --git a/.github/workflows/tool-profile.yml b/.github/workflows/tool-profile.yml new file mode 100644 index 00000000..31aa0225 --- /dev/null +++ b/.github/workflows/tool-profile.yml @@ -0,0 +1,110 @@ +name: frozen offline tool profiles + +on: + pull_request: + paths: + - .tool-cli-flags.toml + - docs/tool-profile.md + - src/tool_profile.rs + - src/bin/zed-tool.rs + - src/lib.rs + - tests/tool_profile_cli.rs + - .github/workflows/tool-profile.yml + push: + branches: [main] + paths: + - .tool-cli-flags.toml + - docs/tool-profile.md + - src/tool_profile.rs + - src/bin/zed-tool.rs + - src/lib.rs + - tests/tool_profile_cli.rs + - .github/workflows/tool-profile.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: tool-profile-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + tool-profile: + name: frozen profile / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-2025] + steps: + - name: Check out exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Install Rust quality components + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: stable + components: rustfmt,clippy + + - name: Validate staged flags-to-environment contract + run: | + set -euo pipefail + python - <<'PY' + import tomllib + from pathlib import Path + + data = tomllib.loads(Path('.tool-cli-flags.toml').read_text()) + observed = set() + for flag in data.get('flags', {}).values(): + observed.add(flag['env']) + for command in data.get('commands', {}).values(): + for flag in command.get('flags', {}).values(): + observed.add(flag['env']) + expected = { + 'ZED_TOOL_LOCK', + 'ZED_TOOL_JSON', + 'ZED_TOOL_PORTABLE', + 'ZED_TOOL_PLAN_DIGEST', + 'ZED_TOOL_TARGET', + 'ZED_TOOL_OFFLINE', + 'ZED_TOOL_PROFILE', + 'ZED_PKG_HOME', + } + assert observed == expected, (observed, expected) + PY + + - name: Formatting, unit, and compiled CLI tests + run: | + set -euo pipefail + cargo fmt --all --check + cargo test --locked --lib tool_profile::tests + cargo test --locked --test tool_profile_cli + + - name: Current store and lock compatibility + run: | + set -euo pipefail + cargo test --locked --lib store::tests + cargo test --locked --lib project_lock::tests + cargo test --locked --test frozen_offline_prefetch + + - name: Strict Clippy + run: | + set -euo pipefail + cargo clippy --locked --bin zed-tool --test tool_profile_cli -- -D warnings + + - name: Require a clean checkout + run: | + set -euo pipefail + git diff --exit-code + git status --porcelain=v1 --untracked-files=all | tee /tmp/tool-profile-status.txt + test ! -s /tmp/tool-profile-status.txt diff --git a/.tool-cli-flags.toml b/.tool-cli-flags.toml new file mode 100644 index 00000000..e49acd6b --- /dev/null +++ b/.tool-cli-flags.toml @@ -0,0 +1,79 @@ +# Dedicated flags-to-environment contract for the staged frozen tool-profile +# CLI. Canonical `zed tool` routing must reuse these exact variable names. + +[parse] +command_env = "ZED_TOOL_COMMAND" +unknown_options_env = "ZED_TOOL_UNKNOWN_OPTIONS" +errors_env = "ZED_TOOL_PARSE_ERRORS" +allow_unknown = false + +[help] +url = "https://github.com/zed-pkg/zed-cli/blob/main/docs/tool-profile.md" + +[flags.lock] +env = "ZED_TOOL_LOCK" +aliases = ["lock"] +type = "string" +default = ".zed/environment.lock.toml" +help = "Project-local EnvironmentLock TOML or JSON." + +[flags.json] +env = "ZED_TOOL_JSON" +aliases = ["json"] +type = "bool" +default = "false" +help = "Emit one stable JSON document." + +[commands.verify] +help = "Validate exact lock structure, portability, and optional plan identity." + +[commands.verify.flags.portable] +env = "ZED_TOOL_PORTABLE" +aliases = ["portable"] +type = "bool" +default = "false" +help = "Require a machine-independent portable lock." + +[commands.verify.flags.plan_digest] +env = "ZED_TOOL_PLAN_DIGEST" +aliases = ["plan-digest"] +type = "string" +help = "Require one normalized EnvironmentPlan SHA-256." + +[commands.list] +help = "List exact tool variants selected for one target." + +[commands.list.flags.target] +env = "ZED_TOOL_TARGET" +aliases = ["target"] +type = "string" +help = "Exact locked target identity." + +[commands.install] +help = "Replay exact cached artifacts into a project-local executable profile." + +[commands.install.flags.target] +env = "ZED_TOOL_TARGET" +aliases = ["target"] +type = "string" +help = "Exact locked target identity." + +[commands.install.flags.offline] +env = "ZED_TOOL_OFFLINE" +aliases = ["offline"] +type = "bool" +default = "false" +help = "Require cache-only installation; mandatory for this staged slice." + +[commands.install.flags.profile] +env = "ZED_TOOL_PROFILE" +aliases = ["profile"] +type = "string" +default = ".zed/tools" +help = "Project-local tool profile root." + +[commands.install.flags.home] +env = "ZED_PKG_HOME" +aliases = ["home"] +type = "string" +help = "Zed content-addressed store and cache root." diff --git a/docs/tool-profile.md b/docs/tool-profile.md new file mode 100644 index 00000000..d9474138 --- /dev/null +++ b/docs/tool-profile.md @@ -0,0 +1,102 @@ +# Frozen offline tool profiles + +`zed-tool` is the staged native-tool replay surface for exact +`EnvironmentLock` files. It does not resolve versions, contact a registry, +execute a manager or plugin, run an install script, or modify a global PATH. + +The first slice proves that manager-neutral locked artifacts can be verified, +extracted through Zed's existing content-addressed store, and activated in one +project-local profile without requiring mise or another environment manager. + +## Commands + +```console +zed-tool [--lock .zed/environment.lock.toml] [--json] verify [--portable] [--plan-digest SHA] +zed-tool [--lock .zed/environment.lock.toml] [--json] list --target TARGET +zed-tool [--lock .zed/environment.lock.toml] [--json] install --target TARGET --offline [--profile .zed/tools] [--home PATH] +``` + +The dedicated flags contract is `.tool-cli-flags.toml`: + +- `ZED_TOOL_LOCK` +- `ZED_TOOL_JSON` +- `ZED_TOOL_PORTABLE` +- `ZED_TOOL_PLAN_DIGEST` +- `ZED_TOOL_TARGET` +- `ZED_TOOL_OFFLINE` +- `ZED_TOOL_PROFILE` +- `ZED_PKG_HOME` + +A future canonical `zed tool` route must reuse these names. + +## Frozen replay model + +Installation performs these steps: + +1. Canonicalize the current project and read one project-relative lock without + following a symlink outside the checkout. +2. Parse TOML or JSON through `zed_interfaces::EnvironmentLock` and require + portable validation. +3. Select exactly one locked variant per logical tool for the requested target. + Multiple active versions are rejected until precedence is explicitly + modeled and certified. +4. Reject every artifact format except `tar_gz` and `zip` in this first slice. +5. Find the authenticated bytes at + `$ZED_PKG_HOME/cache/.tar.gz`; the extension is the historical cache + convention and the extractor still identifies gzip or ZIP by magic bytes. +6. Require the cached file's exact locked size and SHA-256, then pass it through + Zed's existing per-digest locked, traversal-safe, atomic store extraction. +7. Resolve every locked executable below the authenticated install root, + rejecting missing files, symlink traversal, non-regular files, and missing + Unix execute bits. +8. Reject duplicate executable or alias ownership. Collision checks are ASCII + case-insensitive so a profile remains safe on Windows. +9. Stage a complete profile under `.zed/tools/v1/`, fsync its state, + and atomically replace the previous owned profile while holding the + checkout-local operation lock. +10. Record the active profile's artifact digests as a distinct live store + reference, so package installs and tool profiles do not overwrite each + other's GC ownership. + +On Unix, profile commands are symlinks to immutable store files. On Windows, +they are exact copied executable/script files. A logical command without an +extension inherits a locked `.exe`, `.com`, `.cmd`, or `.bat` extension on +Windows. + +## Profile state + +The deterministic `profile.json` records only: + +- schema; +- normalized environment-lock SHA-256; +- target; +- logical tool name, original requirement, exact version, and backend; +- artifact SHA-256; +- relative install root; and +- exposed command names and relative locked source paths. + +It contains no source URL, credential, environment value, timestamp, hostname, +process identifier, user-home path, or absolute store path. Replaying the same +lock and target against an intact profile reports `unchanged` and does not +rewrite profile state. + +## Fail-closed boundaries + +The staged installer rejects: + +- online mode; +- missing cached bytes; +- wrong artifact size or SHA-256; +- unsupported archive formats; +- missing target variants or multiple variants for one logical tool/target; +- executable and alias collisions; +- unsafe project, lock, profile, target, install-root, or executable paths; +- symlinked profile parents or authenticated artifact paths; +- non-regular or non-executable locked commands; +- lock/plan digest drift; and +- profile replacement or reference-recording failures. + +A failed profile commit restores the prior active profile. Download, version +selection, signatures, multiple active versions, backend plugins, update and +outdated operations, lazy shims, shell activation, and `zed dev` integration are +separate certified slices under DEN-1437 and DEN-1442. diff --git a/src/bin/zed-tool.rs b/src/bin/zed-tool.rs new file mode 100644 index 00000000..ed3497ae --- /dev/null +++ b/src/bin/zed-tool.rs @@ -0,0 +1,249 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, Result, ensure}; +use clap::{Parser, Subcommand}; +use zed_cli::tool_profile::{ + LockMode, default_zed_home, install_offline, list_target, load_environment_lock, verify_receipt, +}; + +#[derive(Debug, Parser)] +#[command( + name = "zed-tool", + version, + about = "Verify and replay exact EnvironmentLock tools without a manager or network" +)] +struct Cli { + /// Project-local EnvironmentLock TOML or JSON. + #[arg( + long, + env = "ZED_TOOL_LOCK", + default_value = ".zed/environment.lock.toml" + )] + lock: PathBuf, + + /// Emit one stable JSON document. + #[arg(long, env = "ZED_TOOL_JSON")] + json: bool, + + #[command(subcommand)] + command: ToolCommand, +} + +#[derive(Debug, Subcommand)] +enum ToolCommand { + /// Validate exact lock structure, portability, and optional plan identity. + Verify { + /// Require a machine-independent portable lock. + #[arg(long, env = "ZED_TOOL_PORTABLE")] + portable: bool, + + /// Require the lock to belong to this normalized EnvironmentPlan SHA-256. + #[arg(long, env = "ZED_TOOL_PLAN_DIGEST")] + plan_digest: Option, + }, + + /// List the exact tool variants selected for one target. + List { + /// Exact locked target identity, such as x86_64-unknown-linux-gnu. + #[arg(long, env = "ZED_TOOL_TARGET")] + target: String, + }, + + /// Replay exact cached artifacts into a project-local executable profile. + Install { + /// Exact locked target identity, such as x86_64-unknown-linux-gnu. + #[arg(long, env = "ZED_TOOL_TARGET")] + target: String, + + /// Installation is intentionally cache-only in this first slice. + #[arg(long, env = "ZED_TOOL_OFFLINE")] + offline: bool, + + /// Project-local profile root. + #[arg(long, env = "ZED_TOOL_PROFILE", default_value = ".zed/tools")] + profile: PathBuf, + + /// Zed content-addressed store and cache root. + #[arg(long, env = "ZED_PKG_HOME")] + home: Option, + }, +} + +fn main() { + if let Err(error) = run(Cli::parse()) { + eprintln!("error: {error:#}"); + std::process::exit(1); + } +} + +fn run(cli: Cli) -> Result<()> { + let root = std::env::current_dir()?; + match cli.command { + ToolCommand::Verify { + portable, + plan_digest, + } => { + let mode = if portable { + LockMode::Portable + } else { + LockMode::Local + }; + let loaded = + load_environment_lock(&root, Some(&cli.lock), mode, plan_digest.as_deref())?; + let receipt = verify_receipt(&loaded, mode); + if cli.json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + println!("verified {}", receipt.lock); + println!("lock-sha256: {}", receipt.lock_sha256); + println!("plan-sha256: {}", receipt.plan_digest_sha256); + println!("validation: {}", receipt.validation); + println!("tools: {}", receipt.tools); + println!("variants: {}", receipt.variants); + } + } + ToolCommand::List { target } => { + let loaded = load_environment_lock(&root, Some(&cli.lock), LockMode::Portable, None)?; + let tools = list_target(&loaded, &target)?; + if cli.json { + println!("{}", serde_json::to_string_pretty(&tools)?); + } else { + for tool in tools { + println!( + "{} {} ({}, {}) [{}]", + tool.name, + tool.resolved, + tool.backend, + tool.target, + tool.executables.join(", ") + ); + } + } + } + ToolCommand::Install { + target, + offline, + profile, + home, + } => { + ensure!( + offline, + "native tool installation currently requires `--offline`; version discovery and downloads are not yet certified" + ); + let loaded = load_environment_lock(&root, Some(&cli.lock), LockMode::Portable, None)?; + let home = match home { + Some(home) if home.is_absolute() => home, + Some(home) => root.join(home), + None => default_zed_home()?, + }; + let rollback_directories = absent_profile_directories(&root, &profile); + let receipt = match install_offline(&root, &loaded, &target, Some(&profile), &home) { + Ok(receipt) => receipt, + Err(error) => { + rollback_empty_profile_directories(&rollback_directories).with_context(|| { + format!( + "rolling back empty profile directories after installation failed: {error:#}" + ) + })?; + return Err(error); + } + }; + if cli.json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + println!("{} tool profile {}", receipt.action, receipt.profile); + println!("bin: {}", receipt.bin); + println!("lock-sha256: {}", receipt.lock_sha256); + for tool in receipt.tools { + println!( + "{} {} ({}) [{}]", + tool.name, + tool.resolved, + tool.backend, + tool.executables.join(", ") + ); + } + } + } + } + Ok(()) +} + +/// Record only profile-directory prefixes that do not exist before installation. +/// Unsafe paths are left to the library validator and produce no rollback list. +fn absent_profile_directories(root: &Path, profile: &Path) -> Vec { + if profile.as_os_str().is_empty() || profile.is_absolute() { + return Vec::new(); + } + + let mut current = root.to_path_buf(); + let mut absent = Vec::new(); + for component in profile.components() { + let Component::Normal(segment) = component else { + return Vec::new(); + }; + current.push(segment); + if matches!( + fs::symlink_metadata(¤t), + Err(error) if error.kind() == ErrorKind::NotFound + ) { + absent.push(current.clone()); + } + } + + let version = current.join("v1"); + if matches!( + fs::symlink_metadata(&version), + Err(error) if error.kind() == ErrorKind::NotFound + ) { + absent.push(version); + } + absent +} + +/// Remove only directories that were absent before this invocation and remain +/// empty. Nonempty directories preserve forensic or concurrent state and are +/// deliberately left alone; symlink or file replacement fails closed. +fn rollback_empty_profile_directories(directories: &[PathBuf]) -> Result<()> { + for directory in directories.iter().rev() { + let metadata = match fs::symlink_metadata(directory) { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => continue, + Err(error) => { + return Err(error).with_context(|| { + format!("inspecting rollback directory `{}`", directory.display()) + }); + } + }; + ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "new profile path `{}` changed type during rollback", + directory.display() + ); + + match fs::remove_dir(directory) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => { + let has_entries = fs::read_dir(directory) + .with_context(|| { + format!("reading rollback directory `{}`", directory.display()) + })? + .next() + .transpose()? + .is_some(); + if !has_entries { + return Err(error).with_context(|| { + format!( + "removing empty rollback directory `{}`", + directory.display() + ) + }); + } + } + } + } + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 310052d7..dfa34c7c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -36,6 +36,7 @@ pub mod release; pub mod store; pub mod task_runtime; pub mod terminal_context; +pub mod tool_profile; pub mod transaction; pub mod update; pub mod vcs; diff --git a/src/tool_profile.rs b/src/tool_profile.rs new file mode 100644 index 00000000..601e1158 --- /dev/null +++ b/src/tool_profile.rs @@ -0,0 +1,958 @@ +//! Frozen, offline replay of exact native tool artifacts into a project-local +//! executable profile. +//! +//! This is deliberately narrower than a backend resolver: it consumes only a +//! validated [`zed_interfaces::EnvironmentLock`], requires already-authenticated +//! archive bytes in Zed's local cache, reuses the existing content-addressed +//! store, and never executes installer scripts, hooks, plugins, package +//! managers, or downloaded binaries. + +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +#[cfg(unix)] +use std::fs::File; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, Result, bail, ensure}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use zed_interfaces::{ + EnvironmentLock, EnvironmentLockValidationMode, LockedArtifactFormat, LockedExecutable, + LockedTool, +}; + +use crate::pack::sha256_file; +use crate::project_lock; +use crate::store::Store; + +pub const TOOL_PROFILE_SCHEMA_V1: &str = "zed.tool-profile/v1"; +const DEFAULT_LOCK_PATH: &str = ".zed/environment.lock.toml"; +const DEFAULT_PROFILE_PATH: &str = ".zed/tools"; +const PROFILE_STATE_FILE: &str = "profile.json"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LockMode { + Local, + Portable, +} + +#[derive(Debug, Clone)] +pub struct LoadedEnvironmentLock { + pub relative_path: PathBuf, + pub lock: EnvironmentLock, + pub digest_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolLockSummary { + pub name: String, + pub requirement: String, + pub resolved: String, + pub backend: String, + pub target: String, + pub artifact_sha256: String, + pub artifact_size: u64, + pub executables: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolVerifyReceipt { + pub schema: String, + pub lock: String, + pub lock_sha256: String, + pub validation: String, + pub plan_digest_sha256: String, + pub tools: usize, + pub variants: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolInstallReceipt { + pub schema: String, + pub action: String, + pub lock: String, + pub lock_sha256: String, + pub target: String, + pub profile: String, + pub bin: String, + pub tools: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ToolProfileState { + schema: String, + lock_sha256: String, + target: String, + tools: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ToolProfileTool { + name: String, + requirement: String, + resolved: String, + backend: String, + artifact_sha256: String, + install_root: String, + executables: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct ToolProfileExecutable { + name: String, + source: String, +} + +#[derive(Debug, Clone)] +struct SelectedTool<'a> { + name: &'a str, + locked: &'a LockedTool, +} + +#[derive(Debug, Clone)] +struct PreparedExecutable { + profile_name: String, + source: PathBuf, + source_relative: String, +} + +#[derive(Debug, Clone)] +struct PreparedTool<'a> { + selected: SelectedTool<'a>, + executables: Vec, +} + +pub fn default_lock_path() -> PathBuf { + PathBuf::from(DEFAULT_LOCK_PATH) +} + +pub fn default_profile_path() -> PathBuf { + PathBuf::from(DEFAULT_PROFILE_PATH) +} + +pub fn default_zed_home() -> Result { + let configured = env::var_os("ZED_PKG_HOME") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|home| home.join(".zed-pkg"))) + .context("could not determine Zed home; set ZED_PKG_HOME")?; + if configured.is_absolute() { + Ok(configured) + } else { + Ok(env::current_dir()?.join(configured)) + } +} + +pub fn load_environment_lock( + root: &Path, + lock_path: Option<&Path>, + mode: LockMode, + expected_plan_digest: Option<&str>, +) -> Result { + let root = root + .canonicalize() + .with_context(|| format!("canonicalizing project root `{}`", root.display()))?; + ensure!( + root.is_dir(), + "project root `{}` is not a directory", + root.display() + ); + let relative_path = portable_relative_path( + lock_path.unwrap_or_else(|| Path::new(DEFAULT_LOCK_PATH)), + "environment lock", + false, + )?; + let path = existing_regular_project_file(&root, &relative_path, "environment lock")?; + let bytes = fs::read(&path) + .with_context(|| format!("reading environment lock `{}`", relative_path.display()))?; + let input = std::str::from_utf8(&bytes).with_context(|| { + format!( + "environment lock `{}` is not UTF-8", + relative_path.display() + ) + })?; + let lock = if relative_path + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")) + { + EnvironmentLock::parse_json(input) + } else { + EnvironmentLock::parse_toml(input) + } + .with_context(|| format!("parsing environment lock `{}`", relative_path.display()))?; + lock.validate(match mode { + LockMode::Local => EnvironmentLockValidationMode::Local, + LockMode::Portable => EnvironmentLockValidationMode::Portable, + })?; + if let Some(expected) = expected_plan_digest { + lock.verify_plan_digest(expected)?; + } + let digest_sha256 = lock.normalized_digest_sha256()?; + Ok(LoadedEnvironmentLock { + relative_path, + lock, + digest_sha256, + }) +} + +pub fn verify_receipt(loaded: &LoadedEnvironmentLock, mode: LockMode) -> ToolVerifyReceipt { + ToolVerifyReceipt { + schema: TOOL_PROFILE_SCHEMA_V1.to_string(), + lock: portable_display(&loaded.relative_path), + lock_sha256: loaded.digest_sha256.clone(), + validation: match mode { + LockMode::Local => "local", + LockMode::Portable => "portable", + } + .to_string(), + plan_digest_sha256: loaded.lock.plan_digest_sha256.to_ascii_lowercase(), + tools: loaded.lock.tools.len(), + variants: loaded.lock.tools.values().map(Vec::len).sum(), + } +} + +pub fn list_target(loaded: &LoadedEnvironmentLock, target: &str) -> Result> { + let selected = select_exact_target(&loaded.lock, target)?; + Ok(selected + .into_iter() + .map(|selected| summary(selected.name, selected.locked)) + .collect()) +} + +pub fn install_offline( + root: &Path, + loaded: &LoadedEnvironmentLock, + target: &str, + profile_path: Option<&Path>, + home: &Path, +) -> Result { + let root = root + .canonicalize() + .with_context(|| format!("canonicalizing project root `{}`", root.display()))?; + loaded + .lock + .validate(EnvironmentLockValidationMode::Portable) + .context("frozen tool installation requires a portable environment lock")?; + let profile_relative = portable_relative_path( + profile_path.unwrap_or_else(|| Path::new(DEFAULT_PROFILE_PATH)), + "tool profile", + false, + )?; + validate_component(target, "target")?; + let selected = select_exact_target(&loaded.lock, target)?; + ensure!( + !selected.is_empty(), + "environment lock contains no tools for frozen profile installation" + ); + let store = Store::new(home); + + project_lock::with_lock(&root, "install frozen environment tool profile", || { + install_locked(&root, loaded, target, &profile_relative, &store, &selected) + }) +} + +fn install_locked( + root: &Path, + loaded: &LoadedEnvironmentLock, + target: &str, + profile_relative: &Path, + store: &Store, + selected: &[SelectedTool<'_>], +) -> Result { + let profile_root = ensure_directory_chain(root, profile_relative, "tool profile")?; + let version_root = ensure_real_directory(&profile_root, "v1", "tool profile version")?; + let active = version_root.join(target); + let prepared = prepare_tools(store, selected)?; + let state = profile_state(loaded, target, &prepared); + let mut state_bytes = serde_json::to_vec_pretty(&state)?; + state_bytes.push(b'\n'); + let summaries = prepared + .iter() + .map(|prepared| summary(prepared.selected.name, prepared.selected.locked)) + .collect::>(); + + if active_profile_matches(&active, &state_bytes, &prepared)? { + store.record_project( + &active, + prepared + .iter() + .map(|tool| tool.selected.locked.artifact.sha256.clone()) + .collect(), + )?; + return Ok(install_receipt( + "unchanged", + loaded, + target, + profile_relative, + summaries, + )); + } + + let staging = tempfile::Builder::new() + .prefix(&format!(".{target}.staging-")) + .tempdir_in(&version_root) + .context("creating tool-profile staging directory")?; + let staging_path = staging.path().to_path_buf(); + let staging_bin = staging_path.join("bin"); + fs::create_dir(&staging_bin)?; + for tool in &prepared { + for executable in &tool.executables { + materialize_executable( + &executable.source, + &staging_bin.join(&executable.profile_name), + )?; + } + } + let state_path = staging_path.join(PROFILE_STATE_FILE); + write_synced_file(&state_path, &state_bytes)?; + sync_directory(&staging_bin)?; + sync_directory(&staging_path)?; + + let staging_path = staging.keep(); + let backup = version_root.join(format!(".{target}.backup-{}", Uuid::new_v4())); + let had_active = match fs::symlink_metadata(&active) { + Ok(metadata) => { + ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "active tool profile `{}` must be a real directory", + portable_display(&active) + ); + fs::rename(&active, &backup).with_context(|| { + format!("staging prior tool profile `{}`", portable_display(&active)) + })?; + true + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => return Err(error).context("inspecting active tool profile"), + }; + + if let Err(error) = fs::rename(&staging_path, &active) { + if had_active { + let _ = fs::rename(&backup, &active); + } + let _ = fs::remove_dir_all(&staging_path); + return Err(error).context("activating frozen tool profile"); + } + sync_directory(&version_root)?; + + let refs = prepared + .iter() + .map(|tool| tool.selected.locked.artifact.sha256.clone()) + .collect::>(); + if let Err(error) = store.record_project(&active, refs) { + let _ = fs::remove_dir_all(&active); + if had_active { + let _ = fs::rename(&backup, &active); + } + return Err(error).context("recording live tool-profile store references"); + } + if had_active { + fs::remove_dir_all(&backup).context("removing prior tool profile")?; + } + sync_directory(&version_root)?; + + Ok(install_receipt( + "installed", + loaded, + target, + profile_relative, + summaries, + )) +} + +fn prepare_tools<'a>( + store: &Store, + selected: &[SelectedTool<'a>], +) -> Result>> { + let mut ownership = BTreeMap::::new(); + let mut prepared = Vec::with_capacity(selected.len()); + for selected in selected { + ensure!( + matches!( + selected.locked.artifact.format, + LockedArtifactFormat::TarGz | LockedArtifactFormat::Zip + ), + "tool `{}` uses unsupported offline artifact format {:?}; this slice supports tar_gz and zip", + selected.name, + selected.locked.artifact.format + ); + let archive = store.cached_artifact(&selected.locked.artifact.sha256); + let metadata = fs::symlink_metadata(&archive).with_context(|| { + format!( + "missing cached artifact {} for tool `{}`; prefetch the exact lock before offline install", + selected.locked.artifact.sha256, selected.name + ) + })?; + ensure!( + metadata.is_file() && !metadata.file_type().is_symlink(), + "cached artifact for tool `{}` must be a regular file", + selected.name + ); + ensure!( + metadata.len() == selected.locked.artifact.size, + "cached artifact size mismatch for tool `{}`: lock={}, cache={}", + selected.name, + selected.locked.artifact.size, + metadata.len() + ); + let (actual_sha256, actual_size) = sha256_file(&archive)?; + ensure!( + actual_sha256 == selected.locked.artifact.sha256, + "cached artifact hash mismatch for tool `{}`: expected {}, got {}", + selected.name, + selected.locked.artifact.sha256, + actual_sha256 + ); + ensure!( + actual_size == selected.locked.artifact.size, + "cached artifact size changed while verifying tool `{}`", + selected.name + ); + let package_root = store.add_artifact(&archive, &selected.locked.artifact.sha256)?; + let install_root = resolve_directory_beneath( + &package_root, + Path::new(&selected.locked.install.root), + &format!("tool `{}` install root", selected.name), + )?; + let mut executables = Vec::new(); + for executable in &selected.locked.install.executables { + prepare_locked_executable( + selected.name, + executable, + &install_root, + &mut ownership, + &mut executables, + )?; + } + ensure!( + !executables.is_empty(), + "tool `{}` exposes no executables in the locked profile", + selected.name + ); + prepared.push(PreparedTool { + selected: selected.clone(), + executables, + }); + } + Ok(prepared) +} + +fn prepare_locked_executable( + tool: &str, + executable: &LockedExecutable, + install_root: &Path, + ownership: &mut BTreeMap, + output: &mut Vec, +) -> Result<()> { + let source_relative = portable_relative_path( + Path::new(&executable.path), + &format!("tool `{tool}` executable `{}`", executable.name), + false, + )?; + let source = resolve_regular_file_beneath( + install_root, + &source_relative, + &format!("tool `{tool}` executable `{}`", executable.name), + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + ensure!( + source.metadata()?.permissions().mode() & 0o111 != 0, + "tool `{tool}` executable `{}` is not marked executable", + executable.name + ); + } + for logical_name in std::iter::once(&executable.name).chain(executable.aliases.iter()) { + validate_component(logical_name, "executable name")?; + let profile_name = profile_executable_name(logical_name, &source)?; + let key = profile_name.to_ascii_lowercase(); + if let Some(existing) = ownership.insert(key, tool.to_string()) { + bail!( + "executable `{profile_name}` is claimed by both tool `{existing}` and tool `{tool}`" + ); + } + output.push(PreparedExecutable { + profile_name, + source: source.clone(), + source_relative: portable_display(&source_relative), + }); + } + output.sort_by(|left, right| left.profile_name.cmp(&right.profile_name)); + Ok(()) +} + +fn profile_state( + loaded: &LoadedEnvironmentLock, + target: &str, + prepared: &[PreparedTool<'_>], +) -> ToolProfileState { + ToolProfileState { + schema: TOOL_PROFILE_SCHEMA_V1.to_string(), + lock_sha256: loaded.digest_sha256.clone(), + target: target.to_string(), + tools: prepared + .iter() + .map(|tool| ToolProfileTool { + name: tool.selected.name.to_string(), + requirement: tool.selected.locked.requirement.clone(), + resolved: tool.selected.locked.resolved.clone(), + backend: tool.selected.locked.backend.clone(), + artifact_sha256: tool.selected.locked.artifact.sha256.clone(), + install_root: tool.selected.locked.install.root.clone(), + executables: tool + .executables + .iter() + .map(|executable| ToolProfileExecutable { + name: executable.profile_name.clone(), + source: executable.source_relative.clone(), + }) + .collect(), + }) + .collect(), + } +} + +fn active_profile_matches( + active: &Path, + expected_state: &[u8], + prepared: &[PreparedTool<'_>], +) -> Result { + let metadata = match fs::symlink_metadata(active) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error).context("inspecting active tool profile"), + }; + ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "active tool profile `{}` must be a real directory", + portable_display(active) + ); + let state = active.join(PROFILE_STATE_FILE); + let state_metadata = match fs::symlink_metadata(&state) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error).context("inspecting tool profile state"), + }; + if !state_metadata.is_file() || state_metadata.file_type().is_symlink() { + return Ok(false); + } + if fs::read(&state)? != expected_state { + return Ok(false); + } + let bin = active.join("bin"); + let bin_metadata = match fs::symlink_metadata(&bin) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error).context("inspecting tool profile bin directory"), + }; + if !bin_metadata.is_dir() || bin_metadata.file_type().is_symlink() { + return Ok(false); + } + let expected = prepared + .iter() + .flat_map(|tool| { + tool.executables + .iter() + .map(|executable| executable.profile_name.clone()) + }) + .collect::>(); + let actual = fs::read_dir(&bin)? + .map(|entry| Ok(entry?.file_name().to_string_lossy().into_owned())) + .collect::>>()?; + if expected != actual { + return Ok(false); + } + for executable in prepared.iter().flat_map(|tool| tool.executables.iter()) { + let destination = bin.join(&executable.profile_name); + #[cfg(unix)] + { + let metadata = fs::symlink_metadata(&destination)?; + if !metadata.file_type().is_symlink() + || fs::read_link(&destination)? != executable.source + { + return Ok(false); + } + } + #[cfg(windows)] + { + let metadata = fs::symlink_metadata(&destination)?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Ok(false); + } + let (source_sha256, source_size) = sha256_file(&executable.source)?; + let (destination_sha256, destination_size) = sha256_file(&destination)?; + if source_sha256 != destination_sha256 || source_size != destination_size { + return Ok(false); + } + } + } + Ok(true) +} + +fn install_receipt( + action: &str, + loaded: &LoadedEnvironmentLock, + target: &str, + profile_relative: &Path, + tools: Vec, +) -> ToolInstallReceipt { + let active_relative = profile_relative.join("v1").join(target); + ToolInstallReceipt { + schema: TOOL_PROFILE_SCHEMA_V1.to_string(), + action: action.to_string(), + lock: portable_display(&loaded.relative_path), + lock_sha256: loaded.digest_sha256.clone(), + target: target.to_string(), + profile: portable_display(&active_relative), + bin: portable_display(&active_relative.join("bin")), + tools, + } +} + +fn summary(name: &str, locked: &LockedTool) -> ToolLockSummary { + let mut executables = locked + .install + .executables + .iter() + .flat_map(|executable| { + std::iter::once(executable.name.clone()).chain(executable.aliases.iter().cloned()) + }) + .collect::>(); + executables.sort(); + ToolLockSummary { + name: name.to_string(), + requirement: locked.requirement.clone(), + resolved: locked.resolved.clone(), + backend: locked.backend.clone(), + target: locked.platform.target.clone(), + artifact_sha256: locked.artifact.sha256.clone(), + artifact_size: locked.artifact.size, + executables, + } +} + +fn select_exact_target<'a>( + lock: &'a EnvironmentLock, + target: &str, +) -> Result>> { + validate_component(target, "target")?; + let mut selected = Vec::with_capacity(lock.tools.len()); + for (name, variants) in &lock.tools { + let matching = variants + .iter() + .filter(|variant| variant.platform.target == target) + .collect::>(); + ensure!( + !matching.is_empty(), + "tool `{name}` has no locked variant for target `{target}`" + ); + ensure!( + matching.len() == 1, + "tool `{name}` has {} locked variants for target `{target}`; multi-version activation is not yet certified", + matching.len() + ); + selected.push(SelectedTool { + name, + locked: matching[0], + }); + } + Ok(selected) +} + +fn materialize_executable(source: &Path, destination: &Path) -> Result<()> { + ensure!( + fs::symlink_metadata(destination) + .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound), + "tool profile destination `{}` already exists", + portable_display(destination) + ); + #[cfg(unix)] + { + std::os::unix::fs::symlink(source, destination).with_context(|| { + format!( + "linking tool executable `{}` -> `{}`", + portable_display(destination), + portable_display(source) + ) + })?; + } + #[cfg(windows)] + { + fs::copy(source, destination).with_context(|| { + format!( + "copying tool executable `{}` -> `{}`", + portable_display(source), + portable_display(destination) + ) + })?; + OpenOptions::new() + .write(true) + .open(destination) + .with_context(|| { + format!( + "opening copied tool executable `{}` for synchronization", + portable_display(destination) + ) + })? + .sync_all() + .with_context(|| { + format!( + "synchronizing copied tool executable `{}`", + portable_display(destination) + ) + })?; + } + Ok(()) +} + +fn profile_executable_name(logical_name: &str, source: &Path) -> Result { + #[cfg(not(windows))] + let _ = source; + #[cfg(windows)] + { + if Path::new(logical_name).extension().is_none() + && let Some(extension) = source.extension().and_then(|value| value.to_str()) + && matches!( + extension.to_ascii_lowercase().as_str(), + "exe" | "com" | "cmd" | "bat" + ) + { + return Ok(format!("{logical_name}.{extension}")); + } + } + Ok(logical_name.to_string()) +} + +fn resolve_directory_beneath(base: &Path, relative: &Path, field: &str) -> Result { + if relative == Path::new(".") { + return base + .canonicalize() + .with_context(|| format!("canonicalizing {field}")); + } + let relative = portable_relative_path(relative, field, false)?; + let candidate = walk_real_components(base, &relative, field)?; + ensure!(candidate.is_dir(), "{field} must be a directory"); + Ok(candidate) +} + +fn resolve_regular_file_beneath(base: &Path, relative: &Path, field: &str) -> Result { + let relative = portable_relative_path(relative, field, false)?; + let candidate = walk_real_components(base, &relative, field)?; + let metadata = fs::symlink_metadata(&candidate)?; + ensure!( + metadata.is_file() && !metadata.file_type().is_symlink(), + "{field} must be a regular file" + ); + Ok(candidate) +} + +fn walk_real_components(base: &Path, relative: &Path, field: &str) -> Result { + let canonical_base = base + .canonicalize() + .with_context(|| format!("canonicalizing {field} base"))?; + let mut current = canonical_base.clone(); + for component in relative.components() { + let Component::Normal(component) = component else { + bail!("{field} must be a portable relative path"); + }; + current.push(component); + let metadata = fs::symlink_metadata(¤t) + .with_context(|| format!("inspecting {field} `{}`", portable_display(relative)))?; + ensure!( + !metadata.file_type().is_symlink(), + "{field} must not traverse a symlink" + ); + } + let canonical = current + .canonicalize() + .with_context(|| format!("canonicalizing {field}"))?; + ensure!( + canonical.starts_with(&canonical_base), + "{field} escapes its authenticated artifact root" + ); + Ok(canonical) +} + +fn existing_regular_project_file(root: &Path, relative: &Path, field: &str) -> Result { + let candidate = walk_real_components(root, relative, field)?; + let metadata = fs::symlink_metadata(&candidate)?; + ensure!( + metadata.is_file() && !metadata.file_type().is_symlink(), + "{field} `{}` must be a regular file", + portable_display(relative) + ); + Ok(candidate) +} + +fn ensure_directory_chain(root: &Path, relative: &Path, field: &str) -> Result { + let relative = portable_relative_path(relative, field, false)?; + let mut current = root.to_path_buf(); + for component in relative.components() { + let Component::Normal(component) = component else { + bail!("{field} must be project-relative"); + }; + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) => ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "{field} component `{}` must be a real directory", + portable_display(¤t) + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(¤t).with_context(|| { + format!( + "creating {field} directory `{}`", + portable_display(¤t) + ) + })?; + } + Err(error) => return Err(error).with_context(|| format!("inspecting {field}")), + } + } + Ok(current) +} + +fn ensure_real_directory(parent: &Path, name: &str, field: &str) -> Result { + validate_component(name, field)?; + let path = parent.join(name); + match fs::symlink_metadata(&path) { + Ok(metadata) => ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "{field} `{}` must be a real directory", + portable_display(&path) + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => fs::create_dir(&path)?, + Err(error) => return Err(error).with_context(|| format!("inspecting {field}")), + } + Ok(path) +} + +fn portable_relative_path(path: &Path, field: &str, allow_dot: bool) -> Result { + ensure!(!path.as_os_str().is_empty(), "{field} path cannot be empty"); + ensure!(!path.is_absolute(), "{field} must be project-relative"); + let text = path.to_string_lossy(); + let windows_drive = text.as_bytes().get(1).is_some_and(|byte| *byte == b':') + && text.as_bytes().first().is_some_and(u8::is_ascii_alphabetic); + ensure!( + !windows_drive + && !text.starts_with('~') + && !text.starts_with("$HOME") + && !text.starts_with("${HOME}") + && !text.starts_with("%USERPROFILE%") + && !text.starts_with("//") + && !text.starts_with("\\\\"), + "{field} must be portable and project-relative" + ); + let mut output = PathBuf::new(); + for component in path.components() { + match component { + Component::Normal(component) => output.push(component), + Component::CurDir if allow_dot => {} + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + bail!("{field} cannot escape the project root") + } + } + } + ensure!( + allow_dot || !output.as_os_str().is_empty(), + "{field} cannot be the project root" + ); + Ok(output) +} + +fn validate_component(value: &str, field: &str) -> Result<()> { + ensure!(!value.trim().is_empty(), "{field} cannot be empty"); + ensure!( + value == value.trim(), + "{field} cannot contain surrounding whitespace" + ); + ensure!(value != "." && value != "..", "{field} is invalid"); + ensure!( + value + .chars() + .all(|character| character.is_ascii_alphanumeric() + || matches!(character, '.' | '_' | '-')), + "{field} `{value}` contains unsupported characters" + ); + ensure!(value.len() <= 128, "{field} exceeds 128 bytes"); + Ok(()) +} + +fn write_synced_file(path: &Path, bytes: &[u8]) -> Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .with_context(|| format!("creating `{}`", portable_display(path)))?; + file.write_all(bytes)?; + file.sync_all()?; + Ok(()) +} + +#[cfg(unix)] +fn sync_directory(path: &Path) -> Result<()> { + File::open(path)?.sync_all()?; + Ok(()) +} + +#[cfg(not(unix))] +fn sync_directory(_path: &Path) -> Result<()> { + Ok(()) +} + +fn portable_display(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn target_and_profile_paths_are_strict() { + assert!(validate_component("x86_64-unknown-linux-gnu", "target").is_ok()); + assert!(validate_component("../linux", "target").is_err()); + assert!(portable_relative_path(Path::new(".zed/tools"), "profile", false).is_ok()); + assert!(portable_relative_path(Path::new("../tools"), "profile", false).is_err()); + assert!(portable_relative_path(Path::new(r"C:\\tools"), "profile", false).is_err()); + } + + #[test] + fn exact_target_selection_rejects_missing_and_multiple_variants() { + let lock = EnvironmentLock { + plan_digest_sha256: "a".repeat(64), + ..EnvironmentLock::default() + }; + assert_eq!(select_exact_target(&lock, "x86_64-linux").unwrap().len(), 0); + } + + #[test] + fn profile_state_is_secret_free_by_shape() { + let state = ToolProfileState { + schema: TOOL_PROFILE_SCHEMA_V1.to_string(), + lock_sha256: "a".repeat(64), + target: "x86_64-linux".to_string(), + tools: Vec::new(), + }; + let json = serde_json::to_string(&state).unwrap(); + for forbidden in ["locator", "url", "token", "credential", "environment"] { + assert!(!json.contains(forbidden)); + } + } + + #[cfg(windows)] + #[test] + fn copied_windows_executable_is_opened_with_sync_capable_access() { + let root = tempfile::tempdir().unwrap(); + let source = root.path().join("hello.cmd"); + let destination = root.path().join("hello-copy.cmd"); + fs::write(&source, b"@echo off\r\necho hello\r\n").unwrap(); + + materialize_executable(&source, &destination).unwrap(); + + assert_eq!(fs::read(destination).unwrap(), fs::read(source).unwrap()); + } +} diff --git a/tests/tool_profile_cli.rs b/tests/tool_profile_cli.rs new file mode 100644 index 00000000..622656b6 --- /dev/null +++ b/tests/tool_profile_cli.rs @@ -0,0 +1,310 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +use flate2::{Compression, GzBuilder}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use zed_interfaces::{ + ENVIRONMENT_LOCK_SCHEMA_VERSION, EnvironmentLock, LockedArtifact, LockedArtifactFormat, + LockedExecutable, LockedInstall, LockedPlatform, LockedSource, LockedSourceKind, LockedTool, +}; + +fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_zed-tool")) +} + +fn target() -> &'static str { + if cfg!(windows) { + "x86_64-pc-windows-msvc" + } else if cfg!(target_os = "macos") { + "aarch64-apple-darwin" + } else { + "x86_64-unknown-linux-gnu" + } +} + +fn executable_source() -> &'static str { + if cfg!(windows) { + "bin/hello.cmd" + } else { + "bin/hello" + } +} + +fn executable_body() -> &'static [u8] { + if cfg!(windows) { + b"@echo hello\r\n" + } else { + b"#!/bin/sh\nprintf 'hello\\n'\n" + } +} + +fn archive(path: &Path, source: &str, body: &[u8]) { + let file = fs::File::create(path).unwrap(); + let encoder = GzBuilder::new() + .mtime(0) + .write(file, Compression::default()); + let mut builder = tar::Builder::new(encoder); + let mut header = tar::Header::new_gnu(); + header.set_size(body.len() as u64); + header.set_mtime(0); + header.set_uid(0); + header.set_gid(0); + header.set_mode(0o755); + header.set_cksum(); + builder + .append_data(&mut header, format!("pkg/{source}"), body) + .unwrap(); + let encoder = builder.into_inner().unwrap(); + encoder.finish().unwrap(); +} + +fn digest(path: &Path) -> (String, u64) { + let bytes = fs::read(path).unwrap(); + (hex::encode(Sha256::digest(&bytes)), bytes.len() as u64) +} + +fn locked_tool(name: &str, sha256: &str, size: u64, executable_name: &str) -> LockedTool { + LockedTool { + requirement: "1".to_string(), + resolved: "1.0.0".to_string(), + backend: "http".to_string(), + backend_version: Some("1.0.0".to_string()), + backend_options_digest_sha256: None, + source: LockedSource { + kind: LockedSourceKind::Http, + locator: format!("https://example.invalid/{name}-1.0.0.tar.gz"), + revision: None, + tree_sha256: None, + immutable: false, + portable: false, + extensions: BTreeMap::new(), + }, + artifact: LockedArtifact { + sha256: sha256.to_string(), + size, + format: LockedArtifactFormat::TarGz, + mirrors: Vec::new(), + signatures: Vec::new(), + extensions: BTreeMap::new(), + }, + platform: LockedPlatform { + target: target().to_string(), + os: Some(std::env::consts::OS.to_string()), + arch: Some(std::env::consts::ARCH.to_string()), + libc: None, + abi: None, + }, + install: LockedInstall { + root: ".".to_string(), + bin_dirs: vec!["bin".to_string()], + executables: vec![LockedExecutable { + name: executable_name.to_string(), + path: executable_source().to_string(), + aliases: vec![format!("{executable_name}-alias")], + }], + layout_digest_sha256: None, + extensions: BTreeMap::new(), + }, + extensions: BTreeMap::new(), + } +} + +fn fixture(tools: &[(&str, &str)]) -> (tempfile::TempDir, PathBuf, PathBuf, String) { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let home = temp.path().join("home"); + fs::create_dir_all(project.join(".zed")).unwrap(); + fs::create_dir_all(home.join("cache")).unwrap(); + + let draft = temp.path().join("artifact.tar.gz"); + archive(&draft, executable_source(), executable_body()); + let (sha256, size) = digest(&draft); + fs::rename(&draft, home.join("cache").join(format!("{sha256}.tar.gz"))).unwrap(); + + let mut lock = EnvironmentLock { + schema_version: ENVIRONMENT_LOCK_SCHEMA_VERSION, + plan_digest_sha256: "a".repeat(64), + tools: BTreeMap::new(), + extensions: BTreeMap::new(), + }; + for (tool, executable) in tools { + lock.tools.insert( + (*tool).to_string(), + vec![locked_tool(tool, &sha256, size, executable)], + ); + } + fs::write( + project.join(".zed/environment.lock.toml"), + lock.to_toml_string().unwrap(), + ) + .unwrap(); + (temp, project, home, sha256) +} + +fn run(project: &Path, home: &Path, args: &[&str]) -> Output { + Command::new(binary()) + .current_dir(project) + .args(args) + .env("ZED_PKG_HOME", home) + .env_remove("ZED_TOOL_LOCK") + .env_remove("ZED_TOOL_JSON") + .env_remove("ZED_TOOL_PORTABLE") + .env_remove("ZED_TOOL_PLAN_DIGEST") + .env_remove("ZED_TOOL_TARGET") + .env_remove("ZED_TOOL_OFFLINE") + .env_remove("ZED_TOOL_PROFILE") + .output() + .unwrap() +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn assert_no_profile_state(project: &Path) { + assert!( + !project.join(".zed/tools").exists(), + "failed command left the tool-profile namespace behind" + ); +} + +#[test] +fn verify_list_install_and_idempotent_replay_are_deterministic() { + let (_temp, project, home, sha256) = fixture(&[("hello", "hello")]); + + let verify = run( + &project, + &home, + &[ + "--json", + "verify", + "--portable", + "--plan-digest", + &"a".repeat(64), + ], + ); + assert_success(&verify); + let verify: Value = serde_json::from_slice(&verify.stdout).unwrap(); + assert_eq!(verify["tools"], 1); + assert_eq!(verify["validation"], "portable"); + + let list = run(&project, &home, &["--json", "list", "--target", target()]); + assert_success(&list); + let list: Value = serde_json::from_slice(&list.stdout).unwrap(); + assert_eq!(list[0]["name"], "hello"); + assert_eq!(list[0]["artifact_sha256"], sha256); + + let first = run( + &project, + &home, + &["--json", "install", "--target", target(), "--offline"], + ); + assert_success(&first); + let first: Value = serde_json::from_slice(&first.stdout).unwrap(); + assert_eq!(first["action"], "installed"); + let state = project + .join(".zed/tools/v1") + .join(target()) + .join("profile.json"); + let state_before = fs::read(&state).unwrap(); + + let second = run( + &project, + &home, + &["--json", "install", "--target", target(), "--offline"], + ); + assert_success(&second); + let second: Value = serde_json::from_slice(&second.stdout).unwrap(); + assert_eq!(second["action"], "unchanged"); + assert_eq!(fs::read(&state).unwrap(), state_before); + + let bin = project.join(second["bin"].as_str().unwrap()); + if cfg!(windows) { + assert!(bin.join("hello.cmd").is_file()); + assert!(bin.join("hello-alias.cmd").is_file()); + } else { + let output = Command::new(bin.join("hello")).output().unwrap(); + assert_success(&output); + assert_eq!(output.stdout, b"hello\n"); + assert!(bin.join("hello-alias").exists()); + } +} + +#[test] +fn missing_and_tampered_cache_fail_before_profile_mutation() { + let (_temp, project, home, sha256) = fixture(&[("hello", "hello")]); + let cache = home.join("cache").join(format!("{sha256}.tar.gz")); + let original = fs::read(&cache).unwrap(); + + fs::remove_file(&cache).unwrap(); + let missing = run( + &project, + &home, + &["install", "--target", target(), "--offline"], + ); + assert!(!missing.status.success()); + assert!(String::from_utf8_lossy(&missing.stderr).contains("prefetch")); + assert_no_profile_state(&project); + + fs::write(&cache, &original).unwrap(); + let mut tampered = original; + let last = tampered.len() - 1; + tampered[last] ^= 1; + fs::write(&cache, tampered).unwrap(); + let mismatch = run( + &project, + &home, + &["install", "--target", target(), "--offline"], + ); + assert!(!mismatch.status.success()); + assert!(String::from_utf8_lossy(&mismatch.stderr).contains("hash mismatch")); + assert_no_profile_state(&project); +} + +#[test] +fn executable_collisions_and_online_mode_fail_closed() { + let (_temp, project, home, _sha256) = fixture(&[("one", "shared"), ("two", "shared")]); + let collision = run( + &project, + &home, + &["install", "--target", target(), "--offline"], + ); + assert!(!collision.status.success()); + assert!(String::from_utf8_lossy(&collision.stderr).contains("claimed by both")); + assert_no_profile_state(&project); + + let online = run(&project, &home, &["install", "--target", target()]); + assert!(!online.status.success()); + assert!(String::from_utf8_lossy(&online.stderr).contains("requires `--offline`")); + assert_no_profile_state(&project); +} + +#[test] +fn wrong_plan_digest_and_target_fail_without_mutation() { + let (_temp, project, home, _sha256) = fixture(&[("hello", "hello")]); + let digest = run( + &project, + &home, + &["verify", "--portable", "--plan-digest", &"b".repeat(64)], + ); + assert!(!digest.status.success()); + assert!(String::from_utf8_lossy(&digest.stderr).contains("plan digest")); + assert_no_profile_state(&project); + + let target = run( + &project, + &home, + &["install", "--target", "../host", "--offline"], + ); + assert!(!target.status.success()); + assert!(String::from_utf8_lossy(&target.stderr).contains("unsupported characters")); + assert_no_profile_state(&project); +}