From af4c5e98877899ba977e2b6c9de0a5155dbfb28c Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:38:41 -0500 Subject: [PATCH 1/5] fix: replay frozen artifacts from the local store before registry access --- src/install_graph/resolver.rs | 73 +++++++++++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 3 deletions(-) diff --git a/src/install_graph/resolver.rs b/src/install_graph/resolver.rs index 4d97a762..765a1cb0 100644 --- a/src/install_graph/resolver.rs +++ b/src/install_graph/resolver.rs @@ -29,8 +29,7 @@ pub fn prefetch(project: &Path, cfg: &Config, frozen: bool) -> Result( fn prefetch_locked( project: &Path, cfg: &Config, - registry: &dyn Registry, concurrency: usize, ) -> Result { 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,6 +99,23 @@ fn prefetch_locked( if !seen.insert(key.clone()) { bail!("duplicate package `{key}` in {LOCKFILE_FILE}"); } + + // The lock already authenticates the immutable content hash. A frozen + // replay whose extracted object or verified download cache is present + // must not become a registry-availability check. The transactional + // installer performs the final cache hash/extraction verification and + // only falls back to the configured registry when local bytes are + // actually absent or invalid. + if store.has(&locked.sha256) || store.cached_artifact(&locked.sha256).is_file() { + continue; + } + + if registry.is_none() { + registry = Some(registry_for(&cfg.registry)?); + } + let registry = registry + .as_deref() + .context("frozen prefetch has no registry for an uncached artifact")?; 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 { @@ -160,3 +177,53 @@ pub(super) fn receive_in_order( } } } + +#[cfg(test)] +mod tests { + use super::*; + use zed_interfaces::artifact::ArtifactFormat; + use zed_interfaces::lockfile::LockedPackage; + + #[test] + fn frozen_prefetch_uses_extracted_store_without_constructing_a_registry() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let home = temp.path().join("home"); + fs::create_dir_all(&project).unwrap(); + + let sha256 = "a".repeat(64); + let store = Store::new(&home); + fs::create_dir_all(store.pkg_dir(&sha256)).unwrap(); + + let mut lock = Lockfile::default(); + lock.upsert(LockedPackage { + org: "acme".to_string(), + name: "tool".to_string(), + version: "1.0.0".to_string(), + sha256, + size: 1, + format: ArtifactFormat::TarGz, + vcs_tag: "v1.0.0".to_string(), + vcs_commit: Some("b".repeat(40)), + source: "file:///registry-that-no-longer-exists".to_string(), + }); + fs::write( + project.join(LOCKFILE_FILE), + lock.to_toml_string().unwrap(), + ) + .unwrap(); + + let cfg = Config { + registry: "this registry string is intentionally unusable".to_string(), + home, + token: None, + auth_url: "https://auth.invalid".to_string(), + supabase_url: None, + supabase_key: None, + interactive: false, + }; + + let report = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(report, PrefetchReport::default()); + } +} From 58a3a04c100d3ba5b097997b7ddd8d16761980a0 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:46:02 -0500 Subject: [PATCH 2/5] fix: align frozen prefetch with validated lock-authoritative replay --- src/install_graph/resolver.rs | 126 ++++++++++++---------------------- 1 file changed, 45 insertions(+), 81 deletions(-) diff --git a/src/install_graph/resolver.rs b/src/install_graph/resolver.rs index 765a1cb0..452f2d8c 100644 --- a/src/install_graph/resolver.rs +++ b/src/install_graph/resolver.rs @@ -72,11 +72,25 @@ fn run_with_pool( } } -fn prefetch_locked( - project: &Path, - cfg: &Config, - concurrency: usize, -) -> Result { +fn locked_version_metadata(locked: &zed_interfaces::lockfile::LockedPackage) -> VersionMetadata { + VersionMetadata { + org: locked.org.clone(), + name: locked.name.clone(), + version: locked.version.clone(), + sha256: locked.sha256.clone(), + size: locked.size, + format: locked.format, + vcs_tag: locked.vcs_tag.clone(), + vcs_commit: locked.vcs_commit.clone(), + // The worker never consumes this URL while the authenticated bytes are + // already present in the store or verified artifact cache. + download_url: String::new(), + published_at: "1970-01-01T00:00:00Z".to_string(), + yanked: false, + } +} + +fn prefetch_locked(project: &Path, cfg: &Config, concurrency: usize) -> Result { let lock_path = project.join(LOCKFILE_FILE); let text = fs::read_to_string(&lock_path) .with_context(|| format!("--frozen requires {}", lock_path.display()))?; @@ -100,33 +114,33 @@ fn prefetch_locked( bail!("duplicate package `{key}` in {LOCKFILE_FILE}"); } - // The lock already authenticates the immutable content hash. A frozen - // replay whose extracted object or verified download cache is present - // must not become a registry-availability check. The transactional - // installer performs the final cache hash/extraction verification and - // only falls back to the configured registry when local bytes are - // actually absent or invalid. - if store.has(&locked.sha256) || store.cached_artifact(&locked.sha256).is_file() { - continue; - } - - if registry.is_none() { - registry = Some(registry_for(&cfg.registry)?); - } - let registry = registry - .as_deref() - .context("frozen prefetch has no registry for an uncached artifact")?; - 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, @@ -177,53 +191,3 @@ pub(super) fn receive_in_order( } } } - -#[cfg(test)] -mod tests { - use super::*; - use zed_interfaces::artifact::ArtifactFormat; - use zed_interfaces::lockfile::LockedPackage; - - #[test] - fn frozen_prefetch_uses_extracted_store_without_constructing_a_registry() { - let temp = tempfile::tempdir().unwrap(); - let project = temp.path().join("project"); - let home = temp.path().join("home"); - fs::create_dir_all(&project).unwrap(); - - let sha256 = "a".repeat(64); - let store = Store::new(&home); - fs::create_dir_all(store.pkg_dir(&sha256)).unwrap(); - - let mut lock = Lockfile::default(); - lock.upsert(LockedPackage { - org: "acme".to_string(), - name: "tool".to_string(), - version: "1.0.0".to_string(), - sha256, - size: 1, - format: ArtifactFormat::TarGz, - vcs_tag: "v1.0.0".to_string(), - vcs_commit: Some("b".repeat(40)), - source: "file:///registry-that-no-longer-exists".to_string(), - }); - fs::write( - project.join(LOCKFILE_FILE), - lock.to_toml_string().unwrap(), - ) - .unwrap(); - - let cfg = Config { - registry: "this registry string is intentionally unusable".to_string(), - home, - token: None, - auth_url: "https://auth.invalid".to_string(), - supabase_url: None, - supabase_key: None, - interactive: false, - }; - - let report = prefetch(&project, &cfg, true).unwrap(); - assert_eq!(report, PrefetchReport::default()); - } -} From ceb681dc39f410b7400932d382bd93f823044d51 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 15:46:25 -0500 Subject: [PATCH 3/5] test: prove frozen prefetch survives registry deletion --- 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 1d36ff8ef8f7167ba68c2ddcb20506a292dc9d33 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 16:00:18 -0500 Subject: [PATCH 4/5] ci: certify frozen offline prefetch cross-platform --- .github/workflows/frozen-offline-prefetch.yml | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/frozen-offline-prefetch.yml diff --git a/.github/workflows/frozen-offline-prefetch.yml b/.github/workflows/frozen-offline-prefetch.yml new file mode 100644 index 00000000..21963925 --- /dev/null +++ b/.github/workflows/frozen-offline-prefetch.yml @@ -0,0 +1,60 @@ +name: frozen offline prefetch + +on: + pull_request: + paths: + - src/install_graph/** + - tests/frozen_offline_prefetch.rs + - .github/workflows/frozen-offline-prefetch.yml + push: + branches: [main] + paths: + - src/install_graph/** + - tests/frozen_offline_prefetch.rs + - .github/workflows/frozen-offline-prefetch.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: frozen-offline-prefetch-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + frozen-offline-prefetch: + name: frozen offline prefetch / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-2025] + steps: + - name: Check out exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Install Rust quality components + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + rustc --version --verbose + cargo --version --verbose + + - name: Format + run: cargo fmt --all --check + + - name: Real pack, cold prefetch, registry deletion, and offline replay + run: cargo test --locked --test frozen_offline_prefetch -- --nocapture + + - name: Strict Clippy + run: cargo clippy --locked --test frozen_offline_prefetch -- -D warnings From 70f01fe35b22f5bbb64281bfca51b51d04927fe7 Mon Sep 17 00:00:00 2001 From: Alexander Mills Date: Tue, 4 Aug 2026 16:09:20 -0500 Subject: [PATCH 5/5] test: certify registry-free restore from both store and cache --- tests/frozen_offline_prefetch.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/frozen_offline_prefetch.rs b/tests/frozen_offline_prefetch.rs index af36f50a..83bc1c8f 100644 --- a/tests/frozen_offline_prefetch.rs +++ b/tests/frozen_offline_prefetch.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use zed_cli::config::Config; use zed_cli::install_graph::prefetch; use zed_cli::pack::pack; +use zed_cli::store::Store; use zed_interfaces::lockfile::{LockedPackage, Lockfile}; use zed_interfaces::manifest::Manifest; use zed_interfaces::paths::{LOCKFILE_FILE, MANIFEST_FILE}; @@ -22,7 +23,7 @@ fn test_config(registry: &Path, home: PathBuf) -> Config { } #[test] -fn frozen_prefetch_restores_from_the_store_after_registry_metadata_disappears() { +fn frozen_prefetch_restores_from_store_and_cache_after_registry_metadata_disappears() { let temp = tempfile::tempdir().unwrap(); let registry = temp.path().join("registry"); let source = temp.path().join("source"); @@ -91,12 +92,22 @@ url = "https://example.invalid/offline/tool" fs::write(project.join(LOCKFILE_FILE), lock.to_toml_string().unwrap()).unwrap(); let cfg = test_config(®istry, home); + let store = Store::new(&cfg.home); let cold = prefetch(&project, &cfg, true).unwrap(); assert_eq!(cold.resolved, 1); assert_eq!(cold.downloaded, 1); + assert!(store.has(&packed.sha256)); + assert!(store.cached_artifact(&packed.sha256).is_file()); fs::remove_dir_all(®istry).unwrap(); - let offline = prefetch(&project, &cfg, true).unwrap(); - assert_eq!(offline.resolved, 1); - assert_eq!(offline.downloaded, 0); + let from_store = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(from_store.resolved, 1); + assert_eq!(from_store.downloaded, 0); + + fs::remove_dir_all(store.entry_dir(&packed.sha256)).unwrap(); + assert!(!store.has(&packed.sha256)); + let from_cache = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(from_cache.resolved, 1); + assert_eq!(from_cache.downloaded, 0); + assert!(store.has(&packed.sha256)); }