Skip to main content

arcbox_snapshot/
template_catalog.rs

1//! On-disk template catalog (CORE-107).
2//!
3//! A template is a named, versioned sandbox base: a built rootfs plus a
4//! default-config bundle, optionally paired with a pre-warmed boot-to-ready
5//! snapshot (`template.proto`). The catalog persists metadata only — one
6//! atomically-replaced JSON file per template name under
7//! `{data_dir}/template-catalog/` — while the artifacts live elsewhere and
8//! are referenced, never copied: rootfs images in the converted-rootfs cache
9//! (pinned through [`TemplateCatalog::rootfs_paths`]) and warm snapshots in
10//! the [`SnapshotCatalog`](crate::snapshot::SnapshotCatalog) under the
11//! [`TEMPLATE_LABEL`] reserved label.
12//!
13//! Versioning model: `Build` installs the mutable draft slot; `Publish`
14//! freezes the draft as an immutable version (newest published wins bare-name
15//! resolution). Version strings are caller-chosen and opaque — "newest" means
16//! publish order, never a semver comparison.
17
18use 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
28/// Directory under the vmm data dir holding one JSON file per template name.
29const CATALOG_DIR: &str = "template-catalog";
30
31/// Maximum length of a template name or version string.
32const MAX_COMPONENT_LEN: usize = 64;
33
34/// Reserved snapshot label marking a snapshot as owned by a catalog template.
35///
36/// The value is the owning template name. The checkpoint surface keeps user
37/// checkpoints from squatting on it and template artifacts out of user-facing
38/// snapshot lists; deleting such a snapshot goes through template deletion.
39pub const TEMPLATE_LABEL: &str = "arcbox.template";
40
41/// Readiness probe recorded in template defaults.
42///
43/// The native mirror of `ReadyProbe` (`template.proto`): how to decide a
44/// sandbox created from the template is ready for use. Enforcement gates the
45/// READY transition; expiry marks the sandbox FAILED.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub enum ReadyProbeSpec {
48    /// Ready when something inside the sandbox listens on this TCP port.
49    Port {
50        port: u16,
51        /// Seconds before giving up (0 = daemon default).
52        timeout_seconds: u32,
53    },
54    /// Ready when this command, run inside the sandbox, exits 0.
55    Command {
56        cmd: Vec<String>,
57        /// Seconds before giving up (0 = daemon default).
58        timeout_seconds: u32,
59    },
60}
61
62/// Default configuration a template applies to sandboxes created from it.
63///
64/// The native mirror of `TemplateDefaults` (`template.proto`). Zero `vcpus` /
65/// `memory_mib` mean "no template default" (the daemon default applies); the
66/// per-create override rules live on `CreateSandboxRequest`.
67#[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    /// Advisory: ports the workload is expected to listen on, so clients can
78    /// expose them without knowing the workload. Never acted on at create.
79    #[serde(default)]
80    pub exposed_ports: Vec<u32>,
81    #[serde(default)]
82    pub ready_probe: Option<ReadyProbeSpec>,
83}
84
85/// A pre-warmed boot-to-ready snapshot plus the geometry it requires.
86///
87/// A memory snapshot restores only onto identical vcpus/memory, so geometry
88/// is part of the artifact — create gates template-restore on an exact match
89/// and cold-boots otherwise.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct WarmArtifact {
92    /// Snapshot id in the snapshot catalog (carries [`TEMPLATE_LABEL`]).
93    pub snapshot_id: String,
94    pub vcpus: u32,
95    pub memory_mib: u64,
96}
97
98/// One built template version, or the unpublished draft.
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct TemplateEntry {
101    /// Published version string; empty in the draft slot.
102    #[serde(default)]
103    pub version: String,
104    /// Content digest pinning this entry's artifacts (`sha256:<hex>`),
105    /// computed at build time over source identity + rootfs fingerprint +
106    /// warm snapshot + defaults + labels.
107    pub digest: String,
108    /// Guest-absolute path of the built rootfs image in the rootfs cache.
109    pub rootfs_path: String,
110    /// Pre-warmed snapshot, when the template carries one.
111    #[serde(default)]
112    pub warm: Option<WarmArtifact>,
113    #[serde(default)]
114    pub defaults: TemplateDefaultsSpec,
115    /// Arbitrary key-value metadata (filterable in List).
116    #[serde(default)]
117    pub labels: HashMap<String, String>,
118    /// When this entry was built (draft) or its content built (versions keep
119    /// the build time, not the publish time).
120    pub created_at: DateTime<Utc>,
121    /// On-disk footprint of the artifacts (rootfs + warm snapshot).
122    #[serde(default)]
123    pub size_bytes: u64,
124}
125
126/// Durable record for one template name: the current draft plus every
127/// published version in publish order (newest last).
128#[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/// A `name[:version]` reference resolved against the catalog.
148#[derive(Debug, Clone)]
149pub struct ResolvedTemplate {
150    /// Template name (no version suffix).
151    pub name: String,
152    /// The resolved entry: the exact version, or — for a bare name — the
153    /// newest published version, falling back to the draft when nothing is
154    /// published.
155    pub entry: TemplateEntry,
156}
157
158impl ResolvedTemplate {
159    /// Canonical pinned reference: `name:version@digest` (`name:@digest` for
160    /// drafts). Substituted into `CreateSandboxRequest.template` before the
161    /// idempotent create key is computed, so a retried create after a Publish
162    /// diverges instead of silently replaying the old content.
163    #[must_use]
164    pub fn canonical_ref(&self) -> String {
165        format!("{}:{}@{}", self.name, self.entry.version, self.entry.digest)
166    }
167}
168
169/// Result of installing a draft: the stored entry plus any artifacts the
170/// displaced previous draft owned exclusively.
171#[derive(Debug)]
172pub struct PutDraftOutcome {
173    /// The entry as stored (version forced empty).
174    pub entry: TemplateEntry,
175    pub released: ReleasedArtifacts,
176}
177
178/// Result of publishing: the frozen version plus any released artifacts.
179///
180/// An idempotent re-publish can collapse a rebuilt same-digest draft whose
181/// warm snapshot the published version never shared; that snapshot comes
182/// back here for the caller to reclaim.
183#[derive(Debug)]
184pub struct PublishOutcome {
185    /// The published entry.
186    pub entry: TemplateEntry,
187    pub released: ReleasedArtifacts,
188}
189
190/// Snapshot ids released by a catalog mutation.
191///
192/// Owned by removed entries and referenced by no remaining entry anywhere in
193/// the catalog. The caller (`ComputerManager`) deletes them from the snapshot
194/// catalog — the catalog itself never touches artifacts.
195#[derive(Debug, Default)]
196pub struct ReleasedArtifacts {
197    pub snapshot_ids: Vec<String>,
198}
199
200/// The on-disk template catalog: one atomically-replaced JSON per name.
201pub struct TemplateCatalog {
202    root: PathBuf,
203    /// Serializes read-modify-write cycles within this process. Cross-process
204    /// safety is not a goal: one agent owns the data dir.
205    lock: Mutex<()>,
206}
207
208impl TemplateCatalog {
209    /// Create a catalog rooted under `data_dir`. The directory is created
210    /// lazily on first write; a missing directory reads as an empty catalog.
211    #[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    /// Install `entry` as the draft for `name`, displacing any previous
220    /// draft. The stored entry's `version` is forced empty.
221    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    /// Freeze the current draft as immutable `version`.
237    ///
238    /// Idempotent against retries: re-publishing an existing version succeeds
239    /// when there is no draft (the earlier attempt committed) or when the
240    /// draft's digest matches the published content — releasing that draft's
241    /// exclusively-owned artifacts; a digest clash is
242    /// [`SnapshotError::TemplateVersionExists`] (the divergent draft survives for
243    /// publishing under a new version, and a later `put_draft` releases it).
244    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                    // Refcount after the write so the scan observes the
262                    // record without the draft, or the snapshot still looks
263                    // referenced and is never released.
264                    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    /// Resolve a `name` or `name:version` reference.
290    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    /// Every catalog row: per name (lexical order), published versions in
311    /// publish order, then the draft.
312    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    /// Delete a template version (`name:version`) or a whole template with
324    /// all its versions and its draft (`name`).
325    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    /// True when any catalog entry references `snapshot_id` as its warm
353    /// artifact. The checkpoint surface's deletion guard keys on this so an
354    /// orphaned template-labeled snapshot (record gone after a failed
355    /// post-commit cleanup) stays reclaimable via `DeleteSnapshot`.
356    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    /// Rootfs images that catalog entries need to stay bootable — a pin
370    /// source for the converted-rootfs cache sweep, alongside
371    /// [`SnapshotCatalog::referenced_rootfs_paths`](crate::snapshot::SnapshotCatalog::referenced_rootfs_paths).
372    pub fn rootfs_paths(&self) -> Result<BTreeSet<PathBuf>> {
373        // Held so a listing cannot straddle a mutation and momentarily miss
374        // a path the cache sweep must keep pinned.
375        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    /// [`Self::released_by`] for the post-commit position: the mutation is
388    /// already durable, so a failed catalog-wide scan (an unrelated corrupt
389    /// record) must not turn into an error the caller would read as "nothing
390    /// happened". Releasing nothing is the safe direction — a logged orphan,
391    /// never a deleted snapshot something still references. The warning names
392    /// every candidate id: `list_checkpoints` hides template-labeled
393    /// snapshots, so this log line is how an operator discovers what to
394    /// reclaim with `DeleteSnapshot` once the catalog reads cleanly.
395    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    /// Snapshot ids among `removed` that no remaining catalog entry
413    /// references. Warm snapshots are created per build and never shared, but
414    /// the sweep is reference-counted anyway so a future sharing scheme can't
415    /// silently turn deletion into corruption.
416    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    /// Read every record. Strict by design: a corrupt record is real
451    /// corruption, and treating it as absent would unpin rootfs images the
452    /// cache sweep must not reclaim.
453    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/// Deterministic content digest for a template entry (`sha256:<hex>`).
497///
498/// Inputs are content identities, never file metadata, so an identical
499/// rebuild reproduces the digest: `source_identity` (the rootfs cache file
500/// stem for image builds — it encodes the layer content key and the injected
501/// vm-agent key — or the promoted ORIGIN snapshot id), `warm_identity` (a
502/// content tag like `prewarmed:<geometry>` or `promoted` — never a freshly
503/// minted snapshot id, which would randomize the digest per build), and the
504/// canonicalized defaults and labels. Field-tagged and NUL-separated so
505/// adjacent inputs cannot alias.
506#[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    /// Canonical serialization shadow: map fields become ordered so the JSON
521    /// bytes are deterministic regardless of `HashMap` iteration order.
522    #[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
567/// Validate a template name.
568///
569/// Names become file names, snapshot `vm_id` components, and reference
570/// prefixes, so they share the sandbox-id charset; `:` is additionally
571/// impossible, keeping the `name[:version]` grammar unambiguous.
572pub fn validate_template_name(name: &str) -> Result<()> {
573    validate_component("template name", name, false)
574}
575
576/// Validate a template version string (`[A-Za-z0-9._-]`, e.g. "1.2.0").
577pub 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
602/// Split a `name[:version]` reference and validate both components.
603fn 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        // Published wins over the newer draft on a bare name.
662        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        // Retry after commit (no draft left): succeeds, returns the version.
704        assert_eq!(c.publish("code", "1.0").unwrap().entry.digest, "sha256:d1");
705        // Identical rebuild then re-publish: idempotent, draft consumed.
706        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        // Different content under a taken version: refused.
710        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        // The clashing draft survives for publishing under a new version.
716        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        // A rebuild can produce identical content with a freshly allocated
726        // warm snapshot; the idempotent re-publish must not leak it.
727        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        // The delete commits and reports success; the scan failure only
745        // suppresses artifact release (logged orphan, safe direction).
746        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        // Draft survives a version delete.
781        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        // Defensive cross-reference: two entries sharing one snapshot id.
799        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        // Versions allow dots; '@' stays reserved for canonical refs.
915        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}