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)]
41pub enum ArchiveError {
42 #[error("the layer's baseline line-status could not be carried into the archive: {0}")]
43 LineStatus(String),
44 #[error("io error at {path}")]
45 Io {
46 path: String,
47 #[source]
48 source: std::io::Error,
49 },
50 #[error(
51 "layer {digest} has no retained signature envelope — an archive without its signature \
52 cannot serve as the artifact of record; reinstall from a signed source first"
53 )]
54 NoEnvelope { digest: String },
55 #[error(
56 "payload '{payload}' ({digest}) is named by the layer manifest for platform {platform} but \
57 is not present in the installed layer — an archive missing a payload is not the artifact \
58 of record; reinstall the layer first"
59 )]
60 MissingPayload {
61 payload: String,
62 digest: String,
63 platform: String,
64 },
65 #[error(
66 "payload '{payload}' at {path} hashes to {found}, but the signed manifest names {signed} \
67 for it — refusing to write a blob under a digest its bytes do not have. Either this core \
68 was altered since install, or it was installed for a platform other than {platform} \
69 (`varve install --platform`), in which case archive it with the matching \
70 `varve archive --platform`."
71 )]
72 PayloadDigestMismatch {
73 payload: String,
74 path: String,
75 signed: String,
76 found: String,
77 platform: String,
78 },
79 #[error(
80 "layer {layer} has no payload for platform {platform} in this core — all {omitted} of its \
81 payload entries name other platforms ({platforms}). An empty archive is not the artifact \
82 of record and would only fail on the far side of the gap: install the layer for \
83 {platform}, or archive with the platform this core was installed for."
84 )]
85 NoPayloadForPlatform {
86 layer: String,
87 platform: String,
88 omitted: usize,
89 platforms: String,
90 },
91 #[error("layer.json in the core is not a valid manifest: {0}")]
92 Manifest(#[from] crate::manifest::ManifestError),
93 #[error(transparent)]
94 Carry(#[from] crate::attestcarry::CarryError),
95 #[error(transparent)]
96 Store(#[from] StoreError),
97 #[error(transparent)]
98 Verify(#[from] VerifyError),
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct ExportSummary {
110 pub platform: String,
112 pub archived: usize,
114 pub omitted: std::collections::BTreeMap<String, usize>,
118}
119
120fn named(entry: &crate::manifest::ManifestEntry, name: &str) -> String {
123 match crate::store::entry_version(entry) {
124 Some(version) => format!("{name}@{version}"),
125 None => name.to_string(),
126 }
127}
128
129pub fn export(
134 store: &Store,
135 layer: &InstalledLayer,
136 dest: &Path,
137 platform: &str,
138) -> Result<ExportSummary, ArchiveError> {
139 let io = |path: &Path, source: std::io::Error| ArchiveError::Io {
140 path: path.display().to_string(),
141 source,
142 };
143
144 let manifest_path = layer.root.join("layer.json");
146 let payload = std::fs::read(&manifest_path).map_err(|e| io(&manifest_path, e))?;
147 let envelope_path = layer.root.join(ENVELOPE_FILE);
148 let envelope = match std::fs::read(&envelope_path) {
149 Ok(bytes) => bytes,
150 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
151 return Err(ArchiveError::NoEnvelope {
152 digest: layer.digest.clone(),
153 });
154 }
155 Err(e) => return Err(io(&envelope_path, e)),
156 };
157 let manifest = LayerManifest::parse(&payload)?;
158 let mut blobs: Vec<(String, Vec<u8>)> = Vec::new();
159 let mut omitted: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
160 for entry in &manifest.entries {
161 if entry.kind() == Ok(crate::kind::PayloadKind::Layer) {
164 continue;
165 }
166 let Some(name) = entry.annotations.get("eu.pulseengine.tool") else {
167 continue;
168 };
169 let entry_platform = entry
177 .annotations
178 .get(crate::platform::ANN_PLATFORM)
179 .map(String::as_str);
180 if let Some(other) =
181 entry_platform.filter(|p| !crate::platform::entry_matches(Some(p), platform))
182 {
183 *omitted.entry(other.to_string()).or_default() += 1;
184 continue;
185 }
186 let path = store
192 .entry_path(layer, entry)
193 .ok_or_else(|| ArchiveError::MissingPayload {
194 payload: named(entry, name),
195 digest: entry.digest.clone(),
196 platform: platform.to_string(),
197 })?;
198 let bytes = std::fs::read(&path).map_err(|e| io(&path, e))?;
199 let found = manifest_digest(&bytes);
207 if found != entry.digest {
208 return Err(ArchiveError::PayloadDigestMismatch {
209 payload: named(entry, name),
210 path: path.display().to_string(),
211 signed: entry.digest.clone(),
212 found,
213 platform: platform.to_string(),
214 });
215 }
216 blobs.push((entry.digest.clone(), bytes));
217 }
218 if blobs.is_empty() && !omitted.is_empty() {
225 return Err(ArchiveError::NoPayloadForPlatform {
226 layer: manifest.layer.to_string(),
227 platform: platform.to_string(),
228 omitted: omitted.values().sum(),
229 platforms: omitted.keys().cloned().collect::<Vec<_>>().join(", "),
230 });
231 }
232 let carried = crate::attestcarry::read_persisted(&layer.root, &manifest.layer.to_string())?;
241
242 write_oci_layout(
243 &payload,
244 &envelope,
245 &blobs,
246 &manifest.layer.to_string(),
247 &manifest.channel,
248 Some(platform),
249 dest,
250 )
251 .map_err(|(path, source)| ArchiveError::Io { path, source })?;
252 for c in &carried {
253 crate::attestcarry::attach(dest, &c.statement, &c.bytes)?;
254 }
255 let cache = crate::linestatus::StatusCache::at_root(store.root());
273 if let Some(envelope) = cache
274 .envelope_bytes(layer.layer.line())
275 .map_err(|e| ArchiveError::LineStatus(e.to_string()))?
276 {
277 crate::linestatus::attach_to_layout(dest, layer.layer.line(), &envelope)
278 .map_err(|e| ArchiveError::LineStatus(e.to_string()))?;
279 }
280 Ok(ExportSummary {
281 platform: platform.to_string(),
282 archived: blobs.len(),
283 omitted,
284 })
285}
286
287pub(crate) fn write_oci_layout(
296 payload: &[u8],
297 envelope: &[u8],
298 blobs: &[(String, Vec<u8>)],
299 layer_name: &str,
300 channel: &str,
301 platform: Option<&str>,
302 dest: &Path,
303) -> Result<(), (String, std::io::Error)> {
304 let io = |path: &Path, source: std::io::Error| (path.display().to_string(), source);
305 let payload_digest = manifest_digest(payload);
306 let envelope_digest = manifest_digest(envelope);
307
308 let blob_dir = dest.join("blobs").join("sha256");
309 std::fs::create_dir_all(&blob_dir).map_err(|e| io(&blob_dir, e))?;
310 let write_blob = |digest: &str, bytes: &[u8]| -> Result<(), (String, std::io::Error)> {
311 let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
312 let path = blob_dir.join(hex);
313 std::fs::write(&path, bytes).map_err(|e| io(&path, e))
314 };
315 write_blob(&payload_digest, payload)?;
316 write_blob(&envelope_digest, envelope)?;
317 for (digest, bytes) in blobs {
318 write_blob(digest, bytes)?;
319 }
320
321 let marker_path = dest.join("oci-layout");
322 std::fs::write(&marker_path, br#"{"imageLayoutVersion":"1.0.0"}"#)
323 .map_err(|e| io(&marker_path, e))?;
324
325 let mut index = serde_json::json!({
326 "schemaVersion": 2,
327 "mediaType": "application/vnd.oci.image.index.v1+json",
328 "manifests": [
329 {
330 "mediaType": "application/vnd.oci.image.index.v1+json",
331 "digest": payload_digest,
332 "size": payload.len(),
333 "annotations": {
334 REF_NAME: layer_name,
338 "eu.pulseengine.varve.layer": layer_name,
339 "eu.pulseengine.varve.channel": channel,
340 }
341 },
342 {
343 "mediaType": "application/json",
344 "artifactType": SIGNATURE_ARTIFACT_TYPE,
345 "digest": envelope_digest,
346 "size": envelope.len(),
347 "annotations": { ANN_SIGNS: payload_digest }
348 }
349 ]
350 });
351 if let Some(platform) = platform {
354 index["manifests"][0]["annotations"][ANN_ARCHIVED_FOR] =
355 serde_json::Value::String(platform.to_string());
356 }
357 let index_path = dest.join("index.json");
358 std::fs::write(
359 &index_path,
360 serde_json::to_vec_pretty(&index).expect("index serializes"),
361 )
362 .map_err(|e| io(&index_path, e))?;
363 Ok(())
364}
365
366#[derive(Debug)]
369pub struct OciLayoutSource {
370 root: std::path::PathBuf,
371 platform: Option<String>,
374}
375
376impl OciLayoutSource {
377 pub fn at(root: impl Into<std::path::PathBuf>) -> Self {
378 OciLayoutSource {
379 root: root.into(),
380 platform: None,
381 }
382 }
383
384 pub fn for_platform(mut self, platform: impl Into<String>) -> Self {
387 self.platform = Some(platform.into());
388 self
389 }
390
391 fn blob_path(&self, digest: &str) -> std::path::PathBuf {
392 let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
393 self.root.join("blobs").join("sha256").join(hex)
394 }
395
396 fn archived_for(&self) -> Option<String> {
400 let index: serde_json::Value =
401 serde_json::from_slice(&std::fs::read(self.root.join("index.json")).ok()?).ok()?;
402 index["manifests"]
403 .as_array()?
404 .iter()
405 .find_map(|e| e["annotations"][ANN_ARCHIVED_FOR].as_str())
406 .map(str::to_string)
407 }
408
409 fn absent(&self, digest: &str) -> SourceError {
414 let wanted = self
415 .platform
416 .clone()
417 .unwrap_or_else(crate::platform::host_platform);
418 match self.archived_for() {
419 Some(archived_for) if archived_for != wanted => SourceError::NoPayloadForPlatform {
420 digest: digest.to_string(),
421 wanted,
422 archived_for,
423 },
424 _ => SourceError::NotFound(digest.to_string()),
425 }
426 }
427}
428
429impl LayerSource for OciLayoutSource {
430 fn fetch_manifest(&self, layer: &LayerRef) -> Result<Vec<u8>, SourceError> {
431 let index_path = self.root.join("index.json");
435 let index: serde_json::Value = serde_json::from_slice(
436 &std::fs::read(&index_path)
437 .map_err(|e| SourceError::Transport(format!("{}: {e}", index_path.display())))?,
438 )
439 .map_err(|e| SourceError::Transport(format!("index.json: {e}")))?;
440 let entries = index["manifests"].as_array().cloned().unwrap_or_default();
441
442 let wanted: Vec<String> = entries
444 .iter()
445 .filter(|e| e["artifactType"] != SIGNATURE_ARTIFACT_TYPE)
446 .filter_map(|e| {
447 let digest = e["digest"].as_str()?.to_string();
448 match layer {
449 LayerRef::Digest(d) => (&digest == d).then_some(digest),
450 LayerRef::Name(id) => {
451 let name = e["annotations"]["eu.pulseengine.varve.layer"].as_str()?;
452 (name == id.to_string()).then_some(digest)
453 }
454 }
455 })
456 .collect();
457
458 for digest in wanted {
459 let envelope = entries.iter().find(|e| {
462 e["artifactType"] == SIGNATURE_ARTIFACT_TYPE
463 && e["annotations"][ANN_SIGNS] == *digest
464 });
465 let blob_digest = envelope
466 .and_then(|e| e["digest"].as_str())
467 .map(str::to_string)
468 .unwrap_or_else(|| digest.clone());
469 match std::fs::read(self.blob_path(&blob_digest)) {
470 Ok(bytes) => return Ok(bytes),
471 Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
472 Err(e) => return Err(SourceError::Transport(e.to_string())),
473 }
474 }
475 Err(SourceError::NotFound(format!("{layer:?}")))
476 }
477
478 fn fetch_blob(&self, digest: &str) -> Result<Vec<u8>, SourceError> {
479 match std::fs::read(self.blob_path(digest)) {
480 Ok(bytes) => Ok(bytes),
481 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(self.absent(digest)),
482 Err(e) => Err(SourceError::Transport(e.to_string())),
483 }
484 }
485
486 fn fetch_line_status(&self, _layer: &LayerRef) -> Result<Option<Vec<u8>>, SourceError> {
487 crate::linestatus::read_any_from_layout(&self.root)
490 .map_err(|e| SourceError::Transport(e.to_string()))
491 }
492
493 fn fetch_line_index(&self, line: &str) -> Result<Option<Vec<u8>>, SourceError> {
494 crate::lineindex::read_from_layout(&self.root, line)
499 .map_err(|e| SourceError::Transport(e.to_string()))
500 }
501
502 fn fetch_attestations(
512 &self,
513 layer: &LayerRef,
514 ) -> Result<Vec<crate::attestcarry::CarriedAttestation>, SourceError> {
515 let name = match layer {
520 LayerRef::Name(id) => id.to_string(),
521 LayerRef::Digest(d) => d.clone(),
522 };
523 crate::attestcarry::read_all(&self.root, &name)
524 .map_err(|e| SourceError::Transport(e.to_string()))
525 }
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531 use crate::install::{InstallPolicy, install};
532 use crate::manifest::fixtures::manifest_with_tools;
533 use crate::pin::Pin;
534 use crate::rollback::HighWaterMarks;
535 use crate::source::MemorySource;
536 use crate::verify::{PinnedKeyVerifier, generate_root_keypair, sign_layer_manifest};
537
538 fn pin(layer: &str) -> Pin {
539 Pin::parse(
540 &format!(
541 "manifest-version = 1\n[toolchain]\nchannel = \"qualified\"\nlayer = \"{layer}\"\n"
542 ),
543 "varve.toml",
544 )
545 .unwrap()
546 }
547
548 fn policy() -> InstallPolicy<'static> {
549 InstallPolicy {
550 index: None,
551 now: "2026-08-07T00:00:00Z",
552 staleness_threshold_days: 90,
553 platform: "test-platform",
554 }
555 }
556
557 fn installed() -> (
560 tempfile::TempDir,
561 Store,
562 InstalledLayer,
563 PinnedKeyVerifier,
564 Vec<u8>,
565 ) {
566 let (sk, pk) = generate_root_keypair();
567 let tool = b"synth-bytes".to_vec();
568 let blob_digest = manifest_digest(&tool);
569 let payload = manifest_with_tools(
570 "2026.07.0",
571 "qualified",
572 1,
573 "2026-07-31T09:14:00Z",
574 &[("synth", &blob_digest)],
575 );
576 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
577 let source = MemorySource::new()
578 .with_manifest(envelope.as_bytes())
579 .with_blob(&blob_digest, &tool);
580 let tmp = tempfile::tempdir().unwrap();
581 let root = tmp.path().join("root");
582 let store = Store::at(&root);
583 let mut marks = HighWaterMarks::load(&root).unwrap();
584 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
585 let outcome = install(
586 &pin("2026.07.0"),
587 &source,
588 &verifier,
589 &store,
590 &mut marks,
591 &policy(),
592 )
593 .unwrap();
594 let layer = store.get(&outcome.digest).unwrap().unwrap();
595 (tmp, store, layer, verifier, payload)
596 }
597
598 fn blobs_whose_name_lies(dest: &Path) -> Vec<(String, String)> {
603 let mut bad = Vec::new();
604 for e in std::fs::read_dir(dest.join("blobs/sha256"))
605 .unwrap()
606 .filter_map(|e| e.ok())
607 {
608 let name = e.file_name().to_string_lossy().to_string();
609 let actual = manifest_digest(&std::fs::read(e.path()).unwrap());
610 if actual != format!("sha256:{name}") {
611 bad.push((name, actual));
612 }
613 }
614 bad
615 }
616
617 #[allow(clippy::type_complexity)]
622 fn installed_multi_platform() -> (
623 tempfile::TempDir,
624 Store,
625 InstalledLayer,
626 PinnedKeyVerifier,
627 Vec<u8>,
628 Vec<u8>,
629 Vec<u8>,
630 ) {
631 use crate::manifest::fixtures::manifest_with_platform_tools;
632 let (sk, pk) = generate_root_keypair();
633 let kilnd_a = b"kilnd-built-for-platform-a".to_vec();
634 let kilnd_b = b"kilnd-built-for-platform-b".to_vec();
635 let anyplat = b"a-payload-with-no-platform-claim".to_vec();
636 let (da, db, dn) = (
637 manifest_digest(&kilnd_a),
638 manifest_digest(&kilnd_b),
639 manifest_digest(&anyplat),
640 );
641 let payload = manifest_with_platform_tools(
642 "2026.07.0",
643 "qualified",
644 1,
645 "2026-07-31T09:14:00Z",
646 &[
647 ("kilnd", &da, Some("platform-a")),
648 ("kilnd", &db, Some("platform-b")),
649 ("charter", &dn, None),
650 ],
651 );
652 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
653 let source = MemorySource::new()
655 .with_manifest(envelope.as_bytes())
656 .with_blob(&da, &kilnd_a)
657 .with_blob(&db, &kilnd_b)
658 .with_blob(&dn, &anyplat);
659 let tmp = tempfile::tempdir().unwrap();
660 let root = tmp.path().join("root");
661 let store = Store::at(&root);
662 let mut marks = HighWaterMarks::load(&root).unwrap();
663 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
664 let policy = InstallPolicy {
665 platform: "platform-a",
666 ..policy()
667 };
668 let outcome = install(
669 &pin("2026.07.0"),
670 &source,
671 &verifier,
672 &store,
673 &mut marks,
674 &policy,
675 )
676 .unwrap();
677 let layer = store.get(&outcome.digest).unwrap().unwrap();
678 assert_eq!(
681 std::fs::read(layer.root.join("bin/kilnd")).unwrap(),
682 kilnd_a
683 );
684 (tmp, store, layer, verifier, kilnd_a, kilnd_b, anyplat)
685 }
686
687 #[test]
689 fn a_multi_platform_layer_archives_only_this_platforms_payloads() {
690 let (tmp, store, layer, _v, kilnd_a, kilnd_b, anyplat) = installed_multi_platform();
698 let dest = tmp.path().join("archive");
699 let summary = export(&store, &layer, &dest, "platform-a").unwrap();
700
701 assert_eq!(
704 blobs_whose_name_lies(&dest),
705 Vec::<(String, String)>::new(),
706 "every blob must hold the bytes its digest names"
707 );
708 let blob = |digest: &str| dest.join("blobs/sha256").join(&digest[7..]);
709 assert_eq!(
710 std::fs::read(blob(&manifest_digest(&kilnd_a))).unwrap(),
711 kilnd_a
712 );
713 assert_eq!(
714 std::fs::read(blob(&manifest_digest(&anyplat))).unwrap(),
715 anyplat,
716 "a payload claiming no platform belongs to every archive"
717 );
718 assert!(
722 !blob(&manifest_digest(&kilnd_b)).exists(),
723 "the archive must not invent a payload it does not hold"
724 );
725
726 assert_eq!(summary.platform, "platform-a");
729 assert_eq!(summary.archived, 2);
730 assert_eq!(
731 summary.omitted,
732 std::collections::BTreeMap::from([("platform-b".to_string(), 1)])
733 );
734
735 let index: serde_json::Value =
738 serde_json::from_slice(&std::fs::read(dest.join("index.json")).unwrap()).unwrap();
739 assert_eq!(
740 index["manifests"][0]["annotations"][ANN_ARCHIVED_FOR],
741 "platform-a"
742 );
743 }
744
745 #[test]
747 fn a_multi_platform_archive_reinstalls_and_reverifies_on_its_own_platform() {
748 let (tmp, store, layer, verifier, kilnd_a, _kb, _kn) = installed_multi_platform();
752 let dest = tmp.path().join("archive");
753 export(&store, &layer, &dest, "platform-a").unwrap();
754
755 let fresh_root = tmp.path().join("fresh");
756 let fresh = Store::at(&fresh_root);
757 let mut marks = HighWaterMarks::load(&fresh_root).unwrap();
758 let policy = InstallPolicy {
759 platform: "platform-a",
760 ..policy()
761 };
762 let outcome = install(
763 &pin("2026.07.0"),
764 &OciLayoutSource::at(&dest).for_platform("platform-a"),
765 &verifier,
766 &fresh,
767 &mut marks,
768 &policy,
769 )
770 .unwrap();
771 let entry = fresh.get(&outcome.digest).unwrap().unwrap();
772 assert_eq!(
773 std::fs::read(entry.root.join("bin/kilnd")).unwrap(),
774 kilnd_a,
775 "the far side must get platform-a's kilnd, not platform-b's entry's bytes"
776 );
777 assert_eq!(
778 crate::reverify::verify_installed(&fresh, &entry, &verifier, "platform-a").unwrap(),
779 2
780 );
781 }
782
783 #[test]
785 fn a_consumer_on_another_platform_is_told_the_archive_carries_none_for_it() {
786 let (tmp, store, layer, verifier, ..) = installed_multi_platform();
792 let dest = tmp.path().join("archive");
793 export(&store, &layer, &dest, "platform-a").unwrap();
794
795 let fresh_root = tmp.path().join("fresh-b");
796 let fresh = Store::at(&fresh_root);
797 let mut marks = HighWaterMarks::load(&fresh_root).unwrap();
798 let policy = InstallPolicy {
799 platform: "platform-b",
800 ..policy()
801 };
802 let err = install(
803 &pin("2026.07.0"),
804 &OciLayoutSource::at(&dest).for_platform("platform-b"),
805 &verifier,
806 &fresh,
807 &mut marks,
808 &policy,
809 )
810 .unwrap_err();
811 assert!(
812 matches!(
813 &err,
814 crate::install::InstallError::Source(SourceError::NoPayloadForPlatform {
815 wanted,
816 archived_for,
817 ..
818 }) if wanted == "platform-b" && archived_for == "platform-a"
819 ),
820 "a different-platform consumer must be told which platform this \
821 archive carries, not accused of tampering: {err}"
822 );
823 let text = err.to_string();
824 assert!(
825 text.contains("platform-b") && text.contains("platform-a"),
826 "the message must name both the platform asked for and the one \
827 carried: {text}"
828 );
829 assert!(
830 fresh.list().unwrap().is_empty(),
831 "and nothing lands from a failed install"
832 );
833 }
834
835 #[cfg(unix)]
837 #[test]
838 fn a_blob_that_exists_but_cannot_be_read_is_a_transport_fault_not_an_absent_payload() {
839 use std::os::unix::fs::PermissionsExt;
846 let (tmp, store, layer, verifier, kilnd_a, ..) = installed_multi_platform();
847 let dest = tmp.path().join("archive");
848 export(&store, &layer, &dest, "platform-a").unwrap();
849 let hex = manifest_digest(&kilnd_a)
850 .strip_prefix("sha256:")
851 .unwrap()
852 .to_string();
853 let blob = dest.join("blobs/sha256").join(&hex);
854 std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o000)).unwrap();
855 if std::fs::read(&blob).is_ok() {
859 eprintln!("skipping: this environment does not deny reads on mode 000");
860 return;
861 }
862 let fresh_root = tmp.path().join("fresh");
863 let fresh = Store::at(&fresh_root);
864 let mut marks = HighWaterMarks::load(&fresh_root).unwrap();
865 let err = install(
866 &pin("2026.07.0"),
867 &OciLayoutSource::at(&dest).for_platform("platform-a"),
868 &verifier,
869 &fresh,
870 &mut marks,
871 &InstallPolicy {
872 platform: "platform-a",
873 ..policy()
874 },
875 )
876 .unwrap_err();
877 let _ = std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o644));
878 assert!(
879 matches!(
880 &err,
881 crate::install::InstallError::Source(SourceError::Transport(_))
882 ),
883 "an unreadable blob must be a transport fault, not an absent one: {err}"
884 );
885 }
886
887 #[test]
889 fn a_blob_missing_from_an_archive_of_this_platform_is_not_blamed_on_the_platform() {
890 let (tmp, store, layer, verifier, kilnd_a, ..) = installed_multi_platform();
897 let dest = tmp.path().join("archive");
898 export(&store, &layer, &dest, "platform-a").unwrap();
899 let hex = manifest_digest(&kilnd_a)
900 .strip_prefix("sha256:")
901 .unwrap()
902 .to_string();
903 std::fs::remove_file(dest.join("blobs/sha256").join(&hex)).unwrap();
904
905 let fresh_root = tmp.path().join("fresh");
906 let fresh = Store::at(&fresh_root);
907 let mut marks = HighWaterMarks::load(&fresh_root).unwrap();
908 let err = install(
909 &pin("2026.07.0"),
910 &OciLayoutSource::at(&dest).for_platform("platform-a"),
911 &verifier,
912 &fresh,
913 &mut marks,
914 &InstallPolicy {
915 platform: "platform-a",
916 ..policy()
917 },
918 )
919 .unwrap_err();
920 assert!(
921 matches!(
922 &err,
923 crate::install::InstallError::Source(SourceError::NotFound(_))
924 ),
925 "a damaged archive of this platform is not a platform mismatch: {err}"
926 );
927 }
928
929 #[test]
931 fn an_archive_that_would_carry_no_payload_at_all_is_refused() {
932 use crate::manifest::fixtures::manifest_with_platform_tools;
938 let (sk, pk) = generate_root_keypair();
939 let (a, b) = (b"kilnd-a".to_vec(), b"kilnd-b".to_vec());
940 let (da, db) = (manifest_digest(&a), manifest_digest(&b));
941 let payload = manifest_with_platform_tools(
942 "2026.07.0",
943 "qualified",
944 1,
945 "2026-07-31T09:14:00Z",
946 &[
947 ("kilnd", &da, Some("platform-a")),
948 ("kilnd", &db, Some("platform-b")),
949 ],
950 );
951 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
952 let source = MemorySource::new()
953 .with_manifest(envelope.as_bytes())
954 .with_blob(&da, &a)
955 .with_blob(&db, &b);
956 let tmp = tempfile::tempdir().unwrap();
957 let root = tmp.path().join("root");
958 let store = Store::at(&root);
959 let mut marks = HighWaterMarks::load(&root).unwrap();
960 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
961 let outcome = install(
962 &pin("2026.07.0"),
963 &source,
964 &verifier,
965 &store,
966 &mut marks,
967 &InstallPolicy {
968 platform: "platform-a",
969 ..policy()
970 },
971 )
972 .unwrap();
973 let layer = store.get(&outcome.digest).unwrap().unwrap();
974 let dest = tmp.path().join("archive");
975 let err = export(&store, &layer, &dest, "platfrom-a").unwrap_err();
976 assert!(
977 matches!(&err, ArchiveError::NoPayloadForPlatform { platform, omitted, .. }
978 if platform == "platfrom-a" && *omitted == 2),
979 "got: {err}"
980 );
981 assert!(
982 !dest.join("index.json").exists(),
983 "and nothing is written: a layout that exists is a layout somebody will carry"
984 );
985 }
986
987 #[test]
989 fn a_payload_whose_bytes_do_not_match_its_signed_digest_is_never_archived() {
990 let (tmp, store, layer, ..) = installed_multi_platform();
996 std::fs::write(layer.root.join("bin/kilnd"), b"NOT-WHAT-WAS-SIGNED").unwrap();
997 let err = export(&store, &layer, &tmp.path().join("archive"), "platform-a").unwrap_err();
998 assert!(
999 matches!(&err, ArchiveError::PayloadDigestMismatch { payload, .. } if payload == "kilnd"),
1000 "got: {err}"
1001 );
1002 }
1003
1004 #[test]
1006 fn export_writes_a_standard_oci_image_layout() {
1007 let (tmp, store, layer, _verifier, payload) = installed();
1009 let dest = tmp.path().join("archive");
1010 export(&store, &layer, &dest, "test-platform").unwrap();
1011
1012 let marker: serde_json::Value =
1014 serde_json::from_slice(&std::fs::read(dest.join("oci-layout")).unwrap()).unwrap();
1015 assert_eq!(marker["imageLayoutVersion"], "1.0.0");
1016
1017 let payload_digest = manifest_digest(&payload);
1019 let hex = payload_digest.strip_prefix("sha256:").unwrap();
1020 let manifest_blob = dest.join("blobs/sha256").join(hex);
1021 assert_eq!(std::fs::read(&manifest_blob).unwrap(), payload);
1022
1023 let index: serde_json::Value =
1030 serde_json::from_slice(&std::fs::read(dest.join("index.json")).unwrap()).unwrap();
1031 let tagged = index["manifests"]
1032 .as_array()
1033 .unwrap()
1034 .iter()
1035 .find(|e| e["annotations"][REF_NAME] == "2026.07.0")
1036 .expect("the layer manifest must be tagged with the layer id");
1037 assert_eq!(
1038 tagged["digest"],
1039 manifest_digest(&payload).as_str(),
1040 "the tag must point at the layer manifest, not the envelope"
1041 );
1042
1043 let index: serde_json::Value =
1045 serde_json::from_slice(&std::fs::read(dest.join("index.json")).unwrap()).unwrap();
1046 let entries = index["manifests"].as_array().unwrap();
1047 assert!(
1048 entries
1049 .iter()
1050 .any(|e| e["digest"] == payload_digest.as_str()),
1051 "index must reference the layer manifest"
1052 );
1053 let signature = entries
1054 .iter()
1055 .find(|e| e["artifactType"] == SIGNATURE_ARTIFACT_TYPE)
1056 .expect("index must reference the signature envelope");
1057 assert_eq!(signature["annotations"][ANN_SIGNS], payload_digest.as_str());
1058
1059 let manifest = LayerManifest::parse(&payload).unwrap();
1061 for entry in &manifest.entries {
1062 let hex = entry.digest.strip_prefix("sha256:").unwrap();
1063 assert!(
1064 dest.join("blobs/sha256").join(hex).is_file(),
1065 "blob {}",
1066 entry.digest
1067 );
1068 }
1069 }
1070
1071 #[test]
1073 fn an_archived_layer_installs_into_a_fresh_core_with_verification_unchanged() {
1074 let (tmp, store, layer, verifier, payload) = installed();
1075 let dest = tmp.path().join("archive");
1076 export(&store, &layer, &dest, "test-platform").unwrap();
1077
1078 let fresh_root = tmp.path().join("fresh");
1080 let fresh_store = Store::at(&fresh_root);
1081 let mut fresh_marks = HighWaterMarks::load(&fresh_root).unwrap();
1082 let source = OciLayoutSource::at(&dest);
1083 let outcome = install(
1084 &pin("2026.07.0"),
1085 &source,
1086 &verifier,
1087 &fresh_store,
1088 &mut fresh_marks,
1089 &policy(),
1090 )
1091 .unwrap();
1092 assert_eq!(outcome.digest, manifest_digest(&payload));
1093 let entry = fresh_store.get(&outcome.digest).unwrap().unwrap();
1095 let checked =
1096 crate::reverify::verify_installed(&fresh_store, &entry, &verifier, "test-platform")
1097 .unwrap();
1098 assert_eq!(checked, 1);
1099 }
1100
1101 #[allow(clippy::type_complexity)]
1104 fn installed_with_attestation() -> (
1105 tempfile::TempDir,
1106 Store,
1107 InstalledLayer,
1108 PinnedKeyVerifier,
1109 Vec<u8>,
1110 Vec<u8>,
1111 Vec<u8>,
1112 ) {
1113 use crate::attest::{AttestationKind, sign, statement};
1114 let (sk, pk) = generate_root_keypair();
1115 let tool = b"synth-bytes".to_vec();
1116 let blob_digest = manifest_digest(&tool);
1117 let payload = manifest_with_tools(
1118 "2026.07.0",
1119 "qualified",
1120 1,
1121 "2026-07-31T09:14:00Z",
1122 &[("synth", &blob_digest)],
1123 );
1124 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
1125 let sbom = b"{\"bomFormat\":\"CycloneDX\",\"components\":[]}".to_vec();
1126 let st = statement(
1127 "2026.07.0",
1128 &manifest_digest(&payload),
1129 AttestationKind::Sbom,
1130 &sbom,
1131 "acme-ci",
1132 );
1133 let statement_envelope = sign(&st, &sk, "varve-root-1").unwrap().into_bytes();
1134 let source = MemorySource::new()
1135 .with_manifest(envelope.as_bytes())
1136 .with_blob(&blob_digest, &tool)
1137 .with_attestation(&statement_envelope, &sbom);
1138 let tmp = tempfile::tempdir().unwrap();
1139 let root = tmp.path().join("root");
1140 let store = Store::at(&root);
1141 let mut marks = HighWaterMarks::load(&root).unwrap();
1142 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
1143 let outcome = install(
1144 &pin("2026.07.0"),
1145 &source,
1146 &verifier,
1147 &store,
1148 &mut marks,
1149 &policy(),
1150 )
1151 .unwrap();
1152 let layer = store.get(&outcome.digest).unwrap().unwrap();
1153 (tmp, store, layer, verifier, payload, pk.to_vec(), sbom)
1154 }
1155
1156 #[test]
1158 fn an_attestation_survives_archive_and_an_offline_install_into_a_fresh_core() {
1159 let (tmp, store, layer, verifier, payload, pk, sbom) = installed_with_attestation();
1167 let dest = tmp.path().join("archive");
1168 export(&store, &layer, &dest, "test-platform").unwrap();
1169
1170 let carried = crate::attestcarry::read_all(&dest, "2026.07.0").unwrap();
1172 assert_eq!(carried.len(), 1, "archive must re-emit the evidence");
1173 assert_eq!(carried[0].bytes, sbom, "carried verbatim");
1174
1175 let fresh_root = tmp.path().join("fresh");
1177 let fresh_store = Store::at(&fresh_root);
1178 let mut fresh_marks = HighWaterMarks::load(&fresh_root).unwrap();
1179 let outcome = install(
1180 &pin("2026.07.0"),
1181 &OciLayoutSource::at(&dest),
1182 &verifier,
1183 &fresh_store,
1184 &mut fresh_marks,
1185 &policy(),
1186 )
1187 .unwrap();
1188 assert_eq!(outcome.attestations_carried, 1, "it crossed the gap");
1189
1190 let entry = fresh_store.get(&outcome.digest).unwrap().unwrap();
1193 let reports = crate::attestcarry::report_installed(
1194 &entry.root,
1195 "2026.07.0",
1196 &manifest_digest(&payload),
1197 &pk,
1198 )
1199 .unwrap();
1200 assert_eq!(reports.len(), 1);
1201 assert!(reports[0].binds, "reason: {:?}", reports[0].reason);
1202 assert_eq!(reports[0].kind, "sbom");
1203 assert_eq!(reports[0].producer, "acme-ci");
1204 }
1205
1206 #[test]
1208 fn a_yank_survives_archive_and_an_offline_install_into_a_fresh_core() {
1209 use crate::linestatus::{LineStatus, StatusCache};
1219 let (sk, pk) = generate_root_keypair();
1220 let tool = b"synth-bytes".to_vec();
1221 let blob_digest = manifest_digest(&tool);
1222 let payload = manifest_with_tools(
1223 "2026.07.0",
1224 "qualified",
1225 1,
1226 "2026-07-31T09:14:00Z",
1227 &[("synth", &blob_digest)],
1228 );
1229 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
1230 let doc = LineStatus {
1231 line: "2026.07".into(),
1232 counter: 3,
1233 issued_at: "2026-08-07T00:00:00Z".into(),
1234 support_until: Some("2028-07-31".into()),
1235 yanked: std::collections::BTreeMap::from([(
1236 "2026.07.0".to_string(),
1237 "CVE-2026-0001 in synth".to_string(),
1238 )]),
1239 known_problems: Vec::new(),
1240 };
1241 let status_envelope = doc.sign(&sk, "varve-root-1").unwrap().into_bytes();
1242 let source = MemorySource::new()
1243 .with_manifest(envelope.as_bytes())
1244 .with_blob(&blob_digest, &tool)
1245 .with_line_status(&status_envelope);
1246
1247 let tmp = tempfile::tempdir().unwrap();
1248 let root = tmp.path().join("root");
1249 let store = Store::at(&root);
1250 let mut marks = HighWaterMarks::load(&root).unwrap();
1251 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
1252 let outcome = install(
1253 &pin("2026.07.0"),
1254 &source,
1255 &verifier,
1256 &store,
1257 &mut marks,
1258 &policy(),
1259 )
1260 .unwrap();
1261 let line: crate::layer::Line = "2026.07".parse().unwrap();
1262 let cached = crate::linestatus::cache_baseline_from_source(
1265 &source,
1266 &LayerRef::Name("2026.07.0".parse().unwrap()),
1267 &line,
1268 &pk,
1269 store.root(),
1270 )
1271 .unwrap();
1272 assert_eq!(cached, Some(3), "the near side cached the advisory");
1273 let layer = store.get(&outcome.digest).unwrap().unwrap();
1274
1275 let dest = tmp.path().join("archive");
1276 export(&store, &layer, &dest, "test-platform").unwrap();
1277
1278 let fresh_root = tmp.path().join("fresh");
1281 let fresh = Store::at(&fresh_root);
1282 let mut fresh_marks = HighWaterMarks::load(&fresh_root).unwrap();
1283 let far = OciLayoutSource::at(&dest);
1284 install(
1285 &pin("2026.07.0"),
1286 &far,
1287 &verifier,
1288 &fresh,
1289 &mut fresh_marks,
1290 &policy(),
1291 )
1292 .unwrap();
1293 let carried = crate::linestatus::cache_baseline_from_source(
1294 &far,
1295 &LayerRef::Name("2026.07.0".parse().unwrap()),
1296 &line,
1297 &pk,
1298 fresh.root(),
1299 )
1300 .unwrap();
1301 assert_eq!(carried, Some(3), "the advisory crossed the gap");
1302
1303 let there = StatusCache::at_root(fresh.root())
1306 .load(&line, &pk)
1307 .unwrap()
1308 .expect("the far side has a cached status document");
1309 let report = there.report_for(&"2026.07.0".parse().unwrap());
1310 assert_eq!(
1311 report.yanked_reason.as_deref(),
1312 Some("CVE-2026-0001 in synth"),
1313 "the YANK is what had to arrive, not merely some blob"
1314 );
1315 }
1316
1317 #[test]
1319 fn exporting_a_layer_whose_line_has_no_cached_status_is_not_an_error() {
1320 let (tmp, store, layer, _verifier, _payload) = installed();
1324 let dest = tmp.path().join("archive");
1325 export(&store, &layer, &dest, "test-platform").unwrap();
1326 assert!(
1327 crate::linestatus::read_any_from_layout(&dest)
1328 .unwrap()
1329 .is_none()
1330 );
1331 }
1332
1333 #[test]
1335 fn a_layer_holding_two_versions_of_one_name_crosses_an_air_gap_intact() {
1336 use crate::manifest::fixtures::manifest_with_payloads;
1343 let (sk, pk) = generate_root_keypair();
1344 let a = b"serde-1.0.200-crate".to_vec();
1345 let b = b"serde-1.0.210-crate".to_vec();
1346 let (da, db) = (manifest_digest(&a), manifest_digest(&b));
1347 let payload = manifest_with_payloads(
1348 "2026.07.0",
1349 "qualified",
1350 1,
1351 "2026-07-31T09:14:00Z",
1352 &[
1353 ("serde", "1.0.200", "crate", &da),
1354 ("serde", "1.0.210", "crate", &db),
1355 ],
1356 );
1357 let envelope = sign_layer_manifest(&payload, &sk, "varve-root-1").unwrap();
1358 let source = MemorySource::new()
1359 .with_manifest(envelope.as_bytes())
1360 .with_blob(&da, &a)
1361 .with_blob(&db, &b);
1362 let tmp = tempfile::tempdir().unwrap();
1363 let root = tmp.path().join("root");
1364 let store = Store::at(&root);
1365 let mut marks = HighWaterMarks::load(&root).unwrap();
1366 let verifier = PinnedKeyVerifier::from_public_key_bytes(&pk).unwrap();
1367 let outcome = install(
1368 &pin("2026.07.0"),
1369 &source,
1370 &verifier,
1371 &store,
1372 &mut marks,
1373 &policy(),
1374 )
1375 .unwrap();
1376 let layer = store.get(&outcome.digest).unwrap().unwrap();
1377
1378 let dest = tmp.path().join("archive");
1379 export(&store, &layer, &dest, "test-platform").unwrap();
1380 for (digest, expected) in [(&da, &a), (&db, &b)] {
1384 let hex = digest.strip_prefix("sha256:").unwrap();
1385 assert_eq!(
1386 &std::fs::read(dest.join("blobs/sha256").join(hex)).unwrap(),
1387 expected,
1388 "blob {digest} must hold the bytes it is named for"
1389 );
1390 }
1391
1392 let fresh_root = tmp.path().join("fresh");
1395 let fresh = Store::at(&fresh_root);
1396 let mut fresh_marks = HighWaterMarks::load(&fresh_root).unwrap();
1397 let outcome = install(
1398 &pin("2026.07.0"),
1399 &OciLayoutSource::at(&dest),
1400 &verifier,
1401 &fresh,
1402 &mut fresh_marks,
1403 &policy(),
1404 )
1405 .unwrap();
1406 let entry = fresh.get(&outcome.digest).unwrap().unwrap();
1407 assert_eq!(
1408 crate::reverify::verify_installed(&fresh, &entry, &verifier, "test-platform").unwrap(),
1409 2,
1410 "both versions survive the round trip and re-verify"
1411 );
1412 assert_eq!(
1413 std::fs::read(entry.root.join("payloads/serde/1.0.200")).unwrap(),
1414 a
1415 );
1416 assert_eq!(
1417 std::fs::read(entry.root.join("payloads/serde/1.0.210")).unwrap(),
1418 b
1419 );
1420 }
1421
1422 #[test]
1424 fn the_archive_source_cannot_relax_acceptance() {
1425 let (tmp, store, layer, _verifier, _payload) = installed();
1428 let dest = tmp.path().join("archive");
1429 export(&store, &layer, &dest, "test-platform").unwrap();
1430
1431 let (_, other_pk) = generate_root_keypair();
1432 let wrong = PinnedKeyVerifier::from_public_key_bytes(&other_pk).unwrap();
1433 let fresh_root = tmp.path().join("fresh2");
1434 let fresh_store = Store::at(&fresh_root);
1435 let mut fresh_marks = HighWaterMarks::load(&fresh_root).unwrap();
1436 let err = install(
1437 &pin("2026.07.0"),
1438 &OciLayoutSource::at(&dest),
1439 &wrong,
1440 &fresh_store,
1441 &mut fresh_marks,
1442 &policy(),
1443 )
1444 .unwrap_err();
1445 assert!(
1446 matches!(err, crate::install::InstallError::Verify(_)),
1447 "archive path must reject exactly like any other: {err}"
1448 );
1449 assert!(fresh_store.list().unwrap().is_empty());
1450 }
1451
1452 #[test]
1454 fn export_refuses_a_layer_without_its_envelope() {
1455 let (tmp, store, layer, _verifier, _payload) = installed();
1456 std::fs::remove_file(layer.root.join(ENVELOPE_FILE)).unwrap();
1457 let err = export(&store, &layer, &tmp.path().join("archive"), "test-platform").unwrap_err();
1458 assert!(matches!(err, ArchiveError::NoEnvelope { .. }), "got: {err}");
1459 }
1460
1461 #[test]
1463 fn export_does_not_mutate_the_core() {
1464 let (tmp, store, layer, _verifier, _payload) = installed();
1465 let snapshot = |root: &Path| -> Vec<(String, Vec<u8>)> {
1466 let mut out = Vec::new();
1467 let mut stack = vec![root.to_path_buf()];
1468 while let Some(dir) = stack.pop() {
1469 for e in std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok()) {
1470 let p = e.path();
1471 if p.is_dir() {
1472 stack.push(p);
1473 } else {
1474 out.push((p.display().to_string(), std::fs::read(&p).unwrap()));
1475 }
1476 }
1477 }
1478 out.sort();
1479 out
1480 };
1481 let before = snapshot(store.root());
1482 export(&store, &layer, &tmp.path().join("archive"), "test-platform").unwrap();
1483 assert_eq!(before, snapshot(store.root()));
1484 }
1485}