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 line: "2026.07".into(),
797 counter: 1,
798 issued_at: "2026-08-07T00:00:00Z".into(),
799 support_until: None,
800 yanked: Default::default(),
801 known_problems: Vec::new(),
802 };
803 crate::linestatus::attach_to_layout(
804 &dest,
805 &"2026.07".parse().unwrap(),
806 status.sign(&sk, "k").unwrap().as_bytes(),
807 )
808 .unwrap();
809 let before = std::fs::read(dest.join("index.json")).unwrap();
810
811 let err = export(&store, &layer, &dest, "test-platform")
812 .expect_err("re-archiving over attached evidence must be refused");
813 assert!(
814 matches!(&err, ArchiveError::WouldDestroy(_)),
815 "got: {err:?}"
816 );
817 assert!(err.to_string().contains("line-status"), "{err}");
818 assert_eq!(
819 before,
820 std::fs::read(dest.join("index.json")).unwrap(),
821 "a refused archive must leave the layout byte-identical"
822 );
823
824 export_with_options(
826 &store,
827 &layer,
828 &dest,
829 "test-platform",
830 &ArchiveOptions {
831 force: true,
832 allow_no_status: true,
833 },
834 )
835 .expect("--force overrides the guard");
836 }
837
838 #[test]
840 fn a_multi_platform_layer_archives_only_this_platforms_payloads() {
841 let (tmp, store, layer, _v, kilnd_a, kilnd_b, anyplat) = installed_multi_platform();
849 let dest = tmp.path().join("archive");
850 let summary = export(&store, &layer, &dest, "platform-a").unwrap();
851
852 assert_eq!(
855 blobs_whose_name_lies(&dest),
856 Vec::<(String, String)>::new(),
857 "every blob must hold the bytes its digest names"
858 );
859 let blob = |digest: &str| dest.join("blobs/sha256").join(&digest[7..]);
860 assert_eq!(
861 std::fs::read(blob(&manifest_digest(&kilnd_a))).unwrap(),
862 kilnd_a
863 );
864 assert_eq!(
865 std::fs::read(blob(&manifest_digest(&anyplat))).unwrap(),
866 anyplat,
867 "a payload claiming no platform belongs to every archive"
868 );
869 assert!(
873 !blob(&manifest_digest(&kilnd_b)).exists(),
874 "the archive must not invent a payload it does not hold"
875 );
876
877 assert_eq!(summary.platform, "platform-a");
880 assert_eq!(summary.archived, 2);
881 assert_eq!(
882 summary.omitted,
883 std::collections::BTreeMap::from([("platform-b".to_string(), 1)])
884 );
885
886 let index: serde_json::Value =
889 serde_json::from_slice(&std::fs::read(dest.join("index.json")).unwrap()).unwrap();
890 assert_eq!(
891 index["manifests"][0]["annotations"][ANN_ARCHIVED_FOR],
892 "platform-a"
893 );
894 }
895
896 #[test]
898 fn a_multi_platform_archive_reinstalls_and_reverifies_on_its_own_platform() {
899 let (tmp, store, layer, verifier, kilnd_a, _kb, _kn) = installed_multi_platform();
903 let dest = tmp.path().join("archive");
904 export(&store, &layer, &dest, "platform-a").unwrap();
905
906 let fresh_root = tmp.path().join("fresh");
907 let fresh = Store::at(&fresh_root);
908 let mut marks = HighWaterMarks::load(&fresh_root).unwrap();
909 let policy = InstallPolicy {
910 platform: "platform-a",
911 ..policy()
912 };
913 let outcome = install(
914 &pin("2026.07.0"),
915 &OciLayoutSource::at(&dest).for_platform("platform-a"),
916 &verifier,
917 &fresh,
918 &mut marks,
919 &policy,
920 )
921 .unwrap();
922 let entry = fresh.get(&outcome.digest).unwrap().unwrap();
923 assert_eq!(
924 std::fs::read(entry.root.join("bin/kilnd")).unwrap(),
925 kilnd_a,
926 "the far side must get platform-a's kilnd, not platform-b's entry's bytes"
927 );
928 assert_eq!(
929 crate::reverify::verify_installed(&fresh, &entry, &verifier, "platform-a").unwrap(),
930 2
931 );
932 }
933
934 #[test]
936 fn a_consumer_on_another_platform_is_told_the_archive_carries_none_for_it() {
937 let (tmp, store, layer, verifier, ..) = installed_multi_platform();
943 let dest = tmp.path().join("archive");
944 export(&store, &layer, &dest, "platform-a").unwrap();
945
946 let fresh_root = tmp.path().join("fresh-b");
947 let fresh = Store::at(&fresh_root);
948 let mut marks = HighWaterMarks::load(&fresh_root).unwrap();
949 let policy = InstallPolicy {
950 platform: "platform-b",
951 ..policy()
952 };
953 let err = install(
954 &pin("2026.07.0"),
955 &OciLayoutSource::at(&dest).for_platform("platform-b"),
956 &verifier,
957 &fresh,
958 &mut marks,
959 &policy,
960 )
961 .unwrap_err();
962 assert!(
963 matches!(
964 &err,
965 crate::install::InstallError::Source(SourceError::NoPayloadForPlatform {
966 wanted,
967 archived_for,
968 ..
969 }) if wanted == "platform-b" && archived_for == "platform-a"
970 ),
971 "a different-platform consumer must be told which platform this \
972 archive carries, not accused of tampering: {err}"
973 );
974 let text = err.to_string();
975 assert!(
976 text.contains("platform-b") && text.contains("platform-a"),
977 "the message must name both the platform asked for and the one \
978 carried: {text}"
979 );
980 assert!(
981 fresh.list().unwrap().is_empty(),
982 "and nothing lands from a failed install"
983 );
984 }
985
986 #[cfg(unix)]
988 #[test]
989 fn a_blob_that_exists_but_cannot_be_read_is_a_transport_fault_not_an_absent_payload() {
990 use std::os::unix::fs::PermissionsExt;
997 let (tmp, store, layer, verifier, kilnd_a, ..) = installed_multi_platform();
998 let dest = tmp.path().join("archive");
999 export(&store, &layer, &dest, "platform-a").unwrap();
1000 let hex = manifest_digest(&kilnd_a)
1001 .strip_prefix("sha256:")
1002 .unwrap()
1003 .to_string();
1004 let blob = dest.join("blobs/sha256").join(&hex);
1005 std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
1006 if std::fs::read(&blob).is_ok() {
1010 eprintln!("skipping: this environment does not deny reads on mode 000");
1011 return;
1012 }
1013 let fresh_root = tmp.path().join("fresh");
1014 let fresh = Store::at(&fresh_root);
1015 let mut marks = HighWaterMarks::load(&fresh_root).unwrap();
1016 let err = install(
1017 &pin("2026.07.0"),
1018 &OciLayoutSource::at(&dest).for_platform("platform-a"),
1019 &verifier,
1020 &fresh,
1021 &mut marks,
1022 &InstallPolicy {
1023 platform: "platform-a",
1024 ..policy()
1025 },
1026 )
1027 .unwrap_err();
1028 let _ = std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o644));
1029 assert!(
1030 matches!(
1031 &err,
1032 crate::install::InstallError::Source(SourceError::Transport(_))
1033 ),
1034 "an unreadable blob must be a transport fault, not an absent one: {err}"
1035 );
1036 }
1037
1038 #[test]
1040 fn a_blob_missing_from_an_archive_of_this_platform_is_not_blamed_on_the_platform() {
1041 let (tmp, store, layer, verifier, kilnd_a, ..) = installed_multi_platform();
1048 let dest = tmp.path().join("archive");
1049 export(&store, &layer, &dest, "platform-a").unwrap();
1050 let hex = manifest_digest(&kilnd_a)
1051 .strip_prefix("sha256:")
1052 .unwrap()
1053 .to_string();
1054 std::fs::remove_file(dest.join("blobs/sha256").join(&hex)).unwrap();
1055
1056 let fresh_root = tmp.path().join("fresh");
1057 let fresh = Store::at(&fresh_root);
1058 let mut marks = HighWaterMarks::load(&fresh_root).unwrap();
1059 let err = install(
1060 &pin("2026.07.0"),
1061 &OciLayoutSource::at(&dest).for_platform("platform-a"),
1062 &verifier,
1063 &fresh,
1064 &mut marks,
1065 &InstallPolicy {
1066 platform: "platform-a",
1067 ..policy()
1068 },
1069 )
1070 .unwrap_err();
1071 assert!(
1072 matches!(
1073 &err,
1074 crate::install::InstallError::Source(SourceError::NotFound(_))
1075 ),
1076 "a damaged archive of this platform is not a platform mismatch: {err}"
1077 );
1078 }
1079
1080 #[test]
1082 fn an_archive_that_would_carry_no_payload_at_all_is_refused() {
1083 use crate::manifest::fixtures::manifest_with_platform_tools;
1089 let (sk, pk) = generate_root_keypair();
1090 let (a, b) = (b"kilnd-a".to_vec(), b"kilnd-b".to_vec());
1091 let (da, db) = (manifest_digest(&a), manifest_digest(&b));
1092 let payload = manifest_with_platform_tools(
1093 "2026.07.0",
1094 "qualified",
1095 1,
1096 "2026-07-31T09:14:00Z",
1097 &[
1098 ("kilnd", &da, Some("platform-a")),
1099 ("kilnd", &db, Some("platform-b")),
1100 ],
1101 );
1102 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
1103 let source = MemorySource::new()
1104 .with_manifest(envelope.as_bytes())
1105 .with_blob(&da, &a)
1106 .with_blob(&db, &b);
1107 let tmp = tempfile::tempdir().unwrap();
1108 let root = tmp.path().join("root");
1109 let store = Store::at(&root);
1110 let mut marks = HighWaterMarks::load(&root).unwrap();
1111 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
1112 let outcome = install(
1113 &pin("2026.07.0"),
1114 &source,
1115 &verifier,
1116 &store,
1117 &mut marks,
1118 &InstallPolicy {
1119 platform: "platform-a",
1120 ..policy()
1121 },
1122 )
1123 .unwrap();
1124 let layer = store.get(&outcome.digest).unwrap().unwrap();
1125 let dest = tmp.path().join("archive");
1126 let err = export(&store, &layer, &dest, "platfrom-a").unwrap_err();
1127 assert!(
1128 matches!(&err, ArchiveError::NoPayloadForPlatform { platform, omitted, .. }
1129 if platform == "platfrom-a" && *omitted == 2),
1130 "got: {err}"
1131 );
1132 assert!(
1133 !dest.join("index.json").exists(),
1134 "and nothing is written: a layout that exists is a layout somebody will carry"
1135 );
1136 }
1137
1138 #[test]
1140 fn a_payload_whose_bytes_do_not_match_its_signed_digest_is_never_archived() {
1141 let (tmp, store, layer, ..) = installed_multi_platform();
1147 std::fs::write(layer.root.join("bin/kilnd"), b"NOT-WHAT-WAS-SIGNED").unwrap();
1148 let err = export(&store, &layer, &tmp.path().join("archive"), "platform-a").unwrap_err();
1149 assert!(
1150 matches!(&err, ArchiveError::PayloadDigestMismatch { payload, .. } if payload == "kilnd"),
1151 "got: {err}"
1152 );
1153 }
1154
1155 #[test]
1157 fn export_writes_a_standard_oci_image_layout() {
1158 let (tmp, store, layer, _verifier, payload) = installed();
1160 let dest = tmp.path().join("archive");
1161 export(&store, &layer, &dest, "test-platform").unwrap();
1162
1163 let marker: serde_json::Value =
1165 serde_json::from_slice(&std::fs::read(dest.join("oci-layout")).unwrap()).unwrap();
1166 assert_eq!(marker["imageLayoutVersion"], "1.0.0");
1167
1168 let payload_digest = manifest_digest(&payload);
1170 let hex = payload_digest.strip_prefix("sha256:").unwrap();
1171 let manifest_blob = dest.join("blobs/sha256").join(hex);
1172 assert_eq!(std::fs::read(&manifest_blob).unwrap(), payload);
1173
1174 let index: serde_json::Value =
1181 serde_json::from_slice(&std::fs::read(dest.join("index.json")).unwrap()).unwrap();
1182 let tagged = index["manifests"]
1183 .as_array()
1184 .unwrap()
1185 .iter()
1186 .find(|e| e["annotations"][REF_NAME] == "2026.07.0")
1187 .expect("the layer manifest must be tagged with the layer id");
1188 assert_eq!(
1189 tagged["digest"],
1190 manifest_digest(&payload).as_str(),
1191 "the tag must point at the layer manifest, not the envelope"
1192 );
1193
1194 let index: serde_json::Value =
1196 serde_json::from_slice(&std::fs::read(dest.join("index.json")).unwrap()).unwrap();
1197 let entries = index["manifests"].as_array().unwrap();
1198 assert!(
1199 entries
1200 .iter()
1201 .any(|e| e["digest"] == payload_digest.as_str()),
1202 "index must reference the layer manifest"
1203 );
1204 let signature = entries
1205 .iter()
1206 .find(|e| e["artifactType"] == SIGNATURE_ARTIFACT_TYPE)
1207 .expect("index must reference the signature envelope");
1208 assert_eq!(signature["annotations"][ANN_SIGNS], payload_digest.as_str());
1209
1210 let manifest = LayerManifest::parse(&payload).unwrap();
1212 for entry in &manifest.entries {
1213 let hex = entry.digest.strip_prefix("sha256:").unwrap();
1214 assert!(
1215 dest.join("blobs/sha256").join(hex).is_file(),
1216 "blob {}",
1217 entry.digest
1218 );
1219 }
1220 }
1221
1222 #[test]
1224 fn an_archived_layer_installs_into_a_fresh_core_with_verification_unchanged() {
1225 let (tmp, store, layer, verifier, payload) = installed();
1226 let dest = tmp.path().join("archive");
1227 export(&store, &layer, &dest, "test-platform").unwrap();
1228
1229 let fresh_root = tmp.path().join("fresh");
1231 let fresh_store = Store::at(&fresh_root);
1232 let mut fresh_marks = HighWaterMarks::load(&fresh_root).unwrap();
1233 let source = OciLayoutSource::at(&dest);
1234 let outcome = install(
1235 &pin("2026.07.0"),
1236 &source,
1237 &verifier,
1238 &fresh_store,
1239 &mut fresh_marks,
1240 &policy(),
1241 )
1242 .unwrap();
1243 assert_eq!(outcome.digest, manifest_digest(&payload));
1244 let entry = fresh_store.get(&outcome.digest).unwrap().unwrap();
1246 let checked =
1247 crate::reverify::verify_installed(&fresh_store, &entry, &verifier, "test-platform")
1248 .unwrap();
1249 assert_eq!(checked, 1);
1250 }
1251
1252 #[allow(clippy::type_complexity)]
1255 fn installed_with_attestation() -> (
1256 tempfile::TempDir,
1257 Store,
1258 InstalledLayer,
1259 PinnedKeyVerifier,
1260 Vec<u8>,
1261 Vec<u8>,
1262 Vec<u8>,
1263 ) {
1264 use crate::attest::{AttestationKind, sign, statement};
1265 let (sk, pk) = generate_root_keypair();
1266 let tool = b"synth-bytes".to_vec();
1267 let blob_digest = manifest_digest(&tool);
1268 let payload = manifest_with_tools(
1269 "2026.07.0",
1270 "qualified",
1271 1,
1272 "2026-07-31T09:14:00Z",
1273 &[("synth", &blob_digest)],
1274 );
1275 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
1276 let sbom = b"{\"bomFormat\":\"CycloneDX\",\"components\":[]}".to_vec();
1277 let st = statement(
1278 "2026.07.0",
1279 &manifest_digest(&payload),
1280 AttestationKind::Sbom,
1281 &sbom,
1282 "acme-ci",
1283 );
1284 let statement_envelope = sign(&st, &sk, "varve-root-1").unwrap().into_bytes();
1285 let source = MemorySource::new()
1286 .with_manifest(envelope.as_bytes())
1287 .with_blob(&blob_digest, &tool)
1288 .with_attestation(&statement_envelope, &sbom);
1289 let tmp = tempfile::tempdir().unwrap();
1290 let root = tmp.path().join("root");
1291 let store = Store::at(&root);
1292 let mut marks = HighWaterMarks::load(&root).unwrap();
1293 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
1294 let outcome = install(
1295 &pin("2026.07.0"),
1296 &source,
1297 &verifier,
1298 &store,
1299 &mut marks,
1300 &policy(),
1301 )
1302 .unwrap();
1303 let layer = store.get(&outcome.digest).unwrap().unwrap();
1304 (tmp, store, layer, verifier, payload, pk.to_vec(), sbom)
1305 }
1306
1307 #[test]
1309 fn an_attestation_survives_archive_and_an_offline_install_into_a_fresh_core() {
1310 let (tmp, store, layer, verifier, payload, pk, sbom) = installed_with_attestation();
1318 let dest = tmp.path().join("archive");
1319 export(&store, &layer, &dest, "test-platform").unwrap();
1320
1321 let carried = crate::attestcarry::read_all(&dest, "2026.07.0").unwrap();
1323 assert_eq!(carried.len(), 1, "archive must re-emit the evidence");
1324 assert_eq!(carried[0].bytes, sbom, "carried verbatim");
1325
1326 let fresh_root = tmp.path().join("fresh");
1328 let fresh_store = Store::at(&fresh_root);
1329 let mut fresh_marks = HighWaterMarks::load(&fresh_root).unwrap();
1330 let outcome = install(
1331 &pin("2026.07.0"),
1332 &OciLayoutSource::at(&dest),
1333 &verifier,
1334 &fresh_store,
1335 &mut fresh_marks,
1336 &policy(),
1337 )
1338 .unwrap();
1339 assert_eq!(outcome.attestations_carried, 1, "it crossed the gap");
1340
1341 let entry = fresh_store.get(&outcome.digest).unwrap().unwrap();
1344 let reports = crate::attestcarry::report_installed(
1345 &entry.root,
1346 "2026.07.0",
1347 &manifest_digest(&payload),
1348 &pk,
1349 )
1350 .unwrap();
1351 assert_eq!(reports.len(), 1);
1352 assert!(reports[0].binds, "reason: {:?}", reports[0].reason);
1353 assert_eq!(reports[0].kind, "sbom");
1354 assert_eq!(reports[0].producer, "acme-ci");
1355 }
1356
1357 #[test]
1359 fn a_yank_survives_archive_and_an_offline_install_into_a_fresh_core() {
1360 use crate::linestatus::{LineStatus, StatusCache};
1370 let (sk, pk) = generate_root_keypair();
1371 let tool = b"synth-bytes".to_vec();
1372 let blob_digest = manifest_digest(&tool);
1373 let payload = manifest_with_tools(
1374 "2026.07.0",
1375 "qualified",
1376 1,
1377 "2026-07-31T09:14:00Z",
1378 &[("synth", &blob_digest)],
1379 );
1380 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
1381 let doc = LineStatus {
1382 line: "2026.07".into(),
1383 counter: 3,
1384 issued_at: "2026-08-07T00:00:00Z".into(),
1385 support_until: Some("2028-07-31".into()),
1386 yanked: std::collections::BTreeMap::from([(
1387 "2026.07.0".to_string(),
1388 "CVE-2026-0001 in synth".to_string(),
1389 )]),
1390 known_problems: Vec::new(),
1391 };
1392 let status_envelope = doc.sign(&sk, "varve-root-1").unwrap().into_bytes();
1393 let source = MemorySource::new()
1394 .with_manifest(envelope.as_bytes())
1395 .with_blob(&blob_digest, &tool)
1396 .with_line_status(&status_envelope);
1397
1398 let tmp = tempfile::tempdir().unwrap();
1399 let root = tmp.path().join("root");
1400 let store = Store::at(&root);
1401 let mut marks = HighWaterMarks::load(&root).unwrap();
1402 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
1403 let outcome = install(
1404 &pin("2026.07.0"),
1405 &source,
1406 &verifier,
1407 &store,
1408 &mut marks,
1409 &policy(),
1410 )
1411 .unwrap();
1412 let line: crate::layer::Line = "2026.07".parse().unwrap();
1413 let cached = crate::linestatus::cache_baseline_from_source(
1416 &source,
1417 &LayerRef::Name("2026.07.0".parse().unwrap()),
1418 &line,
1419 &pk,
1420 store.root(),
1421 )
1422 .unwrap();
1423 assert_eq!(cached, Some(3), "the near side cached the advisory");
1424 let layer = store.get(&outcome.digest).unwrap().unwrap();
1425
1426 let dest = tmp.path().join("archive");
1427 export(&store, &layer, &dest, "test-platform").unwrap();
1428
1429 let fresh_root = tmp.path().join("fresh");
1432 let fresh = Store::at(&fresh_root);
1433 let mut fresh_marks = HighWaterMarks::load(&fresh_root).unwrap();
1434 let far = OciLayoutSource::at(&dest);
1435 install(
1436 &pin("2026.07.0"),
1437 &far,
1438 &verifier,
1439 &fresh,
1440 &mut fresh_marks,
1441 &policy(),
1442 )
1443 .unwrap();
1444 let carried = crate::linestatus::cache_baseline_from_source(
1445 &far,
1446 &LayerRef::Name("2026.07.0".parse().unwrap()),
1447 &line,
1448 &pk,
1449 fresh.root(),
1450 )
1451 .unwrap();
1452 assert_eq!(carried, Some(3), "the advisory crossed the gap");
1453
1454 let there = StatusCache::at_root(fresh.root())
1457 .load(&line, &pk)
1458 .unwrap()
1459 .expect("the far side has a cached status document");
1460 let report = there.report_for(&"2026.07.0".parse().unwrap());
1461 assert_eq!(
1462 report.yanked_reason.as_deref(),
1463 Some("CVE-2026-0001 in synth"),
1464 "the YANK is what had to arrive, not merely some blob"
1465 );
1466 }
1467
1468 #[test]
1470 fn exporting_a_layer_whose_line_has_no_cached_status_is_not_an_error() {
1471 let (tmp, store, layer, _verifier, _payload) = installed();
1475 let dest = tmp.path().join("archive");
1476 export(&store, &layer, &dest, "test-platform").unwrap();
1477 assert!(
1478 crate::linestatus::read_any_from_layout(&dest)
1479 .unwrap()
1480 .is_none()
1481 );
1482 }
1483
1484 #[test]
1486 fn a_layer_holding_two_versions_of_one_name_crosses_an_air_gap_intact() {
1487 use crate::manifest::fixtures::manifest_with_payloads;
1494 let (sk, pk) = generate_root_keypair();
1495 let a = b"serde-1.0.200-crate".to_vec();
1496 let b = b"serde-1.0.210-crate".to_vec();
1497 let (da, db) = (manifest_digest(&a), manifest_digest(&b));
1498 let payload = manifest_with_payloads(
1499 "2026.07.0",
1500 "qualified",
1501 1,
1502 "2026-07-31T09:14:00Z",
1503 &[
1504 ("serde", "1.0.200", "crate", &da),
1505 ("serde", "1.0.210", "crate", &db),
1506 ],
1507 );
1508 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
1509 let source = MemorySource::new()
1510 .with_manifest(envelope.as_bytes())
1511 .with_blob(&da, &a)
1512 .with_blob(&db, &b);
1513 let tmp = tempfile::tempdir().unwrap();
1514 let root = tmp.path().join("root");
1515 let store = Store::at(&root);
1516 let mut marks = HighWaterMarks::load(&root).unwrap();
1517 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
1518 let outcome = install(
1519 &pin("2026.07.0"),
1520 &source,
1521 &verifier,
1522 &store,
1523 &mut marks,
1524 &policy(),
1525 )
1526 .unwrap();
1527 let layer = store.get(&outcome.digest).unwrap().unwrap();
1528
1529 let dest = tmp.path().join("archive");
1530 export(&store, &layer, &dest, "test-platform").unwrap();
1531 for (digest, expected) in [(&da, &a), (&db, &b)] {
1535 let hex = digest.strip_prefix("sha256:").unwrap();
1536 assert_eq!(
1537 &std::fs::read(dest.join("blobs/sha256").join(hex)).unwrap(),
1538 expected,
1539 "blob {digest} must hold the bytes it is named for"
1540 );
1541 }
1542
1543 let fresh_root = tmp.path().join("fresh");
1546 let fresh = Store::at(&fresh_root);
1547 let mut fresh_marks = HighWaterMarks::load(&fresh_root).unwrap();
1548 let outcome = install(
1549 &pin("2026.07.0"),
1550 &OciLayoutSource::at(&dest),
1551 &verifier,
1552 &fresh,
1553 &mut fresh_marks,
1554 &policy(),
1555 )
1556 .unwrap();
1557 let entry = fresh.get(&outcome.digest).unwrap().unwrap();
1558 assert_eq!(
1559 crate::reverify::verify_installed(&fresh, &entry, &verifier, "test-platform").unwrap(),
1560 2,
1561 "both versions survive the round trip and re-verify"
1562 );
1563 assert_eq!(
1564 std::fs::read(entry.root.join("payloads/serde/1.0.200")).unwrap(),
1565 a
1566 );
1567 assert_eq!(
1568 std::fs::read(entry.root.join("payloads/serde/1.0.210")).unwrap(),
1569 b
1570 );
1571 }
1572
1573 #[test]
1575 fn the_archive_source_cannot_relax_acceptance() {
1576 let (tmp, store, layer, _verifier, _payload) = installed();
1579 let dest = tmp.path().join("archive");
1580 export(&store, &layer, &dest, "test-platform").unwrap();
1581
1582 let (_, other_pk) = generate_root_keypair();
1583 let wrong = PinnedKeyVerifier::from_public_key_bytes(&other_pk).unwrap();
1584 let fresh_root = tmp.path().join("fresh2");
1585 let fresh_store = Store::at(&fresh_root);
1586 let mut fresh_marks = HighWaterMarks::load(&fresh_root).unwrap();
1587 let err = install(
1588 &pin("2026.07.0"),
1589 &OciLayoutSource::at(&dest),
1590 &wrong,
1591 &fresh_store,
1592 &mut fresh_marks,
1593 &policy(),
1594 )
1595 .unwrap_err();
1596 assert!(
1597 matches!(err, crate::install::InstallError::Verify(_)),
1598 "archive path must reject exactly like any other: {err}"
1599 );
1600 assert!(fresh_store.list().unwrap().is_empty());
1601 }
1602
1603 #[test]
1605 fn export_refuses_a_layer_without_its_envelope() {
1606 let (tmp, store, layer, _verifier, _payload) = installed();
1607 std::fs::remove_file(layer.root.join(ENVELOPE_FILE)).unwrap();
1608 let err = export(&store, &layer, &tmp.path().join("archive"), "test-platform").unwrap_err();
1609 assert!(matches!(err, ArchiveError::NoEnvelope { .. }), "got: {err}");
1610 }
1611
1612 #[test]
1614 fn export_does_not_mutate_the_core() {
1615 let (tmp, store, layer, _verifier, _payload) = installed();
1616 let snapshot = |root: &Path| -> Vec<(String, Vec<u8>)> {
1617 let mut out = Vec::new();
1618 let mut stack = vec![root.to_path_buf()];
1619 while let Some(dir) = stack.pop() {
1620 for e in std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()) {
1621 let p = e.path();
1622 if p.is_dir() {
1623 stack.push(p);
1624 } else {
1625 out.push((p.display().to_string(), std::fs::read(&p).unwrap()));
1626 }
1627 }
1628 }
1629 out.sort();
1630 out
1631 };
1632 let before = snapshot(store.root());
1633 export(&store, &layer, &tmp.path().join("archive"), "test-platform").unwrap();
1634 assert_eq!(before, snapshot(store.root()));
1635 }
1636}