1use std::collections::{BTreeSet, HashMap, HashSet};
19use std::path::{Path, PathBuf};
20use std::sync::Mutex;
21
22use chrono::{DateTime, Utc};
23use serde::{Deserialize, Serialize};
24use tracing::warn;
25
26use crate::error::{Result, SnapshotError};
27
28const CATALOG_DIR: &str = "template-catalog";
30
31const MAX_COMPONENT_LEN: usize = 64;
33
34pub const TEMPLATE_LABEL: &str = "arcbox.template";
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub enum ReadyProbeSpec {
48 Port {
50 port: u16,
51 timeout_seconds: u32,
53 },
54 Command {
56 cmd: Vec<String>,
57 timeout_seconds: u32,
59 },
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
68pub struct TemplateDefaultsSpec {
69 #[serde(default)]
70 pub vcpus: u32,
71 #[serde(default)]
72 pub memory_mib: u64,
73 #[serde(default)]
74 pub cmd: Vec<String>,
75 #[serde(default)]
76 pub env: HashMap<String, String>,
77 #[serde(default)]
80 pub exposed_ports: Vec<u32>,
81 #[serde(default)]
82 pub ready_probe: Option<ReadyProbeSpec>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct WarmArtifact {
92 pub snapshot_id: String,
94 pub vcpus: u32,
95 pub memory_mib: u64,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct TemplateEntry {
101 #[serde(default)]
103 pub version: String,
104 pub digest: String,
108 pub rootfs_path: String,
110 #[serde(default)]
112 pub warm: Option<WarmArtifact>,
113 #[serde(default)]
114 pub defaults: TemplateDefaultsSpec,
115 #[serde(default)]
117 pub labels: HashMap<String, String>,
118 pub created_at: DateTime<Utc>,
121 #[serde(default)]
123 pub size_bytes: u64,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
129struct TemplateRecord {
130 name: String,
131 #[serde(default)]
132 draft: Option<TemplateEntry>,
133 #[serde(default)]
134 versions: Vec<TemplateEntry>,
135}
136
137impl TemplateRecord {
138 fn entries(&self) -> impl Iterator<Item = &TemplateEntry> {
139 self.versions.iter().chain(self.draft.as_ref())
140 }
141
142 fn is_empty(&self) -> bool {
143 self.draft.is_none() && self.versions.is_empty()
144 }
145}
146
147#[derive(Debug, Clone)]
149pub struct ResolvedTemplate {
150 pub name: String,
152 pub entry: TemplateEntry,
156}
157
158impl ResolvedTemplate {
159 #[must_use]
164 pub fn canonical_ref(&self) -> String {
165 format!("{}:{}@{}", self.name, self.entry.version, self.entry.digest)
166 }
167}
168
169#[derive(Debug)]
172pub struct PutDraftOutcome {
173 pub entry: TemplateEntry,
175 pub released: ReleasedArtifacts,
176}
177
178#[derive(Debug)]
184pub struct PublishOutcome {
185 pub entry: TemplateEntry,
187 pub released: ReleasedArtifacts,
188}
189
190#[derive(Debug, Default)]
196pub struct ReleasedArtifacts {
197 pub snapshot_ids: Vec<String>,
198}
199
200pub struct TemplateCatalog {
202 root: PathBuf,
203 lock: Mutex<()>,
206}
207
208impl TemplateCatalog {
209 #[must_use]
212 pub fn new(data_dir: &str) -> Self {
213 Self {
214 root: Path::new(data_dir).join(CATALOG_DIR),
215 lock: Mutex::new(()),
216 }
217 }
218
219 pub fn put_draft(&self, name: &str, mut entry: TemplateEntry) -> Result<PutDraftOutcome> {
222 validate_template_name(name)?;
223 let _guard = self.lock.lock().unwrap();
224 let mut record = self.read_record(name)?.unwrap_or_else(|| TemplateRecord {
225 name: name.to_string(),
226 draft: None,
227 versions: Vec::new(),
228 });
229 entry.version = String::new();
230 let displaced = record.draft.replace(entry.clone());
231 self.write_record(&record)?;
232 let released = self.released_after_commit(displaced.as_slice());
233 Ok(PutDraftOutcome { entry, released })
234 }
235
236 pub fn publish(&self, name: &str, version: &str) -> Result<PublishOutcome> {
245 validate_template_name(name)?;
246 validate_template_version(version)?;
247 let _guard = self.lock.lock().unwrap();
248 let mut record = self
249 .read_record(name)?
250 .ok_or_else(|| SnapshotError::TemplateNotFound(name.to_string()))?;
251 if let Some(existing) = record.versions.iter().find(|e| e.version == version) {
252 let existing = existing.clone();
253 return match record.draft.as_ref() {
254 None => Ok(PublishOutcome {
255 entry: existing,
256 released: ReleasedArtifacts::default(),
257 }),
258 Some(draft) if draft.digest == existing.digest => {
259 let displaced = record.draft.take();
260 self.write_record(&record)?;
261 let released = self.released_after_commit(displaced.as_slice());
265 Ok(PublishOutcome {
266 entry: existing,
267 released,
268 })
269 }
270 Some(_) => Err(SnapshotError::TemplateVersionExists(format!(
271 "{name}:{version} is already published with different content"
272 ))),
273 };
274 }
275 let mut entry = record.draft.take().ok_or_else(|| {
276 SnapshotError::FailedPrecondition(format!(
277 "template {name} has no draft to publish; run Build first"
278 ))
279 })?;
280 entry.version = version.to_string();
281 record.versions.push(entry.clone());
282 self.write_record(&record)?;
283 Ok(PublishOutcome {
284 entry,
285 released: ReleasedArtifacts::default(),
286 })
287 }
288
289 pub fn resolve(&self, reference: &str) -> Result<ResolvedTemplate> {
291 let (name, version) = parse_reference(reference)?;
292 let record = self
293 .read_record(name)?
294 .ok_or_else(|| SnapshotError::TemplateNotFound(reference.to_string()))?;
295 let entry = match version {
296 Some(v) => record.versions.iter().find(|e| e.version == v).cloned(),
297 None => record
298 .versions
299 .last()
300 .cloned()
301 .or_else(|| record.draft.clone()),
302 }
303 .ok_or_else(|| SnapshotError::TemplateNotFound(reference.to_string()))?;
304 Ok(ResolvedTemplate {
305 name: record.name,
306 entry,
307 })
308 }
309
310 pub fn list(&self) -> Result<Vec<(String, TemplateEntry)>> {
313 let _guard = self.lock.lock().unwrap();
314 let mut rows = Vec::new();
315 for record in self.read_all()? {
316 for entry in record.entries() {
317 rows.push((record.name.clone(), entry.clone()));
318 }
319 }
320 Ok(rows)
321 }
322
323 pub fn delete(&self, reference: &str) -> Result<ReleasedArtifacts> {
326 let (name, version) = parse_reference(reference)?;
327 let _guard = self.lock.lock().unwrap();
328 let mut record = self
329 .read_record(name)?
330 .ok_or_else(|| SnapshotError::TemplateNotFound(reference.to_string()))?;
331 let removed: Vec<TemplateEntry> = match version {
332 None => {
333 let mut entries = std::mem::take(&mut record.versions);
334 entries.extend(record.draft.take());
335 self.remove_record(name)?;
336 entries
337 }
338 Some(v) => {
339 let index = record
340 .versions
341 .iter()
342 .position(|e| e.version == v)
343 .ok_or_else(|| SnapshotError::TemplateNotFound(reference.to_string()))?;
344 let entry = record.versions.remove(index);
345 self.write_record(&record)?;
346 vec![entry]
347 }
348 };
349 Ok(self.released_after_commit(&removed))
350 }
351
352 pub fn references_snapshot(&self, snapshot_id: &str) -> Result<bool> {
357 let _guard = self.lock.lock().unwrap();
358 Ok(self
359 .read_all()?
360 .iter()
361 .flat_map(TemplateRecord::entries)
362 .any(|e| {
363 e.warm
364 .as_ref()
365 .is_some_and(|w| w.snapshot_id == snapshot_id)
366 }))
367 }
368
369 pub fn rootfs_paths(&self) -> Result<BTreeSet<PathBuf>> {
373 let _guard = self.lock.lock().unwrap();
376 let mut pinned = BTreeSet::new();
377 for record in self.read_all()? {
378 for entry in record.entries() {
379 if !entry.rootfs_path.is_empty() {
380 pinned.insert(PathBuf::from(&entry.rootfs_path));
381 }
382 }
383 }
384 Ok(pinned)
385 }
386
387 fn released_after_commit(&self, removed: &[TemplateEntry]) -> ReleasedArtifacts {
396 self.released_by(removed).unwrap_or_else(|error| {
397 let candidates: Vec<&str> = removed
398 .iter()
399 .filter_map(|e| e.warm.as_ref().map(|w| w.snapshot_id.as_str()))
400 .collect();
401 warn!(
402 %error,
403 snapshot_ids = candidates.join(","),
404 "template catalog scan failed after a committed mutation; \
405 skipping artifact release (listed warm snapshots may be \
406 orphaned; reclaim via DeleteSnapshot once the catalog parses)"
407 );
408 ReleasedArtifacts::default()
409 })
410 }
411
412 fn released_by(&self, removed: &[TemplateEntry]) -> Result<ReleasedArtifacts> {
417 let candidates: Vec<String> = removed
418 .iter()
419 .filter_map(|e| e.warm.as_ref().map(|w| w.snapshot_id.clone()))
420 .collect();
421 if candidates.is_empty() {
422 return Ok(ReleasedArtifacts::default());
423 }
424 let live: HashSet<String> = self
425 .read_all()?
426 .iter()
427 .flat_map(TemplateRecord::entries)
428 .filter_map(|e| e.warm.as_ref().map(|w| w.snapshot_id.clone()))
429 .collect();
430 Ok(ReleasedArtifacts {
431 snapshot_ids: candidates
432 .into_iter()
433 .filter(|id| !live.contains(id))
434 .collect(),
435 })
436 }
437
438 fn record_path(&self, name: &str) -> PathBuf {
439 self.root.join(format!("{name}.json"))
440 }
441
442 fn read_record(&self, name: &str) -> Result<Option<TemplateRecord>> {
443 match std::fs::read_to_string(self.record_path(name)) {
444 Ok(json) => Ok(Some(serde_json::from_str(&json)?)),
445 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
446 Err(e) => Err(SnapshotError::from(e)),
447 }
448 }
449
450 fn read_all(&self) -> Result<Vec<TemplateRecord>> {
454 if !self.root.exists() {
455 return Ok(Vec::new());
456 }
457 let mut records = Vec::new();
458 for dirent in std::fs::read_dir(&self.root).map_err(SnapshotError::from)? {
459 let path = dirent.map_err(SnapshotError::from)?.path();
460 if !path.is_file() || path.extension().is_none_or(|ext| ext != "json") {
461 continue;
462 }
463 let json = std::fs::read_to_string(&path).map_err(SnapshotError::from)?;
464 records.push(serde_json::from_str(&json)?);
465 }
466 records.sort_by(|a: &TemplateRecord, b: &TemplateRecord| a.name.cmp(&b.name));
467 Ok(records)
468 }
469
470 fn write_record(&self, record: &TemplateRecord) -> Result<()> {
471 if record.is_empty() {
472 return self.remove_record(&record.name);
473 }
474 std::fs::create_dir_all(&self.root).map_err(SnapshotError::from)?;
475 std::fs::set_permissions(&self.root, {
476 use std::os::unix::fs::PermissionsExt;
477 std::fs::Permissions::from_mode(0o700)
478 })
479 .map_err(SnapshotError::from)?;
480 let bytes = serde_json::to_vec_pretty(record)?;
481 arcbox_atomic_file::write(&self.record_path(&record.name), &bytes)
482 .map_err(SnapshotError::from)
483 }
484
485 fn remove_record(&self, name: &str) -> Result<()> {
486 match std::fs::remove_file(self.record_path(name)) {
487 Ok(()) => std::fs::File::open(&self.root)
488 .and_then(|dir| dir.sync_all())
489 .map_err(SnapshotError::from),
490 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
491 Err(e) => Err(SnapshotError::from(e)),
492 }
493 }
494}
495
496#[must_use]
507#[allow(
508 clippy::implicit_hasher,
509 reason = "callers pass the catalog's std-hasher label maps; a hasher type \
510 parameter would serve zero call sites"
511)]
512pub fn compute_digest(
513 source_identity: &str,
514 warm_identity: Option<&str>,
515 defaults: &TemplateDefaultsSpec,
516 labels: &HashMap<String, String>,
517) -> String {
518 use sha2::{Digest as _, Sha256};
519
520 #[derive(Serialize)]
523 struct CanonicalDefaults<'a> {
524 vcpus: u32,
525 memory_mib: u64,
526 cmd: &'a [String],
527 env: std::collections::BTreeMap<&'a str, &'a str>,
528 exposed_ports: &'a [u32],
529 ready_probe: &'a Option<ReadyProbeSpec>,
530 }
531
532 let canonical = CanonicalDefaults {
533 vcpus: defaults.vcpus,
534 memory_mib: defaults.memory_mib,
535 cmd: &defaults.cmd,
536 env: defaults
537 .env
538 .iter()
539 .map(|(k, v)| (k.as_str(), v.as_str()))
540 .collect(),
541 exposed_ports: &defaults.exposed_ports,
542 ready_probe: &defaults.ready_probe,
543 };
544 let sorted_labels: std::collections::BTreeMap<&str, &str> = labels
545 .iter()
546 .map(|(k, v)| (k.as_str(), v.as_str()))
547 .collect();
548
549 let mut hasher = Sha256::new();
550 for (tag, value) in [
551 ("source", source_identity),
552 ("warm", warm_identity.unwrap_or("")),
553 ] {
554 hasher.update(tag.as_bytes());
555 hasher.update([0]);
556 hasher.update(value.as_bytes());
557 hasher.update([0]);
558 }
559 hasher.update(b"defaults\0");
560 hasher.update(serde_json::to_vec(&canonical).expect("plain data serialization cannot fail"));
561 hasher.update(b"\0labels\0");
562 hasher
563 .update(serde_json::to_vec(&sorted_labels).expect("plain data serialization cannot fail"));
564 format!("sha256:{:x}", hasher.finalize())
565}
566
567pub fn validate_template_name(name: &str) -> Result<()> {
573 validate_component("template name", name, false)
574}
575
576pub fn validate_template_version(version: &str) -> Result<()> {
578 validate_component("template version", version, true)
579}
580
581fn validate_component(kind: &str, value: &str, allow_dot: bool) -> Result<()> {
582 if value.is_empty() {
583 return Err(SnapshotError::config(format!("{kind} must not be empty")));
584 }
585 if value.len() > MAX_COMPONENT_LEN {
586 return Err(SnapshotError::config(format!(
587 "{kind} must be at most {MAX_COMPONENT_LEN} characters"
588 )));
589 }
590 let valid = value
591 .bytes()
592 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || (allow_dot && b == b'.'));
593 if !valid {
594 let extra = if allow_dot { ", '.'" } else { "" };
595 return Err(SnapshotError::config(format!(
596 "invalid {kind} {value:?}: only ASCII letters, digits, '-', '_'{extra} are allowed"
597 )));
598 }
599 Ok(())
600}
601
602fn parse_reference(reference: &str) -> Result<(&str, Option<&str>)> {
604 match reference.split_once(':') {
605 None => {
606 validate_template_name(reference)?;
607 Ok((reference, None))
608 }
609 Some((name, version)) => {
610 validate_template_name(name)?;
611 validate_template_version(version)?;
612 Ok((name, Some(version)))
613 }
614 }
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620 use arcbox_error::CommonError;
621
622 fn entry(digest: &str, rootfs: &str) -> TemplateEntry {
623 TemplateEntry {
624 version: String::new(),
625 digest: digest.to_string(),
626 rootfs_path: rootfs.to_string(),
627 warm: None,
628 defaults: TemplateDefaultsSpec::default(),
629 labels: HashMap::new(),
630 created_at: Utc::now(),
631 size_bytes: 0,
632 }
633 }
634
635 fn warm_entry(digest: &str, snapshot_id: &str) -> TemplateEntry {
636 let mut e = entry(digest, "/cache/rootfs-a.ext4");
637 e.warm = Some(WarmArtifact {
638 snapshot_id: snapshot_id.to_string(),
639 vcpus: 2,
640 memory_mib: 512,
641 });
642 e
643 }
644
645 fn catalog() -> (tempfile::TempDir, TemplateCatalog) {
646 let dir = tempfile::tempdir().unwrap();
647 let catalog = TemplateCatalog::new(dir.path().to_str().unwrap());
648 (dir, catalog)
649 }
650
651 #[test]
652 fn bare_name_resolves_draft_until_publish_then_newest_version() {
653 let (_dir, c) = catalog();
654 c.put_draft("code", entry("sha256:d1", "/cache/r1.ext4"))
655 .unwrap();
656 assert_eq!(c.resolve("code").unwrap().entry.digest, "sha256:d1");
657
658 c.publish("code", "1.0").unwrap();
659 c.put_draft("code", entry("sha256:d2", "/cache/r2.ext4"))
660 .unwrap();
661 let resolved = c.resolve("code").unwrap();
663 assert_eq!(resolved.entry.version, "1.0");
664 assert_eq!(resolved.entry.digest, "sha256:d1");
665
666 c.publish("code", "2.0").unwrap();
667 assert_eq!(c.resolve("code").unwrap().entry.version, "2.0");
668 assert_eq!(c.resolve("code:1.0").unwrap().entry.digest, "sha256:d1");
669 }
670
671 #[test]
672 fn canonical_ref_pins_name_version_and_digest() {
673 let (_dir, c) = catalog();
674 c.put_draft("code", entry("sha256:d1", "/r.ext4")).unwrap();
675 assert_eq!(
676 c.resolve("code").unwrap().canonical_ref(),
677 "code:@sha256:d1"
678 );
679 c.publish("code", "1.0").unwrap();
680 assert_eq!(
681 c.resolve("code").unwrap().canonical_ref(),
682 "code:1.0@sha256:d1"
683 );
684 }
685
686 #[test]
687 fn missing_references_fail_with_the_classifier_prefix() {
688 let (_dir, c) = catalog();
689 let err = c.resolve("ghost").unwrap_err();
690 assert!(err.to_string().starts_with("template not found:"), "{err}");
691 c.put_draft("code", entry("sha256:d1", "/r.ext4")).unwrap();
692 assert!(matches!(
693 c.resolve("code:9.9").unwrap_err(),
694 SnapshotError::TemplateNotFound(_)
695 ));
696 }
697
698 #[test]
699 fn publish_is_idempotent_for_retries_but_rejects_content_clash() {
700 let (_dir, c) = catalog();
701 c.put_draft("code", entry("sha256:d1", "/r.ext4")).unwrap();
702 c.publish("code", "1.0").unwrap();
703 assert_eq!(c.publish("code", "1.0").unwrap().entry.digest, "sha256:d1");
705 c.put_draft("code", entry("sha256:d1", "/r.ext4")).unwrap();
707 c.publish("code", "1.0").unwrap();
708 assert_eq!(c.resolve("code").unwrap().entry.version, "1.0");
709 c.put_draft("code", entry("sha256:d2", "/r.ext4")).unwrap();
711 assert!(matches!(
712 c.publish("code", "1.0").unwrap_err(),
713 SnapshotError::TemplateVersionExists(_)
714 ));
715 assert_eq!(c.publish("code", "1.1").unwrap().entry.digest, "sha256:d2");
717 }
718
719 #[test]
720 fn collapsing_a_same_digest_draft_releases_its_fresh_snapshot() {
721 let (_dir, c) = catalog();
722 c.put_draft("code", warm_entry("sha256:d1", "snap-a"))
723 .unwrap();
724 c.publish("code", "1.0").unwrap();
725 c.put_draft("code", warm_entry("sha256:d1", "snap-b"))
728 .unwrap();
729 let outcome = c.publish("code", "1.0").unwrap();
730 assert_eq!(outcome.entry.warm.as_ref().unwrap().snapshot_id, "snap-a");
731 assert_eq!(outcome.released.snapshot_ids, vec!["snap-b".to_string()]);
732 }
733
734 #[test]
735 fn a_corrupt_sibling_record_does_not_fail_a_committed_mutation() {
736 let (dir, c) = catalog();
737 c.put_draft("code", warm_entry("sha256:d1", "snap-1"))
738 .unwrap();
739 std::fs::write(
740 dir.path().join("template-catalog").join("broken.json"),
741 b"not json",
742 )
743 .unwrap();
744 let released = c.delete("code").unwrap();
747 assert_eq!(released.snapshot_ids, Vec::<String>::new());
748 assert!(matches!(
749 c.resolve("code").unwrap_err(),
750 SnapshotError::TemplateNotFound(_)
751 ));
752 }
753
754 #[test]
755 fn publish_without_a_draft_is_a_precondition_failure() {
756 let (_dir, c) = catalog();
757 c.put_draft("code", entry("sha256:d1", "/r.ext4")).unwrap();
758 c.publish("code", "1.0").unwrap();
759 assert!(matches!(
760 c.publish("code", "2.0").unwrap_err(),
761 SnapshotError::FailedPrecondition(_)
762 ));
763 assert!(matches!(
764 c.publish("ghost", "1.0").unwrap_err(),
765 SnapshotError::TemplateNotFound(_)
766 ));
767 }
768
769 #[test]
770 fn delete_version_and_whole_template_release_unreferenced_snapshots() {
771 let (_dir, c) = catalog();
772 c.put_draft("code", warm_entry("sha256:d1", "snap-1"))
773 .unwrap();
774 c.publish("code", "1.0").unwrap();
775 c.put_draft("code", warm_entry("sha256:d2", "snap-2"))
776 .unwrap();
777
778 let released = c.delete("code:1.0").unwrap();
779 assert_eq!(released.snapshot_ids, vec!["snap-1".to_string()]);
780 assert_eq!(c.resolve("code").unwrap().entry.digest, "sha256:d2");
782
783 let released = c.delete("code").unwrap();
784 assert_eq!(released.snapshot_ids, vec!["snap-2".to_string()]);
785 assert!(matches!(
786 c.resolve("code").unwrap_err(),
787 SnapshotError::TemplateNotFound(_)
788 ));
789 assert!(matches!(
790 c.delete("code").unwrap_err(),
791 SnapshotError::TemplateNotFound(_)
792 ));
793 }
794
795 #[test]
796 fn a_still_referenced_snapshot_is_not_released() {
797 let (_dir, c) = catalog();
798 c.put_draft("a", warm_entry("sha256:d1", "shared")).unwrap();
800 c.publish("a", "1.0").unwrap();
801 c.put_draft("b", warm_entry("sha256:d2", "shared")).unwrap();
802
803 assert_eq!(c.delete("a").unwrap().snapshot_ids, Vec::<String>::new());
804 assert_eq!(
805 c.delete("b").unwrap().snapshot_ids,
806 vec!["shared".to_string()]
807 );
808 }
809
810 #[test]
811 fn displacing_a_draft_releases_its_exclusive_snapshot() {
812 let (_dir, c) = catalog();
813 c.put_draft("code", warm_entry("sha256:d1", "snap-old"))
814 .unwrap();
815 let outcome = c
816 .put_draft("code", warm_entry("sha256:d2", "snap-new"))
817 .unwrap();
818 assert_eq!(outcome.released.snapshot_ids, vec!["snap-old".to_string()]);
819 assert_eq!(outcome.entry.version, "");
820 }
821
822 #[test]
823 fn list_orders_by_name_then_publish_order_with_draft_last() {
824 let (_dir, c) = catalog();
825 c.put_draft("b", entry("sha256:b1", "/rb.ext4")).unwrap();
826 c.put_draft("a", entry("sha256:a1", "/ra.ext4")).unwrap();
827 c.publish("a", "1.0").unwrap();
828 c.put_draft("a", entry("sha256:a2", "/ra2.ext4")).unwrap();
829
830 let rows = c.list().unwrap();
831 let keys: Vec<String> = rows
832 .iter()
833 .map(|(name, e)| format!("{name}:{}", e.version))
834 .collect();
835 assert_eq!(keys, vec!["a:1.0", "a:", "b:"]);
836 }
837
838 #[test]
839 fn rootfs_paths_pin_every_entry_including_drafts() {
840 let (_dir, c) = catalog();
841 c.put_draft("a", entry("sha256:a1", "/cache/ra.ext4"))
842 .unwrap();
843 c.publish("a", "1.0").unwrap();
844 c.put_draft("a", entry("sha256:a2", "/cache/ra2.ext4"))
845 .unwrap();
846 c.put_draft("b", entry("sha256:b1", "/cache/rb.ext4"))
847 .unwrap();
848
849 let pinned = c.rootfs_paths().unwrap();
850 for path in ["/cache/ra.ext4", "/cache/ra2.ext4", "/cache/rb.ext4"] {
851 assert!(pinned.contains(Path::new(path)), "missing {path}");
852 }
853 }
854
855 #[test]
856 fn digest_is_deterministic_and_input_sensitive() {
857 let mut defaults = TemplateDefaultsSpec {
858 vcpus: 2,
859 ..Default::default()
860 };
861 defaults.env.insert("A".into(), "1".into());
862 defaults.env.insert("B".into(), "2".into());
863 let labels = HashMap::from([("team".to_string(), "ml".to_string())]);
864
865 let d1 = compute_digest("rootfs-aa-bb", Some("snap-1"), &defaults, &labels);
866 let d2 = compute_digest("rootfs-aa-bb", Some("snap-1"), &defaults, &labels);
867 assert_eq!(d1, d2, "same inputs must reproduce the digest");
868 assert!(d1.starts_with("sha256:"));
869
870 for (source, warm) in [
871 ("rootfs-aa-cc", Some("snap-1")),
872 ("rootfs-aa-bb", Some("snap-2")),
873 ("rootfs-aa-bb", None),
874 ] {
875 assert_ne!(d1, compute_digest(source, warm, &defaults, &labels));
876 }
877 let mut other = defaults.clone();
878 other.cmd = vec!["python".into()];
879 assert_ne!(
880 d1,
881 compute_digest("rootfs-aa-bb", Some("snap-1"), &other, &labels)
882 );
883 assert_ne!(
884 d1,
885 compute_digest("rootfs-aa-bb", Some("snap-1"), &defaults, &HashMap::new())
886 );
887 }
888
889 #[test]
890 fn records_survive_a_catalog_reload() {
891 let (dir, c) = catalog();
892 c.put_draft("code", warm_entry("sha256:d1", "snap-1"))
893 .unwrap();
894 c.publish("code", "1.0").unwrap();
895
896 let reloaded = TemplateCatalog::new(dir.path().to_str().unwrap());
897 let resolved = reloaded.resolve("code:1.0").unwrap();
898 assert_eq!(resolved.entry.digest, "sha256:d1");
899 assert_eq!(resolved.entry.warm.as_ref().unwrap().snapshot_id, "snap-1");
900 }
901
902 #[test]
903 fn names_and_versions_reject_traversal_and_grammar_collisions() {
904 let (_dir, c) = catalog();
905 for bad in ["", "../etc", "a/b", "a b", ":1.0", "名字", &"x".repeat(65)] {
906 assert!(
907 matches!(
908 c.resolve(bad),
909 Err(SnapshotError::Common(CommonError::Config(_)))
910 ),
911 "accepted {bad:?}"
912 );
913 }
914 assert!(matches!(
916 c.resolve("code:1.0@sha256"),
917 Err(SnapshotError::Common(CommonError::Config(_)))
918 ));
919 assert!(matches!(
920 c.put_draft("docker:img", entry("d", "/r")).unwrap_err(),
921 SnapshotError::Common(CommonError::Config(_))
922 ));
923 }
924}