Skip to main content

greentic_deployer/cli/
bundle_stage.rs

1//! Local `.gtbundle` staging into an env's revision directory. Two callers:
2//! the deployer's `op revisions stage --bundle` ([`stage_local_bundle`]), and a
3//! remote worker re-materializing an already-staged revision at boot
4//! ([`materialize_revision_from_bundle`]).
5//!
6//! Extracts a local `.gtbundle` under the env's revision directory, derives a
7//! pinned pack list from the embedded `.gtpack` artifacts (sha256 over the
8//! on-disk file, so it matches what the runner host re-verifies at load), and
9//! writes the `pack-list.lock` document that the revision's `pack_list_lock_ref`
10//! points at. The runtime-config materializer then surfaces that ref for
11//! `greentic-start` to boot from.
12//!
13//! SquashFS extraction is delegated to `greentic-bundle`'s hardened
14//! `unbundle_artifact` (path-traversal + symlink-escape guards), rather than
15//! re-implementing the unpack here.
16
17use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19
20use greentic_deploy_spec::{
21    BundleId, EnvId, LockedPack, PackId, PackListLock, Revision, RevisionId, SchemaVersion,
22};
23use sha2::{Digest, Sha256};
24
25use crate::environment::LocalFsStore;
26use crate::environment::atomic_write::atomic_write_json;
27
28use super::OpError;
29
30/// Refs produced by staging a local bundle, threaded onto the new [`Revision`].
31///
32/// [`Revision`]: greentic_deploy_spec::Revision
33pub struct StagedBundle {
34    /// `sha256:<hex>` of the `.gtbundle` archive.
35    pub bundle_digest: String,
36    /// Env-relative path to the written `pack-list.lock`.
37    pub pack_list_lock_ref: PathBuf,
38    /// The lock document (also written to disk), returned for the outcome view.
39    pub lock: PackListLock,
40}
41
42/// Extract `bundle_path` under `<env_dir>/revisions/<rev>/bundle/`, pin every
43/// embedded `.gtpack` into a `pack-list.lock`, and return the refs to record on
44/// the revision. Idempotent: a re-stage of the same revision id replaces the
45/// prior extraction.
46///
47/// Must be called with the env's flock held (it writes under `env_dir`).
48pub fn stage_local_bundle(
49    env_dir: &Path,
50    revision_id: RevisionId,
51    bundle_path: &Path,
52) -> Result<StagedBundle, OpError> {
53    if !bundle_path.is_file() {
54        return Err(OpError::InvalidArgument(format!(
55            "bundle `{}` is not a file",
56            bundle_path.display()
57        )));
58    }
59    let rev_dir = env_dir.join("revisions").join(revision_id.to_string());
60    // No Revision is recorded for a failed stage (the caller's transact rolls
61    // back env.json), so a partial copy/extraction under this freshly-minted
62    // rev dir would be an invisible orphan. Drop the whole rev dir on any error.
63    stage_into(env_dir, &rev_dir, revision_id, bundle_path).inspect_err(|_| {
64        let _ = std::fs::remove_dir_all(&rev_dir);
65    })
66}
67
68/// Materialize, on disk, everything the bundle-less `greentic-start --env` boot
69/// needs to **serve** an already-staged revision, from a locally-available
70/// `.gtbundle`.
71///
72/// A remote worker (e.g. a K8s pod) receives the env's `environment.json` — the
73/// staged [`Revision`] with its `bundle_source_uri` + `bundle_digest` — but not
74/// the multi-MB pack bytes (a ConfigMap can't carry them). It pulls the
75/// `.gtbundle` at boot and calls this to lay down the artifacts the boot loader
76/// file-checks (`pack-list.lock`, `pack-config.v1` docs) and the runner host
77/// digest-verifies (the `.gtpack` files), then (re)writes `runtime-config.json`
78/// so the next `load_or_empty` activates the revision instead of falling back
79/// to a probes-only boot.
80///
81/// Runs under the env flock ([`LocalFsStore::transact`]). Steps:
82/// 1. extract + pin packs ([`stage_local_bundle`]) → `pack-list.lock` + `.gtpack`s,
83/// 2. **integrity gate** — the staged bundle's content digest MUST equal the
84///    revision's recorded `bundle_digest`; a mismatch means the pull served
85///    different bytes than the deployer pinned, so the extraction is dropped and
86///    an [`OpError::Conflict`] is returned (fail-closed),
87/// 3. materialize `pack-config.v1` docs ([`super::pack_config_stage::materialize_pack_configs`]),
88/// 4. project + write `runtime-config.json` ([`Locked::refresh_runtime_config`]).
89///
90/// Idempotent: a re-run replaces the per-revision extraction. The revision must
91/// already exist in the env (staged by the deployer); this re-materializes its
92/// on-disk artifacts from the bundle, it does not create or mutate the revision
93/// record.
94///
95/// [`Revision`]: greentic_deploy_spec::Revision
96/// [`Locked::refresh_runtime_config`]: crate::environment::store::Locked::refresh_runtime_config
97pub fn materialize_revision_from_bundle(
98    store: &LocalFsStore,
99    env_id: &EnvId,
100    revision_id: RevisionId,
101    bundle_path: &Path,
102) -> Result<(), OpError> {
103    store.transact(env_id, |locked| {
104        let env = locked.load()?;
105        let revision = env
106            .revisions
107            .iter()
108            .find(|r| r.revision_id == revision_id)
109            .ok_or_else(|| {
110                OpError::NotFound(format!(
111                    "revision `{revision_id}` not found in env `{env_id}`"
112                ))
113            })?;
114        let bundle_id = env
115            .bundles
116            .iter()
117            .find(|b| b.deployment_id == revision.deployment_id)
118            .map(|b| &b.bundle_id)
119            .ok_or_else(|| {
120                OpError::NotFound(format!(
121                    "deployment `{}` for revision `{revision_id}` has no bundle in env `{env_id}`",
122                    revision.deployment_id
123                ))
124            })?;
125
126        let env_dir = store.env_dir(env_id)?;
127        let rev_dir = env_dir.join("revisions").join(revision_id.to_string());
128
129        // Move any existing extraction aside BEFORE re-staging, so a failed
130        // (re)materialization — a corrupt/wrong pull, a digest or lock-ref
131        // mismatch, a pack-config rejection, or a transient unpack error inside
132        // `stage_local_bundle` (which clears its own extract dir up front) —
133        // rolls back to the prior good state instead of bricking a revision the
134        // env still routes traffic to. The whole op holds the env flock, so the
135        // move/restore can't race another writer.
136        let backup_dir = env_dir.join("revisions").join(format!("{revision_id}.bak"));
137        let _ = std::fs::remove_dir_all(&backup_dir); // clear a stale backup from an interrupted run
138        let had_existing = rev_dir.exists();
139        if had_existing {
140            std::fs::rename(&rev_dir, &backup_dir).map_err(|source| OpError::Io {
141                path: rev_dir.clone(),
142                source,
143            })?;
144        }
145
146        // Materialize into the now-empty `rev_dir`, then project runtime-config.
147        // Any error rolls back; only an all-green run is promoted.
148        let outcome =
149            materialize_into_rev_dir(&env_dir, &rev_dir, env_id, bundle_id, bundle_path, revision)
150                .and_then(|()| locked.refresh_runtime_config(&env).map_err(OpError::from));
151
152        match outcome {
153            Ok(()) => {
154                // Promote: the fresh extraction passed every check; drop the
155                // saved copy.
156                let _ = std::fs::remove_dir_all(&backup_dir);
157                Ok(())
158            }
159            Err(err) => {
160                // Roll back: discard the partial new attempt and restore the
161                // prior good extraction so the worker can still serve.
162                let _ = std::fs::remove_dir_all(&rev_dir);
163                if had_existing {
164                    let _ = std::fs::rename(&backup_dir, &rev_dir);
165                }
166                Err(err)
167            }
168        }
169    })
170}
171
172/// Steps 1–3 of [`materialize_revision_from_bundle`], writing only under
173/// `rev_dir`: extract + pin packs, integrity-gate the staged bundle's digest
174/// and lock ref against what `revision` recorded, then materialize the
175/// pack-config docs. The caller owns the move-aside/rollback that keeps a prior
176/// good extraction recoverable when any step here fails.
177fn materialize_into_rev_dir(
178    env_dir: &Path,
179    rev_dir: &Path,
180    env_id: &EnvId,
181    bundle_id: &BundleId,
182    bundle_path: &Path,
183    revision: &Revision,
184) -> Result<(), OpError> {
185    let revision_id = revision.revision_id;
186
187    // 1. Extract + pin packs under `<env_dir>/revisions/<rev>/`.
188    let staged = stage_local_bundle(env_dir, revision_id, bundle_path)?;
189
190    // 2. Integrity gate: the bytes the worker pulled MUST match what the deployer
191    //    pinned on the revision. `stage_local_bundle` binds the digest to the
192    //    immutable staged copy, so this compares like for like. Fail closed — a
193    //    mismatch is a supply-chain signal, not a recoverable condition.
194    if staged.bundle_digest != revision.bundle_digest {
195        return Err(OpError::Conflict(format!(
196            "pulled bundle digest `{}` does not match revision `{revision_id}`'s pinned `{}`; \
197             refusing to serve unpinned bytes",
198            staged.bundle_digest, revision.bundle_digest
199        )));
200    }
201    // The lock ref is a pure function of the revision id, so re-staging the same
202    // revision must reproduce the ref the revision already records. A divergence
203    // means the two stage paths drifted — guard against it.
204    if staged.pack_list_lock_ref != revision.pack_list_lock_ref {
205        return Err(OpError::Conflict(format!(
206            "materialized pack-list lock ref `{}` diverges from revision `{revision_id}`'s `{}`",
207            staged.pack_list_lock_ref.display(),
208            revision.pack_list_lock_ref.display()
209        )));
210    }
211
212    // 3. Materialize pack-config docs from the extracted bundle inputs. The refs
213    //    are deterministic for a given (revision id, bundle), so they line up
214    //    with what the revision already records — re-derive the files rather than
215    //    trust the (absent) on-disk copies.
216    let pinned_pack_ids: HashSet<String> = staged
217        .lock
218        .packs
219        .iter()
220        .map(|p| p.pack_id.as_str().to_string())
221        .collect();
222    super::pack_config_stage::materialize_pack_configs(
223        env_dir,
224        rev_dir,
225        revision_id,
226        env_id,
227        bundle_id,
228        &pinned_pack_ids,
229    )?;
230    Ok(())
231}
232
233fn stage_into(
234    env_dir: &Path,
235    rev_dir: &Path,
236    revision_id: RevisionId,
237    bundle_path: &Path,
238) -> Result<StagedBundle, OpError> {
239    let extract_dir = rev_dir.join("bundle");
240
241    // Replace any partial/previous extraction so a re-stage is deterministic.
242    if extract_dir.exists() {
243        std::fs::remove_dir_all(&extract_dir).map_err(|source| OpError::Io {
244            path: extract_dir.clone(),
245            source,
246        })?;
247    }
248    std::fs::create_dir_all(&extract_dir).map_err(|source| OpError::Io {
249        path: extract_dir.clone(),
250        source,
251    })?;
252
253    // Bind the digest to the exact bytes we extract: copy the input into the
254    // revision dir first, then hash AND unpack that immutable staged copy.
255    // Hashing the caller's path and separately re-opening it for extraction
256    // would let a swap between the two operations record one artifact's digest
257    // while pinning another's packs. The staged copy lives under the env flock
258    // (held by the caller) at a freshly-minted revision path, so nothing
259    // rewrites it out from under us.
260    let staged_bundle = rev_dir.join("bundle.gtbundle");
261    std::fs::copy(bundle_path, &staged_bundle).map_err(|source| OpError::Io {
262        path: staged_bundle.clone(),
263        source,
264    })?;
265    let bundle_digest = sha256_file(&staged_bundle).map_err(|source| OpError::Io {
266        path: staged_bundle.clone(),
267        source,
268    })?;
269
270    // Hardened SquashFS unpack of the staged copy (path-traversal +
271    // symlink-escape guards live in greentic-bundle, not duplicated here).
272    greentic_bundle::build::unbundle_artifact(&staged_bundle, &extract_dir).map_err(|err| {
273        OpError::InvalidArgument(format!(
274            "extract bundle `{}`: {err:#}",
275            bundle_path.display()
276        ))
277    })?;
278
279    // A `.gtbundle` always carries a bundle manifest; its absence means the
280    // input was not a Greentic bundle.
281    if !extract_dir.join("bundle-manifest.json").is_file() {
282        return Err(OpError::InvalidArgument(format!(
283            "`{}` is not a .gtbundle: extracted tree has no bundle-manifest.json",
284            bundle_path.display()
285        )));
286    }
287
288    // Pin the embedded `.gtpack`s. Scope the scan to the canonical `packs/`
289    // subtree so a stray `.gtpack` elsewhere in the bundle (e.g. under
290    // `resolved/`) can't silently join the runtime pack set. Each pack's
291    // load-time identity is its content digest + path, re-verified by the
292    // runner host; cross-checking the embedded pack manifest's id/version
293    // against the bundle lock is deferred (needs a `.gtpack` reader) and is
294    // belt-and-suspenders on top of the digest binding + bundle-level DSSE.
295    let packs_dir = extract_dir.join("packs");
296    if !packs_dir.is_dir() {
297        return Err(OpError::InvalidArgument(format!(
298            "bundle `{}` has no packs/ directory",
299            bundle_path.display()
300        )));
301    }
302    let mut gtpacks = Vec::new();
303    collect_gtpacks(&packs_dir, &mut gtpacks).map_err(|source| OpError::Io {
304        path: packs_dir.clone(),
305        source,
306    })?;
307    if gtpacks.is_empty() {
308        return Err(OpError::InvalidArgument(format!(
309            "bundle `{}` contains no .gtpack artifacts under packs/",
310            bundle_path.display()
311        )));
312    }
313
314    let mut packs = Vec::with_capacity(gtpacks.len());
315    for path in gtpacks {
316        let digest = sha256_file(&path).map_err(|source| OpError::Io {
317            path: path.clone(),
318            source,
319        })?;
320        let rel = path
321            .strip_prefix(env_dir)
322            .map_err(|_| {
323                OpError::InvalidArgument(format!(
324                    "extracted pack `{}` escaped the env directory",
325                    path.display()
326                ))
327            })?
328            .to_path_buf();
329        let pack_id = path
330            .file_stem()
331            .and_then(|s| s.to_str())
332            .map(PackId::new)
333            .ok_or_else(|| {
334                OpError::InvalidArgument(format!("pack `{}` has no file stem", path.display()))
335            })?;
336        packs.push(LockedPack {
337            pack_id,
338            path: rel,
339            digest,
340        });
341    }
342    // Stable, path-sorted order so the lock (and its digest) is deterministic.
343    packs.sort_by(|a, b| a.path.cmp(&b.path));
344
345    let lock = PackListLock {
346        schema: SchemaVersion::new(SchemaVersion::PACK_LIST_LOCK_V1),
347        revision_id,
348        packs,
349    };
350    let lock_path = rev_dir.join("pack-list.lock");
351    atomic_write_json(&lock_path, &lock)
352        .map_err(|e| OpError::Store(crate::environment::store::StoreError::from(e)))?;
353    let pack_list_lock_ref = lock_path
354        .strip_prefix(env_dir)
355        .map_err(|_| {
356            OpError::InvalidArgument(format!(
357                "pack-list.lock `{}` escaped the env directory",
358                lock_path.display()
359            ))
360        })?
361        .to_path_buf();
362
363    Ok(StagedBundle {
364        bundle_digest,
365        pack_list_lock_ref,
366        lock,
367    })
368}
369
370/// Streaming SHA-256 of a file, returned as `sha256:<lowercase-hex>`. Streams
371/// through a fixed buffer rather than loading the whole artifact into memory,
372/// so a large bundle or pack can't blow up peak RSS. Shared with
373/// `cli::env_apply` (manifest-vs-live digest diffing).
374pub(crate) fn sha256_file(path: &Path) -> std::io::Result<String> {
375    use std::io::Read;
376    let mut file = std::fs::File::open(path)?;
377    let mut hasher = Sha256::new();
378    let mut buf = [0u8; 64 * 1024];
379    loop {
380        let n = file.read(&mut buf)?;
381        if n == 0 {
382            break;
383        }
384        hasher.update(&buf[..n]);
385    }
386    Ok(format!("sha256:{}", hex::encode(hasher.finalize())))
387}
388
389/// Recursively collect `*.gtpack` files under `dir`. Uses `file_type()` (which
390/// does not follow symlinks), so a symlinked directory is skipped rather than
391/// traversed — extraction has already rejected escaping symlinks, and we never
392/// want to walk outside the extract tree.
393fn collect_gtpacks(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
394    for entry in std::fs::read_dir(dir)? {
395        let entry = entry?;
396        let file_type = entry.file_type()?;
397        let path = entry.path();
398        if file_type.is_dir() {
399            collect_gtpacks(&path, out)?;
400        } else if file_type.is_file() && path.extension().and_then(|e| e.to_str()) == Some("gtpack")
401        {
402            out.push(path);
403        }
404    }
405    Ok(())
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use tempfile::tempdir;
412
413    /// `collect_gtpacks` recurses into nested dirs and only matches `.gtpack`,
414    /// ignoring other files. `stage_local_bundle` hands it the `packs/` subtree,
415    /// so a `.gtpack` placed outside `packs/` is never pinned.
416    #[test]
417    fn collect_gtpacks_recurses_and_filters_by_extension() {
418        let dir = tempdir().unwrap();
419        let root = dir.path();
420
421        // Canonical layout under packs/.
422        let dist = root.join("packs/alpha/dist");
423        std::fs::create_dir_all(&dist).unwrap();
424        std::fs::write(dist.join("alpha.gtpack"), b"PK\x03\x04").unwrap();
425        std::fs::write(dist.join("readme.txt"), b"not a pack").unwrap();
426
427        // A stray .gtpack OUTSIDE packs/ — must be excluded when we scan packs/.
428        std::fs::write(root.join("stray.gtpack"), b"PK\x03\x04").unwrap();
429
430        let mut found = Vec::new();
431        collect_gtpacks(&root.join("packs"), &mut found).unwrap();
432
433        assert_eq!(found.len(), 1, "only the packs/ .gtpack, got {found:?}");
434        assert!(found[0].ends_with("alpha/dist/alpha.gtpack"));
435    }
436}
437
438#[cfg(test)]
439mod materialize_tests {
440    use super::*;
441    use crate::cli::tests_common::{
442        make_bundle_deployment, make_env, make_revision, make_traffic_split,
443    };
444    use crate::environment::mint_revision_id;
445    use crate::environment::store::EnvironmentStore;
446    use greentic_deploy_spec::{PackListEntry, RevisionLifecycle};
447    use tempfile::tempdir;
448
449    fn fixture_bundle() -> PathBuf {
450        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
451            .join("testdata/bundles/perf-smoke-bundle.gtbundle")
452    }
453
454    /// Repin a revision's `bundle_digest` to a value the real fixture cannot
455    /// reproduce (all-zero sha256), under the env flock, so the next
456    /// materialization fails the integrity gate.
457    fn repin_to_unmatchable_digest(store: &LocalFsStore, env_id: &EnvId, revision_id: RevisionId) {
458        store
459            .transact(env_id, |locked| {
460                let mut env = locked.load()?;
461                env.revisions
462                    .iter_mut()
463                    .find(|r| r.revision_id == revision_id)
464                    .unwrap()
465                    .bundle_digest =
466                    "sha256:0000000000000000000000000000000000000000000000000000000000000000"
467                        .to_string();
468                locked.save(&env)
469            })
470            .unwrap();
471    }
472
473    /// Build an env with a deployment and a revision staged from the fixture
474    /// bundle (the deployer side), so `environment.json` records the real digest
475    /// and lock ref a remote worker would receive, then return the ids and env
476    /// dir. When `route` is set, a 100% traffic split points at the revision so
477    /// `runtime-config.json` is materializable.
478    fn seed_staged_env(store: &LocalFsStore, route: bool) -> (EnvId, RevisionId, PathBuf) {
479        let env_id = EnvId::try_from("local").unwrap();
480        let mut env = make_env("local");
481        let deployment = make_bundle_deployment("local", "fast2flow");
482        let did = deployment.deployment_id;
483        env.bundles.push(deployment);
484
485        let revision_id = mint_revision_id();
486        let env_dir = store.env_dir(&env_id).unwrap();
487        let staged = stage_local_bundle(&env_dir, revision_id, &fixture_bundle()).unwrap();
488
489        let mut revision = make_revision("local", "fast2flow", &did, 1, RevisionLifecycle::Ready);
490        revision.revision_id = revision_id;
491        revision.bundle_digest = staged.bundle_digest.clone();
492        revision.pack_list_lock_ref = staged.pack_list_lock_ref.clone();
493        revision.bundle_source_uri =
494            Some("oci://example.test/bundles/fast2flow@sha256:abc".to_string());
495        revision.pack_list = staged
496            .lock
497            .packs
498            .iter()
499            .map(|p| PackListEntry::from_lock_primitives(p.pack_id.clone(), p.digest.clone()))
500            .collect();
501        env.revisions.push(revision);
502        if route {
503            env.traffic_splits.push(make_traffic_split(
504                "local",
505                "fast2flow",
506                &did,
507                &revision_id,
508                "k1",
509            ));
510        }
511        store.save(&env).unwrap();
512        (env_id, revision_id, env_dir)
513    }
514
515    /// Happy path: a worker with only `environment.json` (pack bytes wiped)
516    /// re-materializes the revision from the pulled bundle — `pack-list.lock`,
517    /// the `.gtpack` files, and `runtime-config.json` all reappear, and the pack
518    /// digests still match the lock.
519    #[test]
520    fn materializes_packs_lock_and_runtime_config_from_bundle() {
521        let dir = tempdir().unwrap();
522        let store = LocalFsStore::new(dir.path());
523        let (env_id, revision_id, env_dir) = seed_staged_env(&store, true);
524        let rev_dir = env_dir.join("revisions").join(revision_id.to_string());
525
526        // Simulate the worker: only environment.json shipped — wipe the staged
527        // pack bytes + any runtime-config the deployer side may have written.
528        std::fs::remove_dir_all(&rev_dir).unwrap();
529        let runtime_config = env_dir.join("runtime-config.json");
530        let _ = std::fs::remove_file(&runtime_config);
531        assert!(!rev_dir.exists());
532
533        materialize_revision_from_bundle(&store, &env_id, revision_id, &fixture_bundle()).unwrap();
534
535        let lock_path = rev_dir.join("pack-list.lock");
536        assert!(lock_path.is_file(), "pack-list.lock must be restored");
537        let lock: PackListLock =
538            serde_json::from_slice(&std::fs::read(&lock_path).unwrap()).unwrap();
539        assert_eq!(lock.revision_id, revision_id);
540        assert!(!lock.packs.is_empty(), "fixture bundle has a .gtpack");
541        for pack in &lock.packs {
542            let pack_path = env_dir.join(&pack.path);
543            assert!(
544                pack_path.is_file(),
545                "extracted pack must exist: {}",
546                pack_path.display()
547            );
548            assert_eq!(
549                pack.digest,
550                sha256_file(&pack_path).unwrap(),
551                "pack digest must match the lock"
552            );
553        }
554        assert!(
555            runtime_config.is_file(),
556            "runtime-config.json must be written when a split routes the revision"
557        );
558    }
559
560    /// Integrity gate: when the pulled bundle's digest does not match the
561    /// revision's pinned `bundle_digest`, the call fails closed and leaves no
562    /// partial extraction behind.
563    #[test]
564    fn rejects_bundle_whose_digest_does_not_match_the_pin() {
565        let dir = tempdir().unwrap();
566        let store = LocalFsStore::new(dir.path());
567        let (env_id, revision_id, env_dir) = seed_staged_env(&store, true);
568        let rev_dir = env_dir.join("revisions").join(revision_id.to_string());
569
570        // Repin the revision to a digest the real fixture cannot reproduce.
571        repin_to_unmatchable_digest(&store, &env_id, revision_id);
572        std::fs::remove_dir_all(&rev_dir).unwrap();
573
574        let err = materialize_revision_from_bundle(&store, &env_id, revision_id, &fixture_bundle())
575            .unwrap_err();
576        assert!(
577            matches!(err, OpError::Conflict(_)),
578            "digest mismatch must be a Conflict, got: {err}"
579        );
580        assert!(format!("{err}").contains("does not match"));
581        assert!(
582            !rev_dir.exists(),
583            "a rejected materialization must not leave a partial extraction"
584        );
585    }
586
587    /// Failure atomicity: when a revision dir is already materialized and a
588    /// second materialization fails the integrity gate (a bad re-pull), the
589    /// prior good extraction must survive intact and no backup may leak — a
590    /// transient bad pull must not brick a revision the env still routes to.
591    #[test]
592    fn preserves_existing_revision_dir_when_re_materialization_fails() {
593        let dir = tempdir().unwrap();
594        let store = LocalFsStore::new(dir.path());
595        let (env_id, revision_id, env_dir) = seed_staged_env(&store, true);
596        let rev_dir = env_dir.join("revisions").join(revision_id.to_string());
597        let lock_path = rev_dir.join("pack-list.lock");
598
599        // The seed already materialized a good rev_dir; capture the lock bytes so
600        // we can prove they survive a failed re-materialization.
601        assert!(
602            lock_path.is_file(),
603            "seed must leave a materialized rev dir"
604        );
605        let good_lock = std::fs::read(&lock_path).unwrap();
606
607        // Repin the revision to a digest the fixture cannot reproduce, so the
608        // next materialize fails the integrity gate AFTER moving the good dir
609        // aside.
610        repin_to_unmatchable_digest(&store, &env_id, revision_id);
611
612        let err = materialize_revision_from_bundle(&store, &env_id, revision_id, &fixture_bundle())
613            .unwrap_err();
614        assert!(matches!(err, OpError::Conflict(_)), "got: {err}");
615
616        // The prior good extraction survives intact, and the backup is cleaned up.
617        assert!(
618            lock_path.is_file(),
619            "existing pack-list.lock must survive a failed re-materialize"
620        );
621        assert_eq!(
622            std::fs::read(&lock_path).unwrap(),
623            good_lock,
624            "the restored lock must be byte-identical to the original"
625        );
626        assert!(
627            !env_dir
628                .join("revisions")
629                .join(format!("{revision_id}.bak"))
630                .exists(),
631            "the rollback backup must not leak"
632        );
633    }
634
635    /// A revision id absent from the env is a NotFound, not a panic.
636    #[test]
637    fn missing_revision_is_not_found() {
638        let dir = tempdir().unwrap();
639        let store = LocalFsStore::new(dir.path());
640        let (env_id, _rid, _env_dir) = seed_staged_env(&store, false);
641        let bogus = mint_revision_id();
642        let err = materialize_revision_from_bundle(&store, &env_id, bogus, &fixture_bundle())
643            .unwrap_err();
644        assert!(matches!(err, OpError::NotFound(_)), "got: {err}");
645    }
646}