diff --git a/.github/workflows/frozen-offline-prefetch.yml b/.github/workflows/frozen-offline-prefetch.yml new file mode 100644 index 00000000..21963925 --- /dev/null +++ b/.github/workflows/frozen-offline-prefetch.yml @@ -0,0 +1,60 @@ +name: frozen offline prefetch + +on: + pull_request: + paths: + - src/install_graph/** + - tests/frozen_offline_prefetch.rs + - .github/workflows/frozen-offline-prefetch.yml + push: + branches: [main] + paths: + - src/install_graph/** + - tests/frozen_offline_prefetch.rs + - .github/workflows/frozen-offline-prefetch.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: frozen-offline-prefetch-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + frozen-offline-prefetch: + name: frozen offline prefetch / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-2025] + steps: + - name: Check out exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + show-progress: false + + - name: Install Rust quality components + run: | + set -euo pipefail + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + rustc --version --verbose + cargo --version --verbose + + - name: Format + run: cargo fmt --all --check + + - name: Real pack, cold prefetch, registry deletion, and offline replay + run: cargo test --locked --test frozen_offline_prefetch -- --nocapture + + - name: Strict Clippy + run: cargo clippy --locked --test frozen_offline_prefetch -- -D warnings diff --git a/src/install_graph/resolver.rs b/src/install_graph/resolver.rs index 4d97a762..452f2d8c 100644 --- a/src/install_graph/resolver.rs +++ b/src/install_graph/resolver.rs @@ -29,8 +29,7 @@ pub fn prefetch(project: &Path, cfg: &Config, frozen: bool) -> Result( } } -fn prefetch_locked( - project: &Path, - cfg: &Config, - registry: &dyn Registry, - concurrency: usize, -) -> Result { +fn locked_version_metadata(locked: &zed_interfaces::lockfile::LockedPackage) -> VersionMetadata { + VersionMetadata { + org: locked.org.clone(), + name: locked.name.clone(), + version: locked.version.clone(), + sha256: locked.sha256.clone(), + size: locked.size, + format: locked.format, + vcs_tag: locked.vcs_tag.clone(), + vcs_commit: locked.vcs_commit.clone(), + // The worker never consumes this URL while the authenticated bytes are + // already present in the store or verified artifact cache. + download_url: String::new(), + published_at: "1970-01-01T00:00:00Z".to_string(), + yanked: false, + } +} + +fn prefetch_locked(project: &Path, cfg: &Config, concurrency: usize) -> Result { let lock_path = project.join(LOCKFILE_FILE); let text = fs::read_to_string(&lock_path) .with_context(|| format!("--frozen requires {}", lock_path.display()))?; let lock = Lockfile::parse(&text)?; + let store = Store::new(&cfg.home); + let mut registry: Option> = None; let mut tasks = Vec::with_capacity(lock.packages.len()); let mut seen = BTreeSet::new(); @@ -99,17 +113,34 @@ fn prefetch_locked( if !seen.insert(key.clone()) { bail!("duplicate package `{key}` in {LOCKFILE_FILE}"); } - let version = registry.get_version(&locked.org, &locked.name, &locked.version)?; + + let version = + if store.has(&locked.sha256) || store.cached_artifact(&locked.sha256).is_file() { + // The lockfile authenticates every immutable field needed to + // verify locally owned bytes. Frozen replay must not turn a local + // restore into a registry metadata availability check. + locked_version_metadata(locked) + } else { + if registry.is_none() { + registry = Some(registry_for(&cfg.registry)?); + } + let version = registry + .as_deref() + .context("frozen prefetch registry was not initialized")? + .get_version(&locked.org, &locked.name, &locked.version)?; + validate_version_identity(&version, &locked.org, &locked.name, &locked.version)?; + if version.sha256 != locked.sha256 { + bail!( + "registry artifact for {}@{} changed (lock {} vs registry {}); refusing", + key, + locked.version, + locked.sha256, + version.sha256 + ); + } + version + }; validate_version_identity(&version, &locked.org, &locked.name, &locked.version)?; - if version.sha256 != locked.sha256 { - bail!( - "registry artifact for {}@{} changed (lock {} vs registry {}); refusing", - key, - locked.version, - locked.sha256, - version.sha256 - ); - } tasks.push(FetchTask { sequence: tasks.len(), key, diff --git a/tests/frozen_offline_prefetch.rs b/tests/frozen_offline_prefetch.rs new file mode 100644 index 00000000..83bc1c8f --- /dev/null +++ b/tests/frozen_offline_prefetch.rs @@ -0,0 +1,113 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use zed_cli::config::Config; +use zed_cli::install_graph::prefetch; +use zed_cli::pack::pack; +use zed_cli::store::Store; +use zed_interfaces::lockfile::{LockedPackage, Lockfile}; +use zed_interfaces::manifest::Manifest; +use zed_interfaces::paths::{LOCKFILE_FILE, MANIFEST_FILE}; +use zed_interfaces::registry::VersionMetadata; + +fn test_config(registry: &Path, home: PathBuf) -> Config { + Config { + registry: format!("file://{}", registry.display()), + home, + token: None, + auth_url: "http://127.0.0.1/unused".to_string(), + supabase_url: None, + supabase_key: None, + interactive: false, + } +} + +#[test] +fn frozen_prefetch_restores_from_store_and_cache_after_registry_metadata_disappears() { + let temp = tempfile::tempdir().unwrap(); + let registry = temp.path().join("registry"); + let source = temp.path().join("source"); + let packed_dir = temp.path().join("packed"); + let home = temp.path().join("home"); + let project = temp.path().join("project"); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(&project).unwrap(); + + let manifest_text = r#"[package] +org = "offline" +name = "tool" +version = "1.0.0" + +[package.repository] +vcs = "git" +url = "https://example.invalid/offline/tool" +"#; + fs::write(source.join(MANIFEST_FILE), manifest_text).unwrap(); + fs::write(source.join("payload.txt"), "offline/tool@1.0.0\n").unwrap(); + let manifest = Manifest::parse(manifest_text).unwrap(); + let packed = pack(&source, &manifest, Some(&packed_dir)).unwrap(); + + let artifact_dir = registry.join("artifacts"); + fs::create_dir_all(&artifact_dir).unwrap(); + let artifact = artifact_dir.join(format!("{}.tar.gz", packed.sha256)); + fs::copy(&packed.path, &artifact).unwrap(); + + let version_dir = registry + .join("packages") + .join("offline") + .join("tool") + .join("versions"); + fs::create_dir_all(&version_dir).unwrap(); + let version = VersionMetadata { + org: "offline".to_string(), + name: "tool".to_string(), + version: "1.0.0".to_string(), + sha256: packed.sha256.clone(), + size: packed.size, + format: packed.format, + vcs_tag: "v1.0.0".to_string(), + vcs_commit: Some("0123456789abcdef0123456789abcdef01234567".to_string()), + download_url: format!("file://{}", artifact.display()), + published_at: "1970-01-01T00:00:00Z".to_string(), + yanked: false, + }; + fs::write( + version_dir.join("1.0.0.json"), + serde_json::to_string_pretty(&version).unwrap(), + ) + .unwrap(); + + let mut lock = Lockfile::default(); + lock.upsert(LockedPackage { + org: version.org, + name: version.name, + version: version.version, + sha256: version.sha256, + size: version.size, + format: version.format, + vcs_tag: version.vcs_tag, + vcs_commit: version.vcs_commit, + source: format!("file://{}", registry.display()), + }); + fs::write(project.join(LOCKFILE_FILE), lock.to_toml_string().unwrap()).unwrap(); + + let cfg = test_config(®istry, home); + let store = Store::new(&cfg.home); + let cold = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(cold.resolved, 1); + assert_eq!(cold.downloaded, 1); + assert!(store.has(&packed.sha256)); + assert!(store.cached_artifact(&packed.sha256).is_file()); + + fs::remove_dir_all(®istry).unwrap(); + let from_store = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(from_store.resolved, 1); + assert_eq!(from_store.downloaded, 0); + + fs::remove_dir_all(store.entry_dir(&packed.sha256)).unwrap(); + assert!(!store.has(&packed.sha256)); + let from_cache = prefetch(&project, &cfg, true).unwrap(); + assert_eq!(from_cache.resolved, 1); + assert_eq!(from_cache.downloaded, 0); + assert!(store.has(&packed.sha256)); +}