1use std::path::Path;
14
15use crate::install::VerifyError;
16use crate::manifest::LayerManifest;
17use crate::reverify::ENVELOPE_FILE;
18use crate::source::{LayerRef, LayerSource, SourceError};
19use crate::store::{InstalledLayer, Store, StoreError, manifest_digest};
20
21pub const SIGNATURE_ARTIFACT_TYPE: &str = "application/vnd.pulseengine.varve.signature.v1+json";
23pub const ANN_SIGNS: &str = "eu.pulseengine.varve.signs";
25
26pub const REF_NAME: &str = "org.opencontainers.image.ref.name";
31
32pub const ANN_ARCHIVED_FOR: &str = "eu.pulseengine.varve.archived-for";
39
40#[derive(Debug, thiserror::Error)]
43pub(crate) enum LayoutWriteError {
44 #[error("io error at {path}")]
45 Io {
46 path: String,
47 #[source]
48 source: std::io::Error,
49 },
50 #[error(transparent)]
51 WouldDestroy(#[from] crate::referrers::WouldDestroy),
52}
53
54impl From<LayoutWriteError> for ArchiveError {
55 fn from(e: LayoutWriteError) -> Self {
56 match e {
57 LayoutWriteError::Io { path, source } => ArchiveError::Io { path, source },
58 LayoutWriteError::WouldDestroy(d) => ArchiveError::WouldDestroy(d),
59 }
60 }
61}
62
63#[derive(Debug, thiserror::Error)]
64pub enum ArchiveError {
65 #[error("the layer's baseline line-status could not be carried into the archive: {0}")]
66 LineStatus(String),
67 #[error(transparent)]
70 WouldDestroy(#[from] crate::referrers::WouldDestroy),
71 #[error("io error at {path}")]
72 Io {
73 path: String,
74 #[source]
75 source: std::io::Error,
76 },
77 #[error(
78 "layer {digest} has no retained signature envelope — an archive without its signature \
79 cannot serve as the artifact of record; reinstall from a signed source first"
80 )]
81 NoEnvelope { digest: String },
82 #[error(
83 "payload '{payload}' ({digest}) is named by the layer manifest for platform {platform} but \
84 is not present in the installed layer — an archive missing a payload is not the artifact \
85 of record; reinstall the layer first"
86 )]
87 MissingPayload {
88 payload: String,
89 digest: String,
90 platform: String,
91 },
92 #[error(
93 "payload '{payload}' at {path} hashes to {found}, but the signed manifest names {signed} \
94 for it — refusing to write a blob under a digest its bytes do not have. Either this core \
95 was altered since install, or it was installed for a platform other than {platform} \
96 (`varve install --platform`), in which case archive it with the matching \
97 `varve archive --platform`."
98 )]
99 PayloadDigestMismatch {
100 payload: String,
101 path: String,
102 signed: String,
103 found: String,
104 platform: String,
105 },
106 #[error(
107 "layer {layer} has no payload for platform {platform} in this core — all {omitted} of its \
108 payload entries name other platforms ({platforms}). An empty archive is not the artifact \
109 of record and would only fail on the far side of the gap: install the layer for \
110 {platform}, or archive with the platform this core was installed for."
111 )]
112 NoPayloadForPlatform {
113 layer: String,
114 platform: String,
115 omitted: usize,
116 platforms: String,
117 },
118 #[error("layer.json in the core is not a valid manifest: {0}")]
119 Manifest(#[from] crate::manifest::ManifestError),
120 #[error(transparent)]
121 Carry(#[from] crate::attestcarry::CarryError),
122 #[error(transparent)]
123 Store(#[from] StoreError),
124 #[error(transparent)]
125 Verify(#[from] VerifyError),
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct ExportSummary {
137 pub baseline_missing: bool,
141 pub platform: String,
143 pub archived: usize,
145 pub omitted: std::collections::BTreeMap<String, usize>,
149}
150
151fn named(entry: &crate::manifest::ManifestEntry, name: &str) -> String {
154 match crate::store::entry_version(entry) {
155 Some(version) => format!("{name}@{version}"),
156 None => name.to_string(),
157 }
158}
159
160pub fn export(
165 store: &Store,
166 layer: &InstalledLayer,
167 dest: &Path,
168 platform: &str,
169) -> Result<ExportSummary, ArchiveError> {
170 export_with_options(store, layer, dest, platform, &ArchiveOptions::default())
171}
172
173#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
176pub struct ArchiveOptions {
177 pub force: bool,
179 pub allow_no_status: bool,
182}
183
184pub fn export_with_options(
186 store: &Store,
187 layer: &InstalledLayer,
188 dest: &Path,
189 platform: &str,
190 options: &ArchiveOptions,
191) -> Result<ExportSummary, ArchiveError> {
192 let io = |path: &Path, source: std::io::Error| ArchiveError::Io {
193 path: path.display().to_string(),
194 source,
195 };
196
197 let manifest_path = layer.root.join("layer.json");
199 let payload = std::fs::read(&manifest_path).map_err(|e| io(&manifest_path, e))?;
200 let envelope_path = layer.root.join(ENVELOPE_FILE);
201 let envelope = match std::fs::read(&envelope_path) {
202 Ok(bytes) => bytes,
203 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
204 return Err(ArchiveError::NoEnvelope {
205 digest: layer.digest.clone(),
206 });
207 }
208 Err(e) => return Err(io(&envelope_path, e)),
209 };
210 let manifest = LayerManifest::parse(&payload)?;
211 let mut blobs: Vec<(String, Vec<u8>)> = Vec::new();
212 let mut omitted: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
213 for entry in &manifest.entries {
214 if entry.kind() == Ok(crate::kind::PayloadKind::Layer) {
217 continue;
218 }
219 let Some(name) = entry.annotations.get("eu.pulseengine.tool") else {
220 continue;
221 };
222 let entry_platform = entry
230 .annotations
231 .get(crate::platform::ANN_PLATFORM)
232 .map(String::as_str);
233 if let Some(other) =
234 entry_platform.filter(|p| !crate::platform::entry_matches(Some(p), platform))
235 {
236 *omitted.entry(other.to_string()).or_default() += 1;
237 continue;
238 }
239 let path = store
245 .entry_path(layer, entry)
246 .ok_or_else(|| ArchiveError::MissingPayload {
247 payload: named(entry, name),
248 digest: entry.digest.clone(),
249 platform: platform.to_string(),
250 })?;
251 let bytes = std::fs::read(&path).map_err(|e| io(&path, e))?;
252 let found = manifest_digest(&bytes);
260 if found != entry.digest {
261 return Err(ArchiveError::PayloadDigestMismatch {
262 payload: named(entry, name),
263 path: path.display().to_string(),
264 signed: entry.digest.clone(),
265 found,
266 platform: platform.to_string(),
267 });
268 }
269 blobs.push((entry.digest.clone(), bytes));
270 }
271 if blobs.is_empty() && !omitted.is_empty() {
278 return Err(ArchiveError::NoPayloadForPlatform {
279 layer: manifest.layer.to_string(),
280 platform: platform.to_string(),
281 omitted: omitted.values().sum(),
282 platforms: omitted.keys().cloned().collect::<Vec<_>>().join(", "),
283 });
284 }
285 let carried = crate::attestcarry::read_persisted(&layer.root, &manifest.layer.to_string())?;
294
295 write_oci_layout(
296 &payload,
297 &envelope,
298 &blobs,
299 &manifest.layer.to_string(),
300 &manifest.channel,
301 Some(platform),
302 dest,
303 options.force,
304 )
305 .map_err(ArchiveError::from)?;
306 for c in &carried {
307 crate::attestcarry::attach(dest, &c.statement, &c.bytes)?;
308 }
309 let mut baseline_missing = false;
327 let cache = crate::linestatus::StatusCache::at_root(store.root());
328 match cache
329 .envelope_bytes(layer.layer.line())
330 .map_err(|e| ArchiveError::LineStatus(e.to_string()))?
331 {
332 Some(envelope) => {
333 crate::linestatus::attach_to_layout(dest, layer.layer.line(), &envelope)
334 .map_err(|e| ArchiveError::LineStatus(e.to_string()))?;
335 }
336 None => baseline_missing = !options.allow_no_status,
354 }
355 Ok(ExportSummary {
356 baseline_missing,
357 platform: platform.to_string(),
358 archived: blobs.len(),
359 omitted,
360 })
361}
362
363#[allow(clippy::too_many_arguments)]
378pub(crate) fn write_oci_layout(
379 payload: &[u8],
380 envelope: &[u8],
381 blobs: &[(String, Vec<u8>)],
382 layer_name: &str,
383 channel: &str,
384 platform: Option<&str>,
385 dest: &Path,
386 force: bool,
387) -> Result<(), LayoutWriteError> {
388 crate::referrers::guard(dest, force)?;
392 let io = |path: &Path, source: std::io::Error| LayoutWriteError::Io {
393 path: path.display().to_string(),
394 source,
395 };
396 let payload_digest = manifest_digest(payload);
397 let envelope_digest = manifest_digest(envelope);
398
399 let blob_dir = dest.join("blobs").join("sha256");
400 std::fs::create_dir_all(&blob_dir).map_err(|e| io(&blob_dir, e))?;
401 let write_blob = |digest: &str, bytes: &[u8]| -> Result<(), LayoutWriteError> {
402 let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
403 let path = blob_dir.join(hex);
404 std::fs::write(&path, bytes).map_err(|e| io(&path, e))
405 };
406 write_blob(&payload_digest, payload)?;
407 write_blob(&envelope_digest, envelope)?;
408 for (digest, bytes) in blobs {
409 write_blob(digest, bytes)?;
410 }
411
412 let marker_path = dest.join("oci-layout");
413 std::fs::write(&marker_path, br#"{"imageLayoutVersion":"1.0.0"}"#)
414 .map_err(|e| io(&marker_path, e))?;
415
416 let mut index = serde_json::json!({
417 "schemaVersion": 2,
418 "mediaType": "application/vnd.oci.image.index.v1+json",
419 "manifests": [
420 {
421 "mediaType": "application/vnd.oci.image.index.v1+json",
422 "digest": payload_digest,
423 "size": payload.len(),
424 "annotations": {
425 REF_NAME: layer_name,
429 "eu.pulseengine.varve.layer": layer_name,
430 "eu.pulseengine.varve.channel": channel,
431 }
432 },
433 {
434 "mediaType": "application/json",
435 "artifactType": SIGNATURE_ARTIFACT_TYPE,
436 "digest": envelope_digest,
437 "size": envelope.len(),
438 "annotations": { ANN_SIGNS: payload_digest }
439 }
440 ]
441 });
442 if let Some(platform) = platform {
445 index["manifests"][0]["annotations"][ANN_ARCHIVED_FOR] =
446 serde_json::Value::String(platform.to_string());
447 }
448 let index_path = dest.join("index.json");
449 std::fs::write(
450 &index_path,
451 serde_json::to_vec_pretty(&index).expect("index serializes"),
452 )
453 .map_err(|e| io(&index_path, e))?;
454 Ok(())
455}
456
457#[derive(Debug)]
460pub struct OciLayoutSource {
461 root: std::path::PathBuf,
462 platform: Option<String>,
465}
466
467impl OciLayoutSource {
468 pub fn at(root: impl Into<std::path::PathBuf>) -> Self {
469 OciLayoutSource {
470 root: root.into(),
471 platform: None,
472 }
473 }
474
475 pub fn for_platform(mut self, platform: impl Into<String>) -> Self {
478 self.platform = Some(platform.into());
479 self
480 }
481
482 fn blob_path(&self, digest: &str) -> std::path::PathBuf {
483 let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
484 self.root.join("blobs").join("sha256").join(hex)
485 }
486
487 fn archived_for(&self) -> Option<String> {
491 let index: serde_json::Value =
492 serde_json::from_slice(&std::fs::read(self.root.join("index.json")).ok()?).ok()?;
493 index["manifests"]
494 .as_array()?
495 .iter()
496 .find_map(|e| e["annotations"][ANN_ARCHIVED_FOR].as_str())
497 .map(str::to_string)
498 }
499
500 fn absent(&self, digest: &str) -> SourceError {
505 let wanted = self
506 .platform
507 .clone()
508 .unwrap_or_else(crate::platform::host_platform);
509 match self.archived_for() {
510 Some(archived_for) if archived_for != wanted => SourceError::NoPayloadForPlatform {
511 digest: digest.to_string(),
512 wanted,
513 archived_for,
514 },
515 _ => SourceError::NotFound(digest.to_string()),
516 }
517 }
518}
519
520impl LayerSource for OciLayoutSource {
521 fn fetch_manifest(&self, layer: &LayerRef) -> Result<Vec<u8>, SourceError> {
522 let index_path = self.root.join("index.json");
526 let index: serde_json::Value = serde_json::from_slice(
527 &std::fs::read(&index_path)
528 .map_err(|e| SourceError::Transport(format!("{}: {e}", index_path.display())))?,
529 )
530 .map_err(|e| SourceError::Transport(format!("index.json: {e}")))?;
531 let entries = index["manifests"].as_array().cloned().unwrap_or_default();
532
533 let wanted: Vec<String> = entries
535 .iter()
536 .filter(|e| e["artifactType"] != SIGNATURE_ARTIFACT_TYPE)
537 .filter_map(|e| {
538 let digest = e["digest"].as_str()?.to_string();
539 match layer {
540 LayerRef::Digest(d) => (&digest == d).then_some(digest),
541 LayerRef::Name(id) => {
542 let name = e["annotations"]["eu.pulseengine.varve.layer"].as_str()?;
543 (name == id.to_string()).then_some(digest)
544 }
545 }
546 })
547 .collect();
548
549 for digest in wanted {
550 let envelope = entries.iter().find(|e| {
553 e["artifactType"] == SIGNATURE_ARTIFACT_TYPE
554 && e["annotations"][ANN_SIGNS] == *digest
555 });
556 let blob_digest = envelope
557 .and_then(|e| e["digest"].as_str())
558 .map(str::to_string)
559 .unwrap_or_else(|| digest.clone());
560 match std::fs::read(self.blob_path(&blob_digest)) {
561 Ok(bytes) => return Ok(bytes),
562 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
563 Err(e) => return Err(SourceError::Transport(e.to_string())),
564 }
565 }
566 Err(SourceError::NotFound(format!("{layer:?}")))
567 }
568
569 fn fetch_blob(&self, digest: &str) -> Result<Vec<u8>, SourceError> {
570 match std::fs::read(self.blob_path(digest)) {
571 Ok(bytes) => Ok(bytes),
572 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(self.absent(digest)),
573 Err(e) => Err(SourceError::Transport(e.to_string())),
574 }
575 }
576
577 fn fetch_line_status(&self, _layer: &LayerRef) -> Result<Option<Vec<u8>>, SourceError> {
578 crate::linestatus::read_any_from_layout(&self.root)
581 .map_err(|e| SourceError::Transport(e.to_string()))
582 }
583
584 fn fetch_line_index(&self, line: &str) -> Result<Option<Vec<u8>>, SourceError> {
585 crate::lineindex::read_from_layout(&self.root, line)
590 .map_err(|e| SourceError::Transport(e.to_string()))
591 }
592
593 fn fetch_attestations(
603 &self,
604 layer: &LayerRef,
605 ) -> Result<Vec<crate::attestcarry::CarriedAttestation>, SourceError> {
606 let name = match layer {
611 LayerRef::Name(id) => id.to_string(),
612 LayerRef::Digest(d) => d.clone(),
613 };
614 crate::attestcarry::read_all(&self.root, &name)
615 .map_err(|e| SourceError::Transport(e.to_string()))
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622 use crate::install::{InstallPolicy, install};
623 use crate::manifest::fixtures::manifest_with_tools;
624 use crate::pin::Pin;
625 use crate::rollback::HighWaterMarks;
626 use crate::source::MemorySource;
627 use crate::verify::{PinnedKeyVerifier, generate_root_keypair, sign_layer_manifest};
628
629 fn pin(layer: &str) -> Pin {
630 Pin::parse(
631 &format!(
632 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"{layer}\"\n"
633 ),
634 "varve.toml",
635 )
636 .unwrap()
637 }
638
639 fn policy() -> InstallPolicy<'static> {
640 InstallPolicy {
641 index: None,
642 now: "2026-08-07T00:00:00Z",
643 staleness_threshold_days: 90,
644 platform: "test-platform",
645 }
646 }
647
648 fn installed() -> (
651 tempfile::TempDir,
652 Store,
653 InstalledLayer,
654 PinnedKeyVerifier,
655 Vec<u8>,
656 ) {
657 let (sk, pk) = generate_root_keypair();
658 let tool = b"synth-bytes".to_vec();
659 let blob_digest = manifest_digest(&tool);
660 let payload = manifest_with_tools(
661 "2026.07.0",
662 "qualified",
663 1,
664 "2026-07-31T09:14:00Z",
665 &[("synth", &blob_digest)],
666 );
667 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
668 let source = MemorySource::new()
669 .with_manifest(envelope.as_bytes())
670 .with_blob(&blob_digest, &tool);
671 let tmp = tempfile::tempdir().unwrap();
672 let root = tmp.path().join("root");
673 let store = Store::at(&root);
674 let mut marks = HighWaterMarks::load(&root).unwrap();
675 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
676 let outcome = install(
677 &pin("2026.07.0"),
678 &source,
679 &verifier,
680 &store,
681 &mut marks,
682 &policy(),
683 )
684 .unwrap();
685 let layer = store.get(&outcome.digest).unwrap().unwrap();
686 (tmp, store, layer, verifier, payload)
687 }
688
689 fn blobs_whose_name_lies(dest: &Path) -> Vec<(String, String)> {
694 let mut bad = Vec::new();
695 for e in std::fs::read_dir(dest.join("blobs/sha256"))
696 .unwrap()
697 .filter_map(|e| e.ok())
698 {
699 let name = e.file_name().to_string_lossy().to_string();
700 let actual = manifest_digest(&std::fs::read(e.path()).unwrap());
701 if actual != format!("sha256:{name}") {
702 bad.push((name, actual));
703 }
704 }
705 bad
706 }
707
708 #[allow(clippy::type_complexity)]
713 fn installed_multi_platform() -> (
714 tempfile::TempDir,
715 Store,
716 InstalledLayer,
717 PinnedKeyVerifier,
718 Vec<u8>,
719 Vec<u8>,
720 Vec<u8>,
721 ) {
722 use crate::manifest::fixtures::manifest_with_platform_tools;
723 let (sk, pk) = generate_root_keypair();
724 let kilnd_a = b"kilnd-built-for-platform-a".to_vec();
725 let kilnd_b = b"kilnd-built-for-platform-b".to_vec();
726 let anyplat = b"a-payload-with-no-platform-claim".to_vec();
727 let (da, db, dn) = (
728 manifest_digest(&kilnd_a),
729 manifest_digest(&kilnd_b),
730 manifest_digest(&anyplat),
731 );
732 let payload = manifest_with_platform_tools(
733 "2026.07.0",
734 "qualified",
735 1,
736 "2026-07-31T09:14:00Z",
737 &[
738 ("kilnd", &da, Some("platform-a")),
739 ("kilnd", &db, Some("platform-b")),
740 ("charter", &dn, None),
741 ],
742 );
743 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
744 let source = MemorySource::new()
746 .with_manifest(envelope.as_bytes())
747 .with_blob(&da, &kilnd_a)
748 .with_blob(&db, &kilnd_b)
749 .with_blob(&dn, &anyplat);
750 let tmp = tempfile::tempdir().unwrap();
751 let root = tmp.path().join("root");
752 let store = Store::at(&root);
753 let mut marks = HighWaterMarks::load(&root).unwrap();
754 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
755 let policy = InstallPolicy {
756 platform: "platform-a",
757 ..policy()
758 };
759 let outcome = install(
760 &pin("2026.07.0"),
761 &source,
762 &verifier,
763 &store,
764 &mut marks,
765 &policy,
766 )
767 .unwrap();
768 let layer = store.get(&outcome.digest).unwrap().unwrap();
769 assert_eq!(
772 std::fs::read(layer.root.join("bin/kilnd")).unwrap(),
773 kilnd_a
774 );
775 (tmp, store, layer, verifier, kilnd_a, kilnd_b, anyplat)
776 }
777
778 #[test]
780 fn archiving_over_a_layout_that_carries_evidence_is_refused_by_the_same_guard() {
781 let (tmp, store, layer, _v, _payload) = installed();
790 let dest = tmp.path().join("archive");
791 export(&store, &layer, &dest, "test-platform").unwrap();
792
793 let (sk, _pk) = generate_root_keypair();
795 let status = crate::linestatus::LineStatus {
796 min_counter: None,
797 line: "2026.07".into(),
798 counter: 1,
799 issued_at: "2026-08-07T00:00:00Z".into(),
800 support_until: None,
801 yanked: Default::default(),
802 known_problems: Vec::new(),
803 };
804 crate::linestatus::attach_to_layout(
805 &dest,
806 &"2026.07".parse().unwrap(),
807 status.sign(&sk, "k").unwrap().as_bytes(),
808 )
809 .unwrap();
810 let before = std::fs::read(dest.join("index.json")).unwrap();
811
812 let err = export(&store, &layer, &dest, "test-platform")
813 .expect_err("re-archiving over attached evidence must be refused");
814 assert!(
815 matches!(&err, ArchiveError::WouldDestroy(_)),
816 "got: {err:?}"
817 );
818 assert!(err.to_string().contains("line-status"), "{err}");
819 assert_eq!(
820 before,
821 std::fs::read(dest.join("index.json")).unwrap(),
822 "a refused archive must leave the layout byte-identical"
823 );
824
825 export_with_options(
827 &store,
828 &layer,
829 &dest,
830 "test-platform",
831 &ArchiveOptions {
832 force: true,
833 allow_no_status: true,
834 },
835 )
836 .expect("--force overrides the guard");
837 }
838
839 #[test]
841 fn a_multi_platform_layer_archives_only_this_platforms_payloads() {
842 let (tmp, store, layer, _v, kilnd_a, kilnd_b, anyplat) = installed_multi_platform();
850 let dest = tmp.path().join("archive");
851 let summary = export(&store, &layer, &dest, "platform-a").unwrap();
852
853 assert_eq!(
856 blobs_whose_name_lies(&dest),
857 Vec::<(String, String)>::new(),
858 "every blob must hold the bytes its digest names"
859 );
860 let blob = |digest: &str| dest.join("blobs/sha256").join(&digest[7..]);
861 assert_eq!(
862 std::fs::read(blob(&manifest_digest(&kilnd_a))).unwrap(),
863 kilnd_a
864 );
865 assert_eq!(
866 std::fs::read(blob(&manifest_digest(&anyplat))).unwrap(),
867 anyplat,
868 "a payload claiming no platform belongs to every archive"
869 );
870 assert!(
874 !blob(&manifest_digest(&kilnd_b)).exists(),
875 "the archive must not invent a payload it does not hold"
876 );
877
878 assert_eq!(summary.platform, "platform-a");
881 assert_eq!(summary.archived, 2);
882 assert_eq!(
883 summary.omitted,
884 std::collections::BTreeMap::from([("platform-b".to_string(), 1)])
885 );
886
887 let index: serde_json::Value =
890 serde_json::from_slice(&std::fs::read(dest.join("index.json")).unwrap()).unwrap();
891 assert_eq!(
892 index["manifests"][0]["annotations"][ANN_ARCHIVED_FOR],
893 "platform-a"
894 );
895 }
896
897 #[test]
899 fn a_multi_platform_archive_reinstalls_and_reverifies_on_its_own_platform() {
900 let (tmp, store, layer, verifier, kilnd_a, _kb, _kn) = installed_multi_platform();
904 let dest = tmp.path().join("archive");
905 export(&store, &layer, &dest, "platform-a").unwrap();
906
907 let fresh_root = tmp.path().join("fresh");
908 let fresh = Store::at(&fresh_root);
909 let mut marks = HighWaterMarks::load(&fresh_root).unwrap();
910 let policy = InstallPolicy {
911 platform: "platform-a",
912 ..policy()
913 };
914 let outcome = install(
915 &pin("2026.07.0"),
916 &OciLayoutSource::at(&dest).for_platform("platform-a"),
917 &verifier,
918 &fresh,
919 &mut marks,
920 &policy,
921 )
922 .unwrap();
923 let entry = fresh.get(&outcome.digest).unwrap().unwrap();
924 assert_eq!(
925 std::fs::read(entry.root.join("bin/kilnd")).unwrap(),
926 kilnd_a,
927 "the far side must get platform-a's kilnd, not platform-b's entry's bytes"
928 );
929 assert_eq!(
930 crate::reverify::verify_installed(&fresh, &entry, &verifier, "platform-a").unwrap(),
931 2
932 );
933 }
934
935 #[test]
937 fn a_consumer_on_another_platform_is_told_the_archive_carries_none_for_it() {
938 let (tmp, store, layer, verifier, ..) = installed_multi_platform();
944 let dest = tmp.path().join("archive");
945 export(&store, &layer, &dest, "platform-a").unwrap();
946
947 let fresh_root = tmp.path().join("fresh-b");
948 let fresh = Store::at(&fresh_root);
949 let mut marks = HighWaterMarks::load(&fresh_root).unwrap();
950 let policy = InstallPolicy {
951 platform: "platform-b",
952 ..policy()
953 };
954 let err = install(
955 &pin("2026.07.0"),
956 &OciLayoutSource::at(&dest).for_platform("platform-b"),
957 &verifier,
958 &fresh,
959 &mut marks,
960 &policy,
961 )
962 .unwrap_err();
963 assert!(
964 matches!(
965 &err,
966 crate::install::InstallError::Source(SourceError::NoPayloadForPlatform {
967 wanted,
968 archived_for,
969 ..
970 }) if wanted == "platform-b" && archived_for == "platform-a"
971 ),
972 "a different-platform consumer must be told which platform this \
973 archive carries, not accused of tampering: {err}"
974 );
975 let text = err.to_string();
976 assert!(
977 text.contains("platform-b") && text.contains("platform-a"),
978 "the message must name both the platform asked for and the one \
979 carried: {text}"
980 );
981 assert!(
982 fresh.list().unwrap().is_empty(),
983 "and nothing lands from a failed install"
984 );
985 }
986
987 #[cfg(unix)]
989 #[test]
990 fn a_blob_that_exists_but_cannot_be_read_is_a_transport_fault_not_an_absent_payload() {
991 use std::os::unix::fs::PermissionsExt;
998 let (tmp, store, layer, verifier, kilnd_a, ..) = installed_multi_platform();
999 let dest = tmp.path().join("archive");
1000 export(&store, &layer, &dest, "platform-a").unwrap();
1001 let hex = manifest_digest(&kilnd_a)
1002 .strip_prefix("sha256:")
1003 .unwrap()
1004 .to_string();
1005 let blob = dest.join("blobs/sha256").join(&hex);
1006 std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
1007 if std::fs::read(&blob).is_ok() {
1011 eprintln!("skipping: this environment does not deny reads on mode 000");
1012 return;
1013 }
1014 let fresh_root = tmp.path().join("fresh");
1015 let fresh = Store::at(&fresh_root);
1016 let mut marks = HighWaterMarks::load(&fresh_root).unwrap();
1017 let err = install(
1018 &pin("2026.07.0"),
1019 &OciLayoutSource::at(&dest).for_platform("platform-a"),
1020 &verifier,
1021 &fresh,
1022 &mut marks,
1023 &InstallPolicy {
1024 platform: "platform-a",
1025 ..policy()
1026 },
1027 )
1028 .unwrap_err();
1029 let _ = std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o644));
1030 assert!(
1031 matches!(
1032 &err,
1033 crate::install::InstallError::Source(SourceError::Transport(_))
1034 ),
1035 "an unreadable blob must be a transport fault, not an absent one: {err}"
1036 );
1037 }
1038
1039 #[test]
1041 fn a_blob_missing_from_an_archive_of_this_platform_is_not_blamed_on_the_platform() {
1042 let (tmp, store, layer, verifier, kilnd_a, ..) = installed_multi_platform();
1049 let dest = tmp.path().join("archive");
1050 export(&store, &layer, &dest, "platform-a").unwrap();
1051 let hex = manifest_digest(&kilnd_a)
1052 .strip_prefix("sha256:")
1053 .unwrap()
1054 .to_string();
1055 std::fs::remove_file(dest.join("blobs/sha256").join(&hex)).unwrap();
1056
1057 let fresh_root = tmp.path().join("fresh");
1058 let fresh = Store::at(&fresh_root);
1059 let mut marks = HighWaterMarks::load(&fresh_root).unwrap();
1060 let err = install(
1061 &pin("2026.07.0"),
1062 &OciLayoutSource::at(&dest).for_platform("platform-a"),
1063 &verifier,
1064 &fresh,
1065 &mut marks,
1066 &InstallPolicy {
1067 platform: "platform-a",
1068 ..policy()
1069 },
1070 )
1071 .unwrap_err();
1072 assert!(
1073 matches!(
1074 &err,
1075 crate::install::InstallError::Source(SourceError::NotFound(_))
1076 ),
1077 "a damaged archive of this platform is not a platform mismatch: {err}"
1078 );
1079 }
1080
1081 #[test]
1083 fn an_archive_that_would_carry_no_payload_at_all_is_refused() {
1084 use crate::manifest::fixtures::manifest_with_platform_tools;
1090 let (sk, pk) = generate_root_keypair();
1091 let (a, b) = (b"kilnd-a".to_vec(), b"kilnd-b".to_vec());
1092 let (da, db) = (manifest_digest(&a), manifest_digest(&b));
1093 let payload = manifest_with_platform_tools(
1094 "2026.07.0",
1095 "qualified",
1096 1,
1097 "2026-07-31T09:14:00Z",
1098 &[
1099 ("kilnd", &da, Some("platform-a")),
1100 ("kilnd", &db, Some("platform-b")),
1101 ],
1102 );
1103 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
1104 let source = MemorySource::new()
1105 .with_manifest(envelope.as_bytes())
1106 .with_blob(&da, &a)
1107 .with_blob(&db, &b);
1108 let tmp = tempfile::tempdir().unwrap();
1109 let root = tmp.path().join("root");
1110 let store = Store::at(&root);
1111 let mut marks = HighWaterMarks::load(&root).unwrap();
1112 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
1113 let outcome = install(
1114 &pin("2026.07.0"),
1115 &source,
1116 &verifier,
1117 &store,
1118 &mut marks,
1119 &InstallPolicy {
1120 platform: "platform-a",
1121 ..policy()
1122 },
1123 )
1124 .unwrap();
1125 let layer = store.get(&outcome.digest).unwrap().unwrap();
1126 let dest = tmp.path().join("archive");
1127 let err = export(&store, &layer, &dest, "platfrom-a").unwrap_err();
1128 assert!(
1129 matches!(&err, ArchiveError::NoPayloadForPlatform { platform, omitted, .. }
1130 if platform == "platfrom-a" && *omitted == 2),
1131 "got: {err}"
1132 );
1133 assert!(
1134 !dest.join("index.json").exists(),
1135 "and nothing is written: a layout that exists is a layout somebody will carry"
1136 );
1137 }
1138
1139 #[test]
1141 fn a_payload_whose_bytes_do_not_match_its_signed_digest_is_never_archived() {
1142 let (tmp, store, layer, ..) = installed_multi_platform();
1148 std::fs::write(layer.root.join("bin/kilnd"), b"NOT-WHAT-WAS-SIGNED").unwrap();
1149 let err = export(&store, &layer, &tmp.path().join("archive"), "platform-a").unwrap_err();
1150 assert!(
1151 matches!(&err, ArchiveError::PayloadDigestMismatch { payload, .. } if payload == "kilnd"),
1152 "got: {err}"
1153 );
1154 }
1155
1156 #[test]
1158 fn export_writes_a_standard_oci_image_layout() {
1159 let (tmp, store, layer, _verifier, payload) = installed();
1161 let dest = tmp.path().join("archive");
1162 export(&store, &layer, &dest, "test-platform").unwrap();
1163
1164 let marker: serde_json::Value =
1166 serde_json::from_slice(&std::fs::read(dest.join("oci-layout")).unwrap()).unwrap();
1167 assert_eq!(marker["imageLayoutVersion"], "1.0.0");
1168
1169 let payload_digest = manifest_digest(&payload);
1171 let hex = payload_digest.strip_prefix("sha256:").unwrap();
1172 let manifest_blob = dest.join("blobs/sha256").join(hex);
1173 assert_eq!(std::fs::read(&manifest_blob).unwrap(), payload);
1174
1175 let index: serde_json::Value =
1182 serde_json::from_slice(&std::fs::read(dest.join("index.json")).unwrap()).unwrap();
1183 let tagged = index["manifests"]
1184 .as_array()
1185 .unwrap()
1186 .iter()
1187 .find(|e| e["annotations"][REF_NAME] == "2026.07.0")
1188 .expect("the layer manifest must be tagged with the layer id");
1189 assert_eq!(
1190 tagged["digest"],
1191 manifest_digest(&payload).as_str(),
1192 "the tag must point at the layer manifest, not the envelope"
1193 );
1194
1195 let index: serde_json::Value =
1197 serde_json::from_slice(&std::fs::read(dest.join("index.json")).unwrap()).unwrap();
1198 let entries = index["manifests"].as_array().unwrap();
1199 assert!(
1200 entries
1201 .iter()
1202 .any(|e| e["digest"] == payload_digest.as_str()),
1203 "index must reference the layer manifest"
1204 );
1205 let signature = entries
1206 .iter()
1207 .find(|e| e["artifactType"] == SIGNATURE_ARTIFACT_TYPE)
1208 .expect("index must reference the signature envelope");
1209 assert_eq!(signature["annotations"][ANN_SIGNS], payload_digest.as_str());
1210
1211 let manifest = LayerManifest::parse(&payload).unwrap();
1213 for entry in &manifest.entries {
1214 let hex = entry.digest.strip_prefix("sha256:").unwrap();
1215 assert!(
1216 dest.join("blobs/sha256").join(hex).is_file(),
1217 "blob {}",
1218 entry.digest
1219 );
1220 }
1221 }
1222
1223 #[test]
1225 fn an_archived_layer_installs_into_a_fresh_core_with_verification_unchanged() {
1226 let (tmp, store, layer, verifier, payload) = installed();
1227 let dest = tmp.path().join("archive");
1228 export(&store, &layer, &dest, "test-platform").unwrap();
1229
1230 let fresh_root = tmp.path().join("fresh");
1232 let fresh_store = Store::at(&fresh_root);
1233 let mut fresh_marks = HighWaterMarks::load(&fresh_root).unwrap();
1234 let source = OciLayoutSource::at(&dest);
1235 let outcome = install(
1236 &pin("2026.07.0"),
1237 &source,
1238 &verifier,
1239 &fresh_store,
1240 &mut fresh_marks,
1241 &policy(),
1242 )
1243 .unwrap();
1244 assert_eq!(outcome.digest, manifest_digest(&payload));
1245 let entry = fresh_store.get(&outcome.digest).unwrap().unwrap();
1247 let checked =
1248 crate::reverify::verify_installed(&fresh_store, &entry, &verifier, "test-platform")
1249 .unwrap();
1250 assert_eq!(checked, 1);
1251 }
1252
1253 #[allow(clippy::type_complexity)]
1256 fn installed_with_attestation() -> (
1257 tempfile::TempDir,
1258 Store,
1259 InstalledLayer,
1260 PinnedKeyVerifier,
1261 Vec<u8>,
1262 Vec<u8>,
1263 Vec<u8>,
1264 ) {
1265 use crate::attest::{AttestationKind, sign, statement};
1266 let (sk, pk) = generate_root_keypair();
1267 let tool = b"synth-bytes".to_vec();
1268 let blob_digest = manifest_digest(&tool);
1269 let payload = manifest_with_tools(
1270 "2026.07.0",
1271 "qualified",
1272 1,
1273 "2026-07-31T09:14:00Z",
1274 &[("synth", &blob_digest)],
1275 );
1276 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
1277 let sbom = b"{\"bomFormat\":\"CycloneDX\",\"components\":[]}".to_vec();
1278 let st = statement(
1279 "2026.07.0",
1280 &manifest_digest(&payload),
1281 AttestationKind::Sbom,
1282 &sbom,
1283 "acme-ci",
1284 );
1285 let statement_envelope = sign(&st, &sk, "varve-root-1").unwrap().into_bytes();
1286 let source = MemorySource::new()
1287 .with_manifest(envelope.as_bytes())
1288 .with_blob(&blob_digest, &tool)
1289 .with_attestation(&statement_envelope, &sbom);
1290 let tmp = tempfile::tempdir().unwrap();
1291 let root = tmp.path().join("root");
1292 let store = Store::at(&root);
1293 let mut marks = HighWaterMarks::load(&root).unwrap();
1294 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
1295 let outcome = install(
1296 &pin("2026.07.0"),
1297 &source,
1298 &verifier,
1299 &store,
1300 &mut marks,
1301 &policy(),
1302 )
1303 .unwrap();
1304 let layer = store.get(&outcome.digest).unwrap().unwrap();
1305 (tmp, store, layer, verifier, payload, pk.to_vec(), sbom)
1306 }
1307
1308 #[test]
1310 fn an_attestation_survives_archive_and_an_offline_install_into_a_fresh_core() {
1311 let (tmp, store, layer, verifier, payload, pk, sbom) = installed_with_attestation();
1319 let dest = tmp.path().join("archive");
1320 export(&store, &layer, &dest, "test-platform").unwrap();
1321
1322 let carried = crate::attestcarry::read_all(&dest, "2026.07.0").unwrap();
1324 assert_eq!(carried.len(), 1, "archive must re-emit the evidence");
1325 assert_eq!(carried[0].bytes, sbom, "carried verbatim");
1326
1327 let fresh_root = tmp.path().join("fresh");
1329 let fresh_store = Store::at(&fresh_root);
1330 let mut fresh_marks = HighWaterMarks::load(&fresh_root).unwrap();
1331 let outcome = install(
1332 &pin("2026.07.0"),
1333 &OciLayoutSource::at(&dest),
1334 &verifier,
1335 &fresh_store,
1336 &mut fresh_marks,
1337 &policy(),
1338 )
1339 .unwrap();
1340 assert_eq!(outcome.attestations_carried, 1, "it crossed the gap");
1341
1342 let entry = fresh_store.get(&outcome.digest).unwrap().unwrap();
1345 let reports = crate::attestcarry::report_installed(
1346 &entry.root,
1347 "2026.07.0",
1348 &manifest_digest(&payload),
1349 &pk,
1350 )
1351 .unwrap();
1352 assert_eq!(reports.len(), 1);
1353 assert!(reports[0].binds, "reason: {:?}", reports[0].reason);
1354 assert_eq!(reports[0].kind, "sbom");
1355 assert_eq!(reports[0].producer, "acme-ci");
1356 }
1357
1358 #[test]
1360 fn a_yank_survives_archive_and_an_offline_install_into_a_fresh_core() {
1361 use crate::linestatus::{LineStatus, StatusCache};
1371 let (sk, pk) = generate_root_keypair();
1372 let tool = b"synth-bytes".to_vec();
1373 let blob_digest = manifest_digest(&tool);
1374 let payload = manifest_with_tools(
1375 "2026.07.0",
1376 "qualified",
1377 1,
1378 "2026-07-31T09:14:00Z",
1379 &[("synth", &blob_digest)],
1380 );
1381 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
1382 let doc = LineStatus {
1383 min_counter: None,
1384 line: "2026.07".into(),
1385 counter: 3,
1386 issued_at: "2026-08-07T00:00:00Z".into(),
1387 support_until: Some("2028-07-31".into()),
1388 yanked: std::collections::BTreeMap::from([(
1389 "2026.07.0".to_string(),
1390 "CVE-2026-0001 in synth".to_string(),
1391 )]),
1392 known_problems: Vec::new(),
1393 };
1394 let status_envelope = doc.sign(&sk, "varve-root-1").unwrap().into_bytes();
1395 let source = MemorySource::new()
1396 .with_manifest(envelope.as_bytes())
1397 .with_blob(&blob_digest, &tool)
1398 .with_line_status(&status_envelope);
1399
1400 let tmp = tempfile::tempdir().unwrap();
1401 let root = tmp.path().join("root");
1402 let store = Store::at(&root);
1403 let mut marks = HighWaterMarks::load(&root).unwrap();
1404 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
1405 let outcome = install(
1406 &pin("2026.07.0"),
1407 &source,
1408 &verifier,
1409 &store,
1410 &mut marks,
1411 &policy(),
1412 )
1413 .unwrap();
1414 let line: crate::layer::Line = "2026.07".parse().unwrap();
1415 let cached = crate::linestatus::cache_baseline_from_source(
1418 &source,
1419 &LayerRef::Name("2026.07.0".parse().unwrap()),
1420 &line,
1421 &pk,
1422 store.root(),
1423 )
1424 .unwrap();
1425 assert_eq!(cached, Some(3), "the near side cached the advisory");
1426 let layer = store.get(&outcome.digest).unwrap().unwrap();
1427
1428 let dest = tmp.path().join("archive");
1429 export(&store, &layer, &dest, "test-platform").unwrap();
1430
1431 let fresh_root = tmp.path().join("fresh");
1434 let fresh = Store::at(&fresh_root);
1435 let mut fresh_marks = HighWaterMarks::load(&fresh_root).unwrap();
1436 let far = OciLayoutSource::at(&dest);
1437 install(
1438 &pin("2026.07.0"),
1439 &far,
1440 &verifier,
1441 &fresh,
1442 &mut fresh_marks,
1443 &policy(),
1444 )
1445 .unwrap();
1446 let carried = crate::linestatus::cache_baseline_from_source(
1447 &far,
1448 &LayerRef::Name("2026.07.0".parse().unwrap()),
1449 &line,
1450 &pk,
1451 fresh.root(),
1452 )
1453 .unwrap();
1454 assert_eq!(carried, Some(3), "the advisory crossed the gap");
1455
1456 let there = StatusCache::at_root(fresh.root())
1459 .load(&line, &pk)
1460 .unwrap()
1461 .expect("the far side has a cached status document");
1462 let report = there.report_for(&"2026.07.0".parse().unwrap());
1463 assert_eq!(
1464 report.yanked_reason.as_deref(),
1465 Some("CVE-2026-0001 in synth"),
1466 "the YANK is what had to arrive, not merely some blob"
1467 );
1468 }
1469
1470 #[test]
1472 fn exporting_a_layer_whose_line_has_no_cached_status_is_not_an_error() {
1473 let (tmp, store, layer, _verifier, _payload) = installed();
1477 let dest = tmp.path().join("archive");
1478 export(&store, &layer, &dest, "test-platform").unwrap();
1479 assert!(
1480 crate::linestatus::read_any_from_layout(&dest)
1481 .unwrap()
1482 .is_none()
1483 );
1484 }
1485
1486 #[test]
1488 fn a_layer_holding_two_versions_of_one_name_crosses_an_air_gap_intact() {
1489 use crate::manifest::fixtures::manifest_with_payloads;
1496 let (sk, pk) = generate_root_keypair();
1497 let a = b"serde-1.0.200-crate".to_vec();
1498 let b = b"serde-1.0.210-crate".to_vec();
1499 let (da, db) = (manifest_digest(&a), manifest_digest(&b));
1500 let payload = manifest_with_payloads(
1501 "2026.07.0",
1502 "qualified",
1503 1,
1504 "2026-07-31T09:14:00Z",
1505 &[
1506 ("serde", "1.0.200", "crate", &da),
1507 ("serde", "1.0.210", "crate", &db),
1508 ],
1509 );
1510 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
1511 let source = MemorySource::new()
1512 .with_manifest(envelope.as_bytes())
1513 .with_blob(&da, &a)
1514 .with_blob(&db, &b);
1515 let tmp = tempfile::tempdir().unwrap();
1516 let root = tmp.path().join("root");
1517 let store = Store::at(&root);
1518 let mut marks = HighWaterMarks::load(&root).unwrap();
1519 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
1520 let outcome = install(
1521 &pin("2026.07.0"),
1522 &source,
1523 &verifier,
1524 &store,
1525 &mut marks,
1526 &policy(),
1527 )
1528 .unwrap();
1529 let layer = store.get(&outcome.digest).unwrap().unwrap();
1530
1531 let dest = tmp.path().join("archive");
1532 export(&store, &layer, &dest, "test-platform").unwrap();
1533 for (digest, expected) in [(&da, &a), (&db, &b)] {
1537 let hex = digest.strip_prefix("sha256:").unwrap();
1538 assert_eq!(
1539 &std::fs::read(dest.join("blobs/sha256").join(hex)).unwrap(),
1540 expected,
1541 "blob {digest} must hold the bytes it is named for"
1542 );
1543 }
1544
1545 let fresh_root = tmp.path().join("fresh");
1548 let fresh = Store::at(&fresh_root);
1549 let mut fresh_marks = HighWaterMarks::load(&fresh_root).unwrap();
1550 let outcome = install(
1551 &pin("2026.07.0"),
1552 &OciLayoutSource::at(&dest),
1553 &verifier,
1554 &fresh,
1555 &mut fresh_marks,
1556 &policy(),
1557 )
1558 .unwrap();
1559 let entry = fresh.get(&outcome.digest).unwrap().unwrap();
1560 assert_eq!(
1561 crate::reverify::verify_installed(&fresh, &entry, &verifier, "test-platform").unwrap(),
1562 2,
1563 "both versions survive the round trip and re-verify"
1564 );
1565 assert_eq!(
1566 std::fs::read(entry.root.join("payloads/serde/1.0.200")).unwrap(),
1567 a
1568 );
1569 assert_eq!(
1570 std::fs::read(entry.root.join("payloads/serde/1.0.210")).unwrap(),
1571 b
1572 );
1573 }
1574
1575 #[test]
1577 fn the_archive_source_cannot_relax_acceptance() {
1578 let (tmp, store, layer, _verifier, _payload) = installed();
1581 let dest = tmp.path().join("archive");
1582 export(&store, &layer, &dest, "test-platform").unwrap();
1583
1584 let (_, other_pk) = generate_root_keypair();
1585 let wrong = PinnedKeyVerifier::from_public_key_bytes(&other_pk).unwrap();
1586 let fresh_root = tmp.path().join("fresh2");
1587 let fresh_store = Store::at(&fresh_root);
1588 let mut fresh_marks = HighWaterMarks::load(&fresh_root).unwrap();
1589 let err = install(
1590 &pin("2026.07.0"),
1591 &OciLayoutSource::at(&dest),
1592 &wrong,
1593 &fresh_store,
1594 &mut fresh_marks,
1595 &policy(),
1596 )
1597 .unwrap_err();
1598 assert!(
1599 matches!(err, crate::install::InstallError::Verify(_)),
1600 "archive path must reject exactly like any other: {err}"
1601 );
1602 assert!(fresh_store.list().unwrap().is_empty());
1603 }
1604
1605 #[test]
1607 fn export_refuses_a_layer_without_its_envelope() {
1608 let (tmp, store, layer, _verifier, _payload) = installed();
1609 std::fs::remove_file(layer.root.join(ENVELOPE_FILE)).unwrap();
1610 let err = export(&store, &layer, &tmp.path().join("archive"), "test-platform").unwrap_err();
1611 assert!(matches!(err, ArchiveError::NoEnvelope { .. }), "got: {err}");
1612 }
1613
1614 #[test]
1616 fn export_does_not_mutate_the_core() {
1617 let (tmp, store, layer, _verifier, _payload) = installed();
1618 let snapshot = |root: &Path| -> Vec<(String, Vec<u8>)> {
1619 let mut out = Vec::new();
1620 let mut stack = vec![root.to_path_buf()];
1621 while let Some(dir) = stack.pop() {
1622 for e in std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()) {
1623 let p = e.path();
1624 if p.is_dir() {
1625 stack.push(p);
1626 } else {
1627 out.push((p.display().to_string(), std::fs::read(&p).unwrap()));
1628 }
1629 }
1630 }
1631 out.sort();
1632 out
1633 };
1634 let before = snapshot(store.root());
1635 export(&store, &layer, &tmp.path().join("archive"), "test-platform").unwrap();
1636 assert_eq!(before, snapshot(store.root()));
1637 }
1638}