Skip to main content

cuttlefish_host/
catalog.rs

1//! The local block catalog: maps `name@version` to a cataloged wasm block or
2//! bundle, so a pipeline can reference a block by name instead of a
3//! filesystem path.
4//!
5//! Storage is content-addressed and flat — no entry references another —
6//! because a bundle's internal node structure lives entirely in its own
7//! `.cfbundle` manifest, resolved to concrete blob hashes at build time; the
8//! catalog index itself never needs to represent "this entry depends on that
9//! entry."
10//!
11//! ```text
12//! ~/.cuttlefish/catalog/
13//!   blobs/<sha256>        raw wasm or bundle bytes, one copy per unique artifact
14//!   index.json            name@version -> Entry
15//!   index.json.lock       empty lock file guarding read-modify-write of index.json
16//! ```
17//!
18//! See `docs/superpowers/specs/2026-08-02-block-catalog-design.md` for the
19//! full design (that file is a gitignored working document, not committed;
20//! this module doc is the durable record, per this project's
21//! documentation-lives-in-the-code convention).
22
23use std::collections::BTreeMap;
24use std::fs::{self, File};
25use std::io::Write;
26use std::path::{Path, PathBuf};
27
28use serde::{Deserialize, Serialize};
29
30/// Current schema version of `index.json`'s own on-disk format — bumped only
31/// when the *shape* of the index changes, never tied to this crate's version.
32const INDEX_VERSION: u32 = 1;
33
34/// Whether a cataloged artifact is a single wasm block or a multi-node bundle.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum ArtifactKind {
38    /// A single compiled wasm module.
39    Block,
40    /// A `.cfbundle` container produced by `cuttlefish build`.
41    Bundle,
42}
43
44/// One cataloged `name@version`.
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct Entry {
47    /// Content hash of the artifact, as `sha256:<hex>`.
48    pub hash: String,
49    /// Block or bundle.
50    pub kind: ArtifactKind,
51    /// Compact `{input} -> {output}` signature string, cached at add-time so
52    /// `list`/`show` never have to instantiate wasm just to answer "what does
53    /// this accept and produce."
54    pub signature: String,
55    /// RFC 3339 timestamp, truncated to whole seconds, UTC. Used only to
56    /// order "give me the latest" and to break did-you-mean ties — truncating
57    /// to whole seconds keeps the string plain-comparable without parsing it
58    /// back.
59    pub created_at: String,
60}
61
62/// The whole on-disk `index.json`.
63#[derive(Debug, Serialize, Deserialize)]
64struct IndexFile {
65    version: u32,
66    entries: BTreeMap<String, Entry>,
67    /// `name@version` -> the hash it was published with, for versions that
68    /// have since been removed. Keeps `rm` from being a way to launder a
69    /// republish: the identity stays claimed by its original content even
70    /// after the entry is gone.
71    ///
72    /// `default` rather than an `INDEX_VERSION` bump on purpose — an index
73    /// written before this field existed is a perfectly good version-1 index
74    /// with nothing retired, and bumping the version would make every
75    /// already-written catalog report itself as corrupt.
76    #[serde(default)]
77    retired: BTreeMap<String, String>,
78}
79
80impl IndexFile {
81    fn empty() -> Self {
82        Self {
83            version: INDEX_VERSION,
84            entries: BTreeMap::new(),
85            retired: BTreeMap::new(),
86        }
87    }
88}
89
90/// Characters legal in either half of a `name@version`. Deliberately narrow:
91/// a catalog identifier is a key people type and scripts interpolate, so
92/// whitespace and path separators earn nothing and invite confusion between
93/// an identifier and a filesystem path.
94fn is_legal_identifier_char(c: char) -> bool {
95    c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')
96}
97
98/// Check that `s` is a well-formed `name@version` before it can be written
99/// into the index.
100///
101/// This guards the write path only. `show`/`rm`/`resolve` stay permissive so
102/// that a key already in an index — hand-edited, or written before this check
103/// existed — remains inspectable and removable rather than stranded.
104fn validate_name_version(s: &str) -> Result<(), CatalogError> {
105    let invalid = |reason: String| CatalogError::InvalidNameVersion {
106        name_version: s.to_string(),
107        reason,
108    };
109
110    let separators = s.matches('@').count();
111    if separators != 1 {
112        return Err(invalid(match separators {
113            0 => "expected <name>@<version>, e.g. echo-summarize@1".to_string(),
114            n => format!("found {n} '@' separators"),
115        }));
116    }
117
118    let (name, version) = s.split_once('@').expect("exactly one '@' is present");
119    if name.is_empty() {
120        return Err(invalid("the name is empty".to_string()));
121    }
122    if version.is_empty() {
123        return Err(invalid("the version is empty".to_string()));
124    }
125
126    for (half, label) in [(name, "name"), (version, "version")] {
127        if let Some(bad) = half.chars().find(|c| !is_legal_identifier_char(*c)) {
128            return Err(invalid(format!(
129                "the {label} contains {bad:?}; only letters, digits, '.', '-' and '_' are allowed"
130            )));
131        }
132    }
133
134    Ok(())
135}
136
137/// Something went wrong reading, writing, or resolving through the catalog.
138#[derive(Debug, thiserror::Error)]
139pub enum CatalogError {
140    /// `name@version` was already catalogued; versions are immutable once published.
141    #[error("{name_version} is already catalogued; versions are immutable once published")]
142    AlreadyExists {
143        /// The name@version that was already present.
144        name_version: String,
145    },
146    /// The identifier handed to `add` is not a well-formed `name@version`.
147    #[error("{name_version:?} is not a name@version ({reason})")]
148    InvalidNameVersion {
149        /// The identifier that was rejected.
150        name_version: String,
151        /// Why it was rejected, as a sentence fragment.
152        reason: String,
153    },
154    /// `name@version` was published, then removed, and is now being re-added
155    /// with different content. Removing an entry drops it from the index but
156    /// does not un-publish the identity, so this is still the immutability
157    /// violation `AlreadyExists` guards against — just spread over two steps.
158    #[error(
159        "{name_version} was previously catalogued with different content; versions are \
160         immutable once published ({previous_hash} -> {new_hash})"
161    )]
162    RetiredWithDifferentContent {
163        /// The name@version being re-added.
164        name_version: String,
165        /// The hash it was published with originally.
166        previous_hash: String,
167        /// The hash of the artifact now being offered.
168        new_hash: String,
169    },
170    /// No entry matches the requested `name@version`.
171    #[error(
172        "no such catalog entry: {name_version}{}",
173        format_did_you_mean(did_you_mean)
174    )]
175    NotFound {
176        /// The name@version that was requested.
177        name_version: String,
178        /// Names within edit distance 2 of the requested one, closest first,
179        /// capped at 5. Empty when nothing is close.
180        did_you_mean: Vec<String>,
181    },
182    /// An unqualified name was used somewhere that requires an exact version
183    /// (resolving a node reference already recorded inside a bundle's
184    /// manifest — see `ResolutionContext::Durable`).
185    #[error("{name} has no version — an exact name@version is required here")]
186    UnqualifiedName {
187        /// The unqualified name that was rejected.
188        name: String,
189    },
190    /// The catalog's own `index.json` failed to parse.
191    #[error("catalog index at {path} is corrupt: {reason}")]
192    CorruptIndex {
193        /// Path to the unreadable index file.
194        path: PathBuf,
195        /// What went wrong parsing it.
196        reason: String,
197    },
198    /// The artifact's magic bytes match neither a wasm module nor a bundle.
199    #[error("{path}: not a recognised artifact (header: {header:02x?})")]
200    UnrecognizedArtifact {
201        /// The path that was handed to `add`.
202        path: PathBuf,
203        /// The first bytes actually seen.
204        header: Vec<u8>,
205    },
206    /// The artifact's magic bytes were recognised, but its contents could not
207    /// be read: a wasm-magic file whose module body is truncated or
208    /// otherwise invalid, or a bundle-magic file whose manifest JSON fails to
209    /// parse. Distinct from `CorruptIndex` (that's the catalog's own
210    /// bookkeeping file, not an input artifact) and from
211    /// `UnrecognizedArtifact` (that's the magic byte itself not matching
212    /// anything).
213    #[error("{path}: {reason}")]
214    UninspectableArtifact {
215        /// The path that was handed to `add`, or a synthetic label standing
216        /// in for one — `read_bundle_signature` takes a `label: &str` that
217        /// need not be a real filesystem path. `add` always passes a real
218        /// on-disk path, but `pipeline::check` calls it with a stage's
219        /// display name, which for a `Cataloged` stage (built by
220        /// `pipeline::resolve_and_load` from a bare catalog name, not a
221        /// filesystem path) is just that bare name.
222        path: PathBuf,
223        /// What went wrong reading past the magic bytes.
224        reason: String,
225    },
226    /// An entry's `hash` came back from `index.json` in a shape that isn't a
227    /// well-formed sha256 digest (64 lowercase hex digits, optionally
228    /// prefixed with `sha256:`) — the exact shape `write_blob` always
229    /// produces. `index.json` is never format-validated on read, so a
230    /// hand-edited or maliciously crafted index could otherwise smuggle
231    /// `../` traversal or an absolute path into a filesystem join; this is
232    /// the guard that rejects it before that ever happens. Distinct from
233    /// `CorruptIndex` (that's `index.json` failing to *parse* as JSON at
234    /// all; this is JSON that parses fine but whose `hash` field is
235    /// nonsense).
236    #[error(
237        "catalog entry has a malformed hash {hash:?}: expected sha256:<64 lowercase hex digits>"
238    )]
239    MalformedHash {
240        /// The invalid hash value, exactly as found in the entry.
241        hash: String,
242    },
243    /// Underlying I/O failure — a plain failure to open/read/write a path,
244    /// before any catalog-specific logic ever inspects the bytes.
245    #[error(transparent)]
246    Io(#[from] std::io::Error),
247}
248
249/// Render `did_you_mean` as a message suffix, or nothing if it's empty —
250/// never a dangling "(did you mean: ?)" for a genuinely unmatched name.
251fn format_did_you_mean(names: &[String]) -> String {
252    if names.is_empty() {
253        String::new()
254    } else {
255        format!(" (did you mean: {}?)", names.join(", "))
256    }
257}
258
259/// Levenshtein edit distance between two strings, by character.
260fn levenshtein(a: &str, b: &str) -> usize {
261    let a: Vec<char> = a.chars().collect();
262    let b: Vec<char> = b.chars().collect();
263    let mut prev: Vec<usize> = (0..=b.len()).collect();
264    let mut curr = vec![0usize; b.len() + 1];
265
266    for i in 1..=a.len() {
267        curr[0] = i;
268        for j in 1..=b.len() {
269            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
270            curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
271        }
272        std::mem::swap(&mut prev, &mut curr);
273    }
274    prev[b.len()]
275}
276
277/// Pick up to 5 catalogued names within edit distance 2 of `target_name`
278/// (compared as bare names, with any `@version` stripped from both sides),
279/// closest first, ties broken by `created_at` ascending (oldest first). A
280/// prefix match misses common real typos (`summarise`/`summarize` share no
281/// prefix relationship); edit distance catches them.
282fn pick_did_you_mean(target_name: &str, entries: &BTreeMap<String, Entry>) -> Vec<String> {
283    let target_name = target_name.split('@').next().unwrap_or(target_name);
284    const MAX_DISTANCE: usize = 2;
285    const LIMIT: usize = 5;
286
287    // Suggest the newest version of each close name, not every version of it.
288    let mut by_name: BTreeMap<&str, (&str, &str)> = BTreeMap::new();
289    for (name_version, entry) in entries {
290        let name = name_version.split('@').next().unwrap_or(name_version);
291        if levenshtein(target_name, name) > MAX_DISTANCE {
292            continue;
293        }
294        by_name
295            .entry(name)
296            .and_modify(|(nv, created)| {
297                if entry.created_at.as_str() > *created {
298                    *nv = name_version;
299                    *created = entry.created_at.as_str();
300                }
301            })
302            .or_insert((name_version, entry.created_at.as_str()));
303    }
304
305    let mut candidates: Vec<(usize, &str, &str)> = by_name
306        .into_iter()
307        .map(|(name, (nv, created))| (levenshtein(target_name, name), nv, created))
308        .collect();
309    candidates.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.2.cmp(b.2)));
310
311    candidates
312        .into_iter()
313        .take(LIMIT)
314        .map(|(_, nv, _)| nv.to_string())
315        .collect()
316}
317
318const WASM_MAGIC: &[u8] = b"\0asm";
319/// The one source of truth for the `.cfbundle` container's magic bytes —
320/// shared with `bundle::build` (the writer) so the two can never drift
321/// apart the way `manifest_len`'s endianness once had to be pinned down
322/// after the fact.
323pub(crate) const BUNDLE_MAGIC: &[u8; 4] = b"CFBD";
324/// `.cfbundle`'s fixed header size: `BUNDLE_MAGIC` (4 bytes) + `manifest_len`
325/// as a little-endian `u64` (8 bytes). Shared with `bundle::build`, which
326/// writes exactly this many bytes before the manifest.
327pub(crate) const BUNDLE_HEADER_LEN: usize = BUNDLE_MAGIC.len() + 8;
328
329/// Identify an artifact by its magic bytes, never by file extension — the
330/// same "classify by content" rule `handles::classify` already applies to
331/// input files. `None` means neither magic matched; the caller turns that
332/// into `CatalogError::UnrecognizedArtifact`, naming the path.
333pub(crate) fn sniff_artifact_kind(bytes: &[u8]) -> Option<ArtifactKind> {
334    if bytes.starts_with(WASM_MAGIC) {
335        Some(ArtifactKind::Block)
336    } else if bytes.starts_with(BUNDLE_MAGIC) {
337        Some(ArtifactKind::Bundle)
338    } else {
339        None
340    }
341}
342
343fn index_path(root: &Path) -> PathBuf {
344    root.join("index.json")
345}
346
347fn lock_path(root: &Path) -> PathBuf {
348    root.join("index.json.lock")
349}
350
351/// Read `index.json`. A missing file is a brand-new, empty catalog — not an
352/// error. A file that exists but fails to parse, or whose `version` this
353/// build doesn't understand, is `CatalogError::CorruptIndex` — loud, never
354/// silently treated as empty (an empty-looking catalog after real entries
355/// were written would make every subsequent `add` "work" while quietly
356/// discarding everything that came before it).
357fn read_index(root: &Path) -> Result<IndexFile, CatalogError> {
358    let path = index_path(root);
359    if !path.exists() {
360        return Ok(IndexFile::empty());
361    }
362
363    let bytes = fs::read(&path)?;
364    let index: IndexFile =
365        serde_json::from_slice(&bytes).map_err(|e| CatalogError::CorruptIndex {
366            path: path.clone(),
367            reason: e.to_string(),
368        })?;
369
370    if index.version != INDEX_VERSION {
371        return Err(CatalogError::CorruptIndex {
372            path,
373            reason: format!(
374                "index format version {} is not supported by this build (expected {INDEX_VERSION})",
375                index.version
376            ),
377        });
378    }
379
380    Ok(index)
381}
382
383/// Acquire the exclusive lock on `index.json.lock`, read-modify-write
384/// `index.json` atomically (write to a temp file, `fsync`, then rename), and
385/// return. The temp-file-then-rename means a reader racing this write always
386/// sees either the fully-old or fully-new file, never a partial one — reads
387/// (`list`/`show`) never need to take the lock at all.
388///
389/// The lock is released by `lock_file` simply going out of scope (an
390/// OS-level advisory lock is tied to the open file handle) on every return
391/// path, including the early return from `f(&mut index)?` below — there is
392/// no separate `unlock()` call to forget on an error path.
393fn with_locked_index<T>(
394    root: &Path,
395    f: impl FnOnce(&mut IndexFile) -> Result<T, CatalogError>,
396) -> Result<T, CatalogError> {
397    fs::create_dir_all(root)?;
398    let lock_file = File::options()
399        .create(true)
400        .truncate(false)
401        .write(true)
402        .open(lock_path(root))?;
403    lock_file.lock()?;
404
405    let mut index = read_index(root)?;
406    let result = f(&mut index)?;
407
408    let tmp_path = root.join("index.json.tmp");
409    let bytes = serde_json::to_vec_pretty(&index).expect("IndexFile always serializes");
410    {
411        let mut tmp = File::create(&tmp_path)?;
412        tmp.write_all(&bytes)?;
413        tmp.sync_all()?;
414    }
415    fs::rename(&tmp_path, index_path(root))?;
416
417    Ok(result)
418}
419
420fn blobs_dir(root: &Path) -> PathBuf {
421    root.join("blobs")
422}
423
424/// Whether `hex` is exactly 64 lowercase hex digits — the exact shape
425/// `write_blob` always produces via `format!("{:x}", Sha256::digest(bytes))`.
426/// This is the sole guard between an `Entry`'s `hash` field (read straight
427/// out of `index.json`, never format-validated elsewhere) and a filesystem
428/// path: without it, a hash like `../../../etc/passwd` or an absolute path
429/// would `Path::join` straight through to arbitrary files outside `blobs/`.
430fn is_well_formed_sha256_hex(hex: &str) -> bool {
431    hex.len() == 64
432        && hex
433            .bytes()
434            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
435}
436
437/// Read just enough of a `.cfbundle` container (magic already confirmed by
438/// the caller) to recover its cached `signature` field — never touching the
439/// stage blobs that follow the manifest, since the catalog only ever needs
440/// the signature.
441///
442/// `manifest_len` is **little-endian** — the build spike documents it only
443/// as `u64`, with no endianness stated, so this is the first concrete
444/// commitment to a byte order for the container format. Pinned here rather
445/// than left ambiguous a second time: whoever implements `cuttlefish build`
446/// (which doesn't exist as code yet) must write `manifest_len` little-endian
447/// to match what this reader expects, not native-endian.
448pub(crate) fn read_bundle_signature(bytes: &[u8], label: &str) -> Result<String, CatalogError> {
449    if bytes.len() < BUNDLE_HEADER_LEN {
450        return Err(CatalogError::UninspectableArtifact {
451            path: PathBuf::from(label),
452            reason: "shorter than the bundle header".to_string(),
453        });
454    }
455
456    let manifest_len =
457        u64::from_le_bytes(bytes[4..BUNDLE_HEADER_LEN].try_into().expect("8 bytes")) as usize;
458    let manifest_bytes = BUNDLE_HEADER_LEN
459        .checked_add(manifest_len)
460        .and_then(|end| bytes.get(BUNDLE_HEADER_LEN..end))
461        .ok_or_else(|| CatalogError::UninspectableArtifact {
462            path: PathBuf::from(label),
463            reason: format!("manifest_len {manifest_len} exceeds the file's actual length"),
464        })?;
465
466    let manifest: serde_json::Value = serde_json::from_slice(manifest_bytes).map_err(|e| {
467        CatalogError::UninspectableArtifact {
468            path: PathBuf::from(label),
469            reason: format!("manifest is not valid JSON: {e}"),
470        }
471    })?;
472
473    // A node whose offset+len falls outside the stage-bytes region that
474    // actually follows the manifest is structurally impossible — it can
475    // only come from a hand-crafted or corrupt bundle, since `bundle::build`
476    // always writes offsets that fit. Reject it now, at the one point every
477    // bundle (this crate's own output or someone else's) passes through
478    // before being embedded in something else or cataloged, rather than
479    // leaving it to be discovered later as an out-of-bounds read once
480    // nested-subjobs actually executes a node.
481    if let Some(nodes) = manifest.get("nodes").and_then(|v| v.as_array()) {
482        let body_len = (bytes.len() - BUNDLE_HEADER_LEN - manifest_len) as u64;
483        for node in nodes {
484            let bounds = node
485                .get("offset")
486                .and_then(|v| v.as_u64())
487                .zip(node.get("len").and_then(|v| v.as_u64()));
488            let in_bounds = match bounds {
489                Some((offset, len)) => offset.checked_add(len).is_some_and(|end| end <= body_len),
490                None => false,
491            };
492            if !in_bounds {
493                return Err(CatalogError::UninspectableArtifact {
494                    path: PathBuf::from(label),
495                    reason: format!(
496                        "a node's offset/len ({:?}) doesn't fit within the bundle's \
497                         {body_len}-byte stage-bytes region",
498                        (
499                            node.get("offset").and_then(|v| v.as_u64()),
500                            node.get("len").and_then(|v| v.as_u64())
501                        )
502                    ),
503                });
504            }
505        }
506    }
507
508    manifest
509        .get("signature")
510        .and_then(|v| v.as_str())
511        .map(str::to_string)
512        .ok_or_else(|| CatalogError::UninspectableArtifact {
513            path: PathBuf::from(label),
514            reason: "manifest has no string field \"signature\"".to_string(),
515        })
516}
517
518/// Write `bytes` into the content-addressed blob store, deduplicating by
519/// hash (two names cataloging identical bytes cost nothing extra), and
520/// return the hash as `sha256:<hex>` — self-describing in the index even
521/// though the on-disk filename is bare hex (it's already inside a directory
522/// named `blobs`; a prefix there would be redundant).
523fn write_blob(root: &Path, bytes: &[u8]) -> Result<String, CatalogError> {
524    use sha2::{Digest, Sha256};
525    use std::sync::atomic::{AtomicU64, Ordering};
526
527    let hex = format!("{:x}", Sha256::digest(bytes));
528    let dir = blobs_dir(root);
529    fs::create_dir_all(&dir)?;
530
531    let blob_path = dir.join(&hex);
532    if !blob_path.exists() {
533        // Unique per call (process id + a process-lifetime counter), not
534        // derived from the hash — two concurrent writers of identical bytes
535        // must never share a temp path. A deterministic {hex}.tmp name let
536        // one writer's O_TRUNC-on-open truncate the other's in-flight or
537        // already-written-but-not-yet-renamed file, risking a truncated
538        // file landing at blob_path under a hash it doesn't actually match.
539        static COUNTER: AtomicU64 = AtomicU64::new(0);
540        let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
541        let tmp_path = dir.join(format!("{hex}.tmp.{}.{unique}", std::process::id()));
542        {
543            let mut tmp = File::create(&tmp_path)?;
544            tmp.write_all(bytes)?;
545            tmp.sync_all()?;
546        }
547        fs::rename(&tmp_path, &blob_path)?;
548    }
549
550    Ok(format!("sha256:{hex}"))
551}
552
553/// A local block catalog rooted at a directory. This type has no opinion
554/// about environment variables or home directories — the caller (the CLI)
555/// decides where `root` points.
556pub struct Catalog {
557    root: PathBuf,
558}
559
560/// What `add` actually did, for a caller to report.
561#[derive(Debug, Clone)]
562pub struct AddOutcome {
563    /// The name@version now cataloged.
564    pub name_version: String,
565    /// Block or bundle.
566    pub kind: ArtifactKind,
567    /// The cached signature string.
568    pub signature: String,
569    /// True when `signature` is the permissive `json -> json` default — the
570    /// caller should print a warning, not fail; the permissive fallback
571    /// itself is existing, intentional behavior, unchanged by the catalog.
572    pub is_permissive_default: bool,
573}
574
575/// Where a pipeline entry string is being resolved from — determines whether
576/// an unqualified name (no `@version`) is legal. The dividing line is
577/// source-spec-text vs. compiled-artifact-reference, not "which command
578/// invoked it": `cuttlefish build` resolves an unqualified name from the
579/// spec it was given exactly once, at build time, and records the exact
580/// resolution in the manifest it emits — the unqualified form itself never
581/// survives into that manifest.
582#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583pub enum ResolutionContext {
584    /// Resolving a reference found directly in a source `.cuttlefish` spec,
585    /// on behalf of a top-level interactive command (`cuttlefish run`, or
586    /// `cuttlefish build` pointed at that spec file). Unqualified names are
587    /// legal here.
588    Interactive,
589    /// Resolving a node reference already recorded inside a bundle's
590    /// manifest. Unqualified names are illegal here.
591    Durable,
592}
593
594/// The result of resolving one pipeline entry string.
595#[derive(Debug, Clone)]
596pub enum Resolved {
597    /// `s` was a direct filesystem path or ended in `.wasm` — used as-is, no
598    /// catalog lookup at all.
599    Direct(PathBuf),
600    /// `s` resolved through the catalog to this entry.
601    Cataloged {
602        /// The exact name@version resolved to, even if `s` itself was
603        /// unqualified.
604        name_version: String,
605        /// The resolved entry.
606        entry: Entry,
607    },
608}
609
610impl Catalog {
611    /// Open (without yet creating on disk) a catalog rooted at `root`.
612    pub fn open(root: impl Into<PathBuf>) -> Self {
613        Self { root: root.into() }
614    }
615
616    /// Catalog the artifact at `artifact_path` under `name_version`.
617    ///
618    /// `engine` is only used if the artifact turns out to be a wasm block —
619    /// a bundle's signature is read straight from its own manifest, never
620    /// wasm-instantiated (bundle bytes are a custom container, not a wasm
621    /// module; instantiating them would simply fail to parse).
622    pub fn add(
623        &self,
624        name_version: &str,
625        artifact_path: &Path,
626        engine: &wasmtime::Engine,
627    ) -> Result<AddOutcome, CatalogError> {
628        validate_name_version(name_version)?;
629
630        let bytes = fs::read(artifact_path)?;
631        let kind =
632            sniff_artifact_kind(&bytes).ok_or_else(|| CatalogError::UnrecognizedArtifact {
633                path: artifact_path.to_path_buf(),
634                header: bytes.iter().take(8).copied().collect(),
635            })?;
636
637        let (signature, is_permissive_default) = match kind {
638            ArtifactKind::Block => {
639                let sig = crate::runner::read_signature(engine, &bytes).map_err(|e| {
640                    CatalogError::UninspectableArtifact {
641                        path: artifact_path.to_path_buf(),
642                        reason: format!("{e:#}"),
643                    }
644                })?;
645                let permissive = cuttlefish_abi::Signature {
646                    input: cuttlefish_abi::Ty::Json,
647                    output: cuttlefish_abi::Ty::Json,
648                };
649                let is_permissive = sig == permissive;
650                (sig.to_string(), is_permissive)
651            }
652            ArtifactKind::Bundle => {
653                let sig = read_bundle_signature(&bytes, &artifact_path.to_string_lossy())?;
654                (sig, false)
655            }
656        };
657
658        let hash = write_blob(&self.root, &bytes)?;
659        let created_at = now_rfc3339();
660        let name_version = name_version.to_string();
661
662        with_locked_index(&self.root, |index| {
663            if index.entries.contains_key(&name_version) {
664                return Err(CatalogError::AlreadyExists {
665                    name_version: name_version.clone(),
666                });
667            }
668            // A removed version keeps its claim on the identity. Re-adding the
669            // exact bytes it was published with is an undo of the `rm`;
670            // re-adding anything else is a republish, which is the thing
671            // immutability exists to forbid.
672            if let Some(previous_hash) = index.retired.get(&name_version) {
673                if previous_hash != &hash {
674                    return Err(CatalogError::RetiredWithDifferentContent {
675                        name_version: name_version.clone(),
676                        previous_hash: previous_hash.clone(),
677                        new_hash: hash.clone(),
678                    });
679                }
680                index.retired.remove(&name_version);
681            }
682            index.entries.insert(
683                name_version.clone(),
684                Entry {
685                    hash,
686                    kind,
687                    signature: signature.clone(),
688                    created_at,
689                },
690            );
691            Ok(())
692        })?;
693
694        Ok(AddOutcome {
695            name_version,
696            kind,
697            signature,
698            is_permissive_default,
699        })
700    }
701
702    /// List every cataloged entry, in deterministic (sorted-by-name@version)
703    /// order.
704    pub fn list(&self) -> Result<Vec<(String, Entry)>, CatalogError> {
705        let index = read_index(&self.root)?;
706        Ok(index.entries.into_iter().collect())
707    }
708
709    /// Look up one entry's cached `Entry` by exact, case-sensitive
710    /// `name@version` — a catalog name is an opaque string, like a version is;
711    /// no case-folding, no normalization.
712    pub fn show(&self, name_version: &str) -> Result<Entry, CatalogError> {
713        let index = read_index(&self.root)?;
714        index.entries.get(name_version).cloned().ok_or_else(|| {
715            let name = name_version.split('@').next().unwrap_or(name_version);
716            CatalogError::NotFound {
717                name_version: name_version.to_string(),
718                did_you_mean: pick_did_you_mean(name, &index.entries),
719            }
720        })
721    }
722
723    /// Read an entry's raw bytes back out of the blob store. The catalog's
724    /// only way to get from an `Entry` to actual artifact bytes — `blobs/`
725    /// stays an implementation detail, same as `add()` already hides
726    /// `write_blob`.
727    pub fn read_blob(&self, entry: &Entry) -> Result<Vec<u8>, CatalogError> {
728        let hex = entry.hash.strip_prefix("sha256:").unwrap_or(&entry.hash);
729        if !is_well_formed_sha256_hex(hex) {
730            return Err(CatalogError::MalformedHash {
731                hash: entry.hash.clone(),
732            });
733        }
734        Ok(fs::read(blobs_dir(&self.root).join(hex))?)
735    }
736
737    /// Remove a `name@version` from the index. The blob it pointed at is left on
738    /// disk — no garbage collection in v1 (an orphaned blob is wasted space, not
739    /// a correctness problem; see the design doc).
740    pub fn rm(&self, name_version: &str) -> Result<(), CatalogError> {
741        with_locked_index(&self.root, |index| {
742            if let Some(entry) = index.entries.remove(name_version) {
743                // Record what this identity was published as, so a later
744                // `add` can tell an undo from a republish.
745                index
746                    .retired
747                    .insert(name_version.to_string(), entry.hash.clone());
748                Ok(())
749            } else {
750                let name = name_version.split('@').next().unwrap_or(name_version);
751                Err(CatalogError::NotFound {
752                    name_version: name_version.to_string(),
753                    did_you_mean: pick_did_you_mean(name, &index.entries),
754                })
755            }
756        })
757    }
758
759    /// Resolve one pipeline entry string per the catalog spec's three-step
760    /// algorithm: direct path/`.wasm`/`.cfbundle` first, then an exact
761    /// catalog lookup if `@version` is present, then latest-by-`created_at`
762    /// if it's not and `context` allows an unqualified name.
763    ///
764    /// A compiled-artifact suffix (`.wasm` or `.cfbundle`) is always treated
765    /// as Direct, even when nothing actually exists at that path — a
766    /// genuinely missing artifact should fail with a clear "no such file",
767    /// not be silently reinterpreted as a catalog name that happens to
768    /// contain a `.` in it. Both suffixes get identical treatment here:
769    /// `pipeline::resolve_and_load`'s own decision to prefer a joined path
770    /// for one of these suffixes (even before checking existence) is only
771    /// correct if this function honors the same suffixes the same way.
772    pub fn resolve(&self, s: &str, context: ResolutionContext) -> Result<Resolved, CatalogError> {
773        if s.ends_with(".wasm") || s.ends_with(".cfbundle") || Path::new(s).exists() {
774            return Ok(Resolved::Direct(PathBuf::from(s)));
775        }
776
777        let index = read_index(&self.root)?;
778
779        if let Some((name, version)) = s.rsplit_once('@') {
780            let name_version = format!("{name}@{version}");
781            let entry = index.entries.get(&name_version).cloned().ok_or_else(|| {
782                CatalogError::NotFound {
783                    name_version: name_version.clone(),
784                    did_you_mean: pick_did_you_mean(name, &index.entries),
785                }
786            })?;
787            return Ok(Resolved::Cataloged {
788                name_version,
789                entry,
790            });
791        }
792
793        if context == ResolutionContext::Durable {
794            return Err(CatalogError::UnqualifiedName {
795                name: s.to_string(),
796            });
797        }
798
799        let mut versions: Vec<(&String, &Entry)> = index
800            .entries
801            .iter()
802            .filter(|(nv, _)| nv.rsplit_once('@').map(|(n, _)| n) == Some(s))
803            .collect();
804        versions.sort_by(|a, b| a.1.created_at.cmp(&b.1.created_at));
805
806        let (name_version, entry) =
807            versions
808                .last()
809                .copied()
810                .ok_or_else(|| CatalogError::NotFound {
811                    name_version: s.to_string(),
812                    did_you_mean: pick_did_you_mean(s, &index.entries),
813                })?;
814
815        Ok(Resolved::Cataloged {
816            name_version: name_version.clone(),
817            entry: entry.clone(),
818        })
819    }
820}
821
822/// The bare `$CUTTLEFISH_HOME`/`~/.cuttlefish` root everything else in this
823/// crate's on-disk layout (the catalog, the jobs directory) is rooted under.
824/// `None` when neither `$CUTTLEFISH_HOME` nor a resolvable home directory is
825/// available — this library never exits the process on a caller's behalf, so
826/// reporting that is the caller's job (both `cuttlefish` and `cuttlefishd`
827/// already have their own error-reporting convention).
828pub(crate) fn cuttlefish_home() -> Option<PathBuf> {
829    if let Ok(home) = std::env::var("CUTTLEFISH_HOME") {
830        return Some(PathBuf::from(home));
831    }
832    dirs::home_dir().map(|home| home.join(".cuttlefish"))
833}
834
835/// Where the catalog lives when the caller doesn't say otherwise:
836/// `$CUTTLEFISH_HOME/catalog` if set, else `~/.cuttlefish/catalog`. `None`
837/// when neither is available — see `cuttlefish_home` (private).
838pub fn default_root() -> Option<PathBuf> {
839    cuttlefish_home().map(|h| h.join("catalog"))
840}
841
842/// The current UTC time, truncated to whole seconds and formatted as RFC
843/// 3339 (`2026-08-02T18:03:00Z`). Truncating avoids variable-width fractional
844/// seconds, so `created_at` strings sort correctly with plain string
845/// comparison (used for "give me the latest" and did-you-mean tie-breaking)
846/// without ever needing to be parsed back.
847pub(crate) fn now_rfc3339() -> String {
848    let now = time::OffsetDateTime::now_utc()
849        .replace_nanosecond(0)
850        .expect("0 is always a valid nanosecond value");
851    now.format(&time::format_description::well_known::Rfc3339)
852        .expect("Rfc3339 formatting cannot fail for a valid OffsetDateTime")
853}
854
855#[cfg(test)]
856mod tests {
857    use super::*;
858    use std::sync::atomic::{AtomicBool, Ordering};
859    use std::sync::{Arc, Barrier};
860
861    /// Enough threads to genuinely contend for the index lock on any machine
862    /// this runs on, without making the test slow.
863    const WRITERS: usize = 16;
864
865    #[test]
866    fn default_root_honors_cuttlefish_home() {
867        // No other test in this process mutates CUTTLEFISH_HOME, and
868        // catalog.rs's own tests never read it — set/remove is confined to
869        // this one test. (This toolchain's std::env::set_var/remove_var are
870        // safe fns, not unsafe — the crate forbids unsafe_code entirely, so
871        // an unsafe wrapper isn't an option regardless.)
872        std::env::set_var("CUTTLEFISH_HOME", "/tmp/cf-test-home");
873        let root = default_root();
874        std::env::remove_var("CUTTLEFISH_HOME");
875        assert_eq!(root, Some(PathBuf::from("/tmp/cf-test-home/catalog")));
876    }
877
878    #[test]
879    fn index_file_serializes_to_the_shape_the_spec_documents() {
880        let mut entries = BTreeMap::new();
881        entries.insert(
882            "chunk-text@1".to_string(),
883            Entry {
884                hash: "sha256:9f86d081".to_string(),
885                kind: ArtifactKind::Block,
886                signature: "{path: text} -> [text]".to_string(),
887                created_at: "2026-08-02T18:03:00Z".to_string(),
888            },
889        );
890        let index = IndexFile {
891            version: INDEX_VERSION,
892            entries,
893            retired: BTreeMap::new(),
894        };
895
896        let json = serde_json::to_string(&index).expect("IndexFile always serializes");
897        let parsed: serde_json::Value =
898            serde_json::from_str(&json).expect("what we just wrote must parse");
899
900        assert_eq!(parsed["version"], 1);
901        assert_eq!(parsed["entries"]["chunk-text@1"]["kind"], "block");
902        assert_eq!(
903            parsed["entries"]["chunk-text@1"]["signature"],
904            "{path: text} -> [text]"
905        );
906
907        let round_tripped: IndexFile =
908            serde_json::from_str(&json).expect("must deserialize what we just serialized");
909        assert_eq!(round_tripped.version, INDEX_VERSION);
910        assert!(round_tripped.entries.contains_key("chunk-text@1"));
911    }
912
913    #[test]
914    fn not_found_with_suggestions_reads_as_one_sentence() {
915        let err = CatalogError::NotFound {
916            name_version: "summarise@1".to_string(),
917            did_you_mean: vec!["summarize@1".to_string()],
918        };
919        assert_eq!(
920            err.to_string(),
921            "no such catalog entry: summarise@1 (did you mean: summarize@1?)"
922        );
923    }
924
925    #[test]
926    fn not_found_with_no_suggestions_has_no_dangling_parenthetical() {
927        let err = CatalogError::NotFound {
928            name_version: "xyz@1".to_string(),
929            did_you_mean: vec![],
930        };
931        assert_eq!(err.to_string(), "no such catalog entry: xyz@1");
932    }
933
934    fn entry_fixture(created_at: &str) -> Entry {
935        Entry {
936            hash: "sha256:deadbeef".to_string(),
937            kind: ArtifactKind::Block,
938            signature: "json -> json".to_string(),
939            created_at: created_at.to_string(),
940        }
941    }
942
943    fn seed(root: &Path, name_version: &str, created_at: &str) {
944        with_locked_index(root, |index| {
945            index
946                .entries
947                .insert(name_version.to_string(), entry_fixture(created_at));
948            Ok::<_, CatalogError>(())
949        })
950        .unwrap();
951    }
952
953    #[test]
954    fn levenshtein_matches_known_distances() {
955        assert_eq!(levenshtein("kitten", "sitting"), 3);
956        assert_eq!(levenshtein("summarize", "summarise"), 1);
957        assert_eq!(levenshtein("same", "same"), 0);
958    }
959
960    #[test]
961    fn did_you_mean_catches_a_one_character_typo_a_prefix_match_would_miss() {
962        // "summarise" and "summarize" share no prefix relationship (they diverge
963        // at the 8th character) — a starts-with prefix match would silently
964        // produce zero suggestions on exactly this typo.
965        let mut entries = BTreeMap::new();
966        entries.insert(
967            "summarize@1".to_string(),
968            entry_fixture("2026-01-01T00:00:00Z"),
969        );
970        assert_eq!(
971            pick_did_you_mean("summarise", &entries),
972            vec!["summarize@1".to_string()]
973        );
974    }
975
976    #[test]
977    fn did_you_mean_is_empty_when_nothing_registered_is_close() {
978        let mut entries = BTreeMap::new();
979        entries.insert(
980            "summarize@1".to_string(),
981            entry_fixture("2026-01-01T00:00:00Z"),
982        );
983        assert!(pick_did_you_mean("completely-unrelated-name", &entries).is_empty());
984    }
985
986    #[test]
987    fn did_you_mean_is_capped_at_five_closest_ordered_by_distance() {
988        let mut entries = BTreeMap::new();
989        // All within edit distance 1 of "cat" by construction (each swaps one
990        // letter), so the cap — not the distance threshold — is what's under test.
991        for (i, name) in ["bat", "cot", "car", "cap", "can", "cad"]
992            .iter()
993            .enumerate()
994        {
995            entries.insert(
996                format!("{name}@1"),
997                entry_fixture(&format!("2026-01-0{}T00:00:00Z", i + 1)),
998            );
999        }
1000        let suggestions = pick_did_you_mean("cat", &entries);
1001        assert_eq!(suggestions.len(), 5, "capped at 5: {suggestions:?}");
1002    }
1003
1004    #[test]
1005    fn did_you_mean_suggests_the_newest_version_when_multiple_versions_of_a_close_name_exist() {
1006        let mut entries = BTreeMap::new();
1007        entries.insert(
1008            "summarize@1".to_string(),
1009            entry_fixture("2026-01-01T00:00:00Z"),
1010        );
1011        entries.insert(
1012            "summarize@2".to_string(),
1013            entry_fixture("2026-06-01T00:00:00Z"),
1014        );
1015        assert_eq!(
1016            pick_did_you_mean("summarise", &entries),
1017            vec!["summarize@2".to_string()],
1018            "must suggest the newest version of a matching name, not every version"
1019        );
1020    }
1021
1022    #[test]
1023    fn wasm_magic_bytes_sniff_as_a_block() {
1024        assert_eq!(
1025            sniff_artifact_kind(b"\0asm\x01\x00\x00\x00"),
1026            Some(ArtifactKind::Block)
1027        );
1028    }
1029
1030    #[test]
1031    fn bundle_magic_bytes_sniff_as_a_bundle() {
1032        assert_eq!(
1033            sniff_artifact_kind(b"CFBD\x00\x00\x00\x00\x00\x00\x00\x00"),
1034            Some(ArtifactKind::Bundle)
1035        );
1036    }
1037
1038    #[test]
1039    fn unrecognised_bytes_sniff_to_none_not_a_guess() {
1040        assert_eq!(sniff_artifact_kind(b"whatever-this-is"), None);
1041    }
1042
1043    #[test]
1044    fn writing_then_reading_the_index_round_trips_through_disk() {
1045        let dir = tempfile::tempdir().unwrap();
1046        with_locked_index(dir.path(), |index| {
1047            index
1048                .entries
1049                .insert("a@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1050            Ok::<_, CatalogError>(())
1051        })
1052        .unwrap();
1053
1054        let index = read_index(dir.path()).unwrap();
1055        assert!(index.entries.contains_key("a@1"));
1056    }
1057
1058    #[test]
1059    fn reading_an_index_that_does_not_exist_yet_is_an_empty_catalog_not_an_error() {
1060        let dir = tempfile::tempdir().unwrap();
1061        let index = read_index(dir.path()).expect("no index.json yet is not corruption");
1062        assert!(index.entries.is_empty());
1063    }
1064
1065    #[test]
1066    fn a_truncated_index_is_a_corrupt_index_error_not_an_empty_catalog() {
1067        let dir = tempfile::tempdir().unwrap();
1068        std::fs::create_dir_all(dir.path()).unwrap();
1069        std::fs::write(dir.path().join("index.json"), b"{\"version\": 1, \"ent").unwrap();
1070
1071        let err = read_index(dir.path()).unwrap_err();
1072        assert!(
1073            matches!(err, CatalogError::CorruptIndex { .. }),
1074            "a truncated index must be a loud CorruptIndex, not treated as empty: {err:?}"
1075        );
1076    }
1077
1078    #[test]
1079    fn an_unsupported_index_version_is_a_corrupt_index_error() {
1080        let dir = tempfile::tempdir().unwrap();
1081        std::fs::create_dir_all(dir.path()).unwrap();
1082        std::fs::write(
1083            dir.path().join("index.json"),
1084            br#"{"version": 999, "entries": {}}"#,
1085        )
1086        .unwrap();
1087
1088        let err = read_index(dir.path()).unwrap_err();
1089        assert!(matches!(err, CatalogError::CorruptIndex { .. }), "{err:?}");
1090    }
1091
1092    #[test]
1093    fn concurrent_writes_from_two_threads_both_land_and_the_index_stays_parseable() {
1094        let dir = tempfile::tempdir().unwrap();
1095        let root_a = dir.path().to_path_buf();
1096        let root_b = dir.path().to_path_buf();
1097
1098        let t1 = std::thread::spawn(move || {
1099            with_locked_index(&root_a, |index| {
1100                index
1101                    .entries
1102                    .insert("a@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1103                Ok::<_, CatalogError>(())
1104            })
1105            .unwrap();
1106        });
1107        let t2 = std::thread::spawn(move || {
1108            with_locked_index(&root_b, |index| {
1109                index
1110                    .entries
1111                    .insert("b@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1112                Ok::<_, CatalogError>(())
1113            })
1114            .unwrap();
1115        });
1116        t1.join().unwrap();
1117        t2.join().unwrap();
1118
1119        let index = read_index(dir.path()).expect("the index must still parse after contention");
1120        assert!(index.entries.contains_key("a@1"));
1121        assert!(index.entries.contains_key("b@1"));
1122    }
1123
1124    /// `add`'s duplicate check runs *inside* the locked section, so a pack of
1125    /// writers racing for one key must resolve to exactly one winner — the
1126    /// "versions are immutable once published" promise only holds under
1127    /// contention if the check-then-insert is genuinely atomic. Every thread
1128    /// is held at a barrier so they collide on the lock rather than politely
1129    /// serializing.
1130    #[test]
1131    fn racing_inserts_of_the_same_key_leave_exactly_one_winner() {
1132        let dir = tempfile::tempdir().unwrap();
1133        let root = dir.path().to_path_buf();
1134        let barrier = Arc::new(Barrier::new(WRITERS));
1135
1136        let handles: Vec<_> = (0..WRITERS)
1137            .map(|_| {
1138                let root = root.clone();
1139                let barrier = barrier.clone();
1140                std::thread::spawn(move || {
1141                    barrier.wait();
1142                    with_locked_index(&root, |index| {
1143                        if index.entries.contains_key("race@1") {
1144                            return Err(CatalogError::AlreadyExists {
1145                                name_version: "race@1".to_string(),
1146                            });
1147                        }
1148                        index
1149                            .entries
1150                            .insert("race@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1151                        Ok(())
1152                    })
1153                })
1154            })
1155            .collect();
1156
1157        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1158
1159        let winners = results.iter().filter(|r| r.is_ok()).count();
1160        assert_eq!(
1161            winners, 1,
1162            "exactly one racing writer may claim a key; got {winners}"
1163        );
1164        assert!(
1165            results
1166                .iter()
1167                .all(|r| r.is_ok() || matches!(r, Err(CatalogError::AlreadyExists { .. }))),
1168            "every loser must lose with AlreadyExists, not an io or corruption error: {results:?}"
1169        );
1170
1171        let index = read_index(&root).expect("the index must still parse after contention");
1172        assert_eq!(index.entries.len(), 1);
1173    }
1174
1175    /// The two-thread test above proves the lock exists; this proves it holds
1176    /// up under real contention. Without a barrier, two threads usually finish
1177    /// one after another and never touch the lock at the same time, so a
1178    /// read-modify-write that lost updates could still pass.
1179    #[test]
1180    fn many_racing_writers_of_distinct_keys_all_land_with_no_lost_updates() {
1181        let dir = tempfile::tempdir().unwrap();
1182        let root = dir.path().to_path_buf();
1183        let barrier = Arc::new(Barrier::new(WRITERS));
1184
1185        let handles: Vec<_> = (0..WRITERS)
1186            .map(|w| {
1187                let root = root.clone();
1188                let barrier = barrier.clone();
1189                std::thread::spawn(move || {
1190                    barrier.wait();
1191                    with_locked_index(&root, |index| {
1192                        index.entries.insert(
1193                            format!("writer-{w}@1"),
1194                            entry_fixture("2026-01-01T00:00:00Z"),
1195                        );
1196                        Ok::<_, CatalogError>(())
1197                    })
1198                    .unwrap();
1199                })
1200            })
1201            .collect();
1202        for h in handles {
1203            h.join().unwrap();
1204        }
1205
1206        let index = read_index(&root).expect("the index must still parse after contention");
1207        assert_eq!(
1208            index.entries.len(),
1209            WRITERS,
1210            "every writer's entry must survive; a lost update means the \
1211             read-modify-write escaped the lock: {:?}",
1212            index.entries.keys().collect::<Vec<_>>()
1213        );
1214    }
1215
1216    /// `with_locked_index` documents that readers need no lock because a
1217    /// writer only ever publishes via rename, so a reader sees the wholly-old
1218    /// or wholly-new file and never a half-written one. Nothing asserted it:
1219    /// this hammers lock-free readers against writers and fails if any read
1220    /// ever comes back corrupt.
1221    #[test]
1222    fn lock_free_readers_never_observe_a_partial_index_while_writers_hammer() {
1223        let dir = tempfile::tempdir().unwrap();
1224        let root = dir.path().to_path_buf();
1225        // Seed first so index.json exists before any reader starts — a
1226        // missing index is legitimately empty, which would mask a torn read.
1227        seed(&root, "seed@1", "2026-01-01T00:00:00Z");
1228
1229        let stop = Arc::new(AtomicBool::new(false));
1230
1231        let writers: Vec<_> = (0..4)
1232            .map(|w| {
1233                let root = root.clone();
1234                std::thread::spawn(move || {
1235                    for i in 0..60 {
1236                        with_locked_index(&root, |index| {
1237                            index.entries.insert(
1238                                format!("w{w}-{i}@1"),
1239                                entry_fixture("2026-01-01T00:00:00Z"),
1240                            );
1241                            Ok::<_, CatalogError>(())
1242                        })
1243                        .unwrap();
1244                    }
1245                })
1246            })
1247            .collect();
1248
1249        let readers: Vec<_> = (0..4)
1250            .map(|_| {
1251                let root = root.clone();
1252                let stop = stop.clone();
1253                std::thread::spawn(move || {
1254                    let mut reads = 0u32;
1255                    while !stop.load(Ordering::Relaxed) {
1256                        let index = read_index(&root)
1257                            .expect("a lock-free reader must never see a partial or corrupt index");
1258                        // A torn read that still parsed would most likely show
1259                        // up as losing the seed entry that is only ever added.
1260                        assert!(
1261                            index.entries.contains_key("seed@1"),
1262                            "an entry that is never removed vanished from a concurrent read"
1263                        );
1264                        reads += 1;
1265                    }
1266                    reads
1267                })
1268            })
1269            .collect();
1270
1271        for w in writers {
1272            w.join().unwrap();
1273        }
1274        stop.store(true, Ordering::Relaxed);
1275
1276        let total: u32 = readers.into_iter().map(|r| r.join().unwrap()).sum();
1277        assert!(
1278            total > 0,
1279            "the readers must have actually observed the index"
1280        );
1281    }
1282
1283    /// Every other concurrency test here races `add` against `add`. Removals
1284    /// take the same lock and rewrite the same file, so a mixed workload is
1285    /// where an asymmetry would show up — e.g. a removal path that wrote the
1286    /// index outside the locked section. Each thread owns a disjoint key and
1287    /// adds then removes it, so the end state is exactly the untouched
1288    /// keep-alive entries regardless of interleaving.
1289    #[test]
1290    fn adds_and_removals_racing_on_one_index_leave_exactly_the_expected_entries() {
1291        let dir = tempfile::tempdir().unwrap();
1292        let root = dir.path().to_path_buf();
1293        seed(&root, "keep@1", "2026-01-01T00:00:00Z");
1294        seed(&root, "keep@2", "2026-01-01T00:00:00Z");
1295
1296        let barrier = Arc::new(Barrier::new(WRITERS));
1297        let handles: Vec<_> = (0..WRITERS)
1298            .map(|w| {
1299                let root = root.clone();
1300                let barrier = barrier.clone();
1301                std::thread::spawn(move || {
1302                    let key = format!("churn-{w}@1");
1303                    barrier.wait();
1304                    for _ in 0..10 {
1305                        with_locked_index(&root, |index| {
1306                            index
1307                                .entries
1308                                .insert(key.clone(), entry_fixture("2026-01-01T00:00:00Z"));
1309                            Ok::<_, CatalogError>(())
1310                        })
1311                        .unwrap();
1312                        with_locked_index(&root, |index| {
1313                            index.entries.remove(&key).expect(
1314                                "a key only this thread ever touches must still be present",
1315                            );
1316                            Ok::<_, CatalogError>(())
1317                        })
1318                        .unwrap();
1319                    }
1320                })
1321            })
1322            .collect();
1323        for h in handles {
1324            h.join().unwrap();
1325        }
1326
1327        let index = read_index(&root).expect("the index must still parse after mixed contention");
1328        let names: Vec<_> = index.entries.keys().cloned().collect();
1329        assert_eq!(
1330            names,
1331            vec!["keep@1".to_string(), "keep@2".to_string()],
1332            "churn keys must all be gone and the untouched entries must survive"
1333        );
1334    }
1335
1336    #[test]
1337    fn identical_bytes_under_two_writes_produce_exactly_one_blob_file() {
1338        let dir = tempfile::tempdir().unwrap();
1339        let hash1 = write_blob(dir.path(), b"hello world").unwrap();
1340        let hash2 = write_blob(dir.path(), b"hello world").unwrap();
1341
1342        assert_eq!(hash1, hash2);
1343        assert!(hash1.starts_with("sha256:"));
1344
1345        let blob_count = std::fs::read_dir(dir.path().join("blobs")).unwrap().count();
1346        assert_eq!(
1347            blob_count, 1,
1348            "identical bytes must dedupe to a single blob file"
1349        );
1350    }
1351
1352    #[test]
1353    fn the_blob_filename_on_disk_is_bare_hex_no_prefix() {
1354        let dir = tempfile::tempdir().unwrap();
1355        let hash = write_blob(dir.path(), b"hello world").unwrap();
1356        let hex = hash
1357            .strip_prefix("sha256:")
1358            .expect("index field is prefixed");
1359
1360        assert!(dir.path().join("blobs").join(hex).exists());
1361    }
1362
1363    #[test]
1364    fn many_concurrent_writers_of_identical_bytes_never_corrupt_the_blob() {
1365        let dir = tempfile::tempdir().unwrap();
1366        let root = dir.path().to_path_buf();
1367        let content = b"identical content raced by many concurrent writers";
1368
1369        let handles: Vec<_> = (0..16)
1370            .map(|_| {
1371                let root = root.clone();
1372                std::thread::spawn(move || write_blob(&root, content).unwrap())
1373            })
1374            .collect();
1375
1376        let hashes: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1377        assert!(
1378            hashes.iter().all(|h| h == &hashes[0]),
1379            "every writer must compute and report the same hash: {hashes:?}"
1380        );
1381
1382        let hex = hashes[0].strip_prefix("sha256:").unwrap();
1383        let blob_bytes = std::fs::read(root.join("blobs").join(hex)).unwrap();
1384        assert_eq!(
1385            blob_bytes, content,
1386            "the published blob must be exactly the input bytes, not truncated or corrupted by a racing writer"
1387        );
1388    }
1389
1390    fn make_bundle(manifest_json: &[u8]) -> Vec<u8> {
1391        let mut bytes = b"CFBD".to_vec();
1392        bytes.extend_from_slice(&(manifest_json.len() as u64).to_le_bytes());
1393        bytes.extend_from_slice(manifest_json);
1394        bytes
1395    }
1396
1397    #[test]
1398    fn reads_the_signature_field_out_of_a_valid_bundle_manifest() {
1399        let bundle = make_bundle(
1400            br#"{"nodes":[],"edges":[],"signature":"{path: text} -> {summary: text}"}"#,
1401        );
1402        let sig = read_bundle_signature(&bundle, "test.cfbundle").unwrap();
1403        assert_eq!(sig, "{path: text} -> {summary: text}");
1404    }
1405
1406    #[test]
1407    fn a_manifest_len_exceeding_the_actual_bytes_is_uninspectable() {
1408        let mut bundle = make_bundle(br#"{"nodes":[],"edges":[],"signature":"x -> x"}"#);
1409        bundle.truncate(bundle.len() - 5); // manifest_len now overshoots what's left
1410        let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1411        match err {
1412            CatalogError::UninspectableArtifact { reason, .. } => assert!(
1413                reason.contains("exceeds the file's actual length"),
1414                "{reason}"
1415            ),
1416            other => panic!("expected UninspectableArtifact, got {other:?}"),
1417        }
1418    }
1419
1420    #[test]
1421    fn invalid_manifest_json_is_uninspectable() {
1422        let bundle = make_bundle(b"not valid json at all");
1423        let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1424        match err {
1425            CatalogError::UninspectableArtifact { reason, .. } => {
1426                assert!(reason.contains("not valid JSON"), "{reason}")
1427            }
1428            other => panic!("expected UninspectableArtifact, got {other:?}"),
1429        }
1430    }
1431
1432    #[test]
1433    fn a_manifest_missing_the_signature_field_is_uninspectable() {
1434        let bundle = make_bundle(br#"{"nodes":[],"edges":[]}"#);
1435        let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1436        match err {
1437            CatalogError::UninspectableArtifact { reason, .. } => {
1438                assert!(reason.contains("no string field"), "{reason}")
1439            }
1440            other => panic!("expected UninspectableArtifact, got {other:?}"),
1441        }
1442    }
1443
1444    #[test]
1445    fn a_node_whose_offset_and_len_overflow_the_stage_bytes_is_uninspectable() {
1446        // No stage bytes follow the manifest at all here, so any non-zero
1447        // offset/len is already out of bounds — exactly the "internally
1448        // impossible node table" shape a hand-crafted or corrupt bundle
1449        // could smuggle past a check that only ever reads the `signature`
1450        // field.
1451        let bundle = make_bundle(
1452            br#"{"nodes":[{"name":"bad","kind":"block","resolved":null,
1453                 "signature":"json -> json","offset":99999,"len":99999}],
1454                 "signature":"json -> json"}"#,
1455        );
1456        let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1457        match err {
1458            CatalogError::UninspectableArtifact { reason, .. } => {
1459                assert!(reason.contains("doesn't fit"), "{reason}")
1460            }
1461            other => panic!("expected UninspectableArtifact, got {other:?}"),
1462        }
1463    }
1464
1465    #[test]
1466    fn a_node_whose_offset_and_len_exactly_fit_the_stage_bytes_is_fine() {
1467        let mut bundle = make_bundle(
1468            br#"{"nodes":[{"name":"ok","kind":"block","resolved":null,
1469                 "signature":"json -> json","offset":0,"len":3}],
1470                 "signature":"json -> json"}"#,
1471        );
1472        bundle.extend_from_slice(b"abc");
1473        let sig = read_bundle_signature(&bundle, "test.cfbundle").unwrap();
1474        assert_eq!(sig, "json -> json");
1475    }
1476
1477    #[test]
1478    fn an_overflowing_node_offset_plus_len_is_uninspectable_not_a_panic() {
1479        // Regression-shaped like the manifest_len overflow fix elsewhere in
1480        // this file: offset + len must not panic on overflow, it must
1481        // report a clean error.
1482        let bundle = make_bundle(
1483            format!(
1484                r#"{{"nodes":[{{"name":"bad","kind":"block","resolved":null,
1485                     "signature":"json -> json","offset":{},"len":10}}],
1486                     "signature":"json -> json"}}"#,
1487                u64::MAX
1488            )
1489            .as_bytes(),
1490        );
1491        let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1492        assert!(matches!(err, CatalogError::UninspectableArtifact { .. }));
1493    }
1494
1495    #[test]
1496    fn an_overflowing_manifest_len_is_uninspectable_not_a_panic() {
1497        // manifest_len near u64::MAX must not panic when added to BUNDLE_HEADER_LEN —
1498        // regression test for the checked_add fix (a bare `+` here panics with
1499        // "attempt to add with overflow" in debug builds, turning a crafted
1500        // bundle file into a crash instead of a clean error).
1501        let mut bytes = b"CFBD".to_vec();
1502        bytes.extend_from_slice(&u64::MAX.to_le_bytes());
1503        let err = read_bundle_signature(&bytes, "test.cfbundle").unwrap_err();
1504        match err {
1505            CatalogError::UninspectableArtifact { reason, .. } => {
1506                assert!(
1507                    reason.contains("exceeds the file's actual length"),
1508                    "{reason}"
1509                )
1510            }
1511            other => panic!("expected UninspectableArtifact, got {other:?}"),
1512        }
1513    }
1514
1515    #[test]
1516    fn a_file_shorter_than_the_header_is_uninspectable() {
1517        let err = read_bundle_signature(b"CFBD", "test.cfbundle").unwrap_err();
1518        match err {
1519            CatalogError::UninspectableArtifact { reason, .. } => {
1520                assert!(
1521                    reason.contains("shorter than the bundle header"),
1522                    "{reason}"
1523                )
1524            }
1525            other => panic!("expected UninspectableArtifact, got {other:?}"),
1526        }
1527    }
1528
1529    #[test]
1530    fn adding_a_wasm_block_with_no_cf_signature_export_caches_the_permissive_default_and_flags_it()
1531    {
1532        let catalog_dir = tempfile::tempdir().unwrap();
1533        let wasm_dir = tempfile::tempdir().unwrap();
1534        let wasm_path = wasm_dir.path().join("no_sig.wasm");
1535        std::fs::write(
1536            &wasm_path,
1537            wat::parse_str(r#"(module (memory (export "memory") 1))"#).unwrap(),
1538        )
1539        .unwrap();
1540
1541        let catalog = Catalog::open(catalog_dir.path());
1542        let outcome = catalog
1543            .add("no-sig@1", &wasm_path, &wasmtime::Engine::default())
1544            .expect("a block missing cf_signature is not an add-time error");
1545
1546        assert_eq!(outcome.signature, "json -> json");
1547        assert!(
1548            outcome.is_permissive_default,
1549            "a block with no cf_signature export must be flagged, not silently accepted"
1550        );
1551    }
1552
1553    #[test]
1554    fn adding_wasm_magic_bytes_with_an_invalid_module_body_is_uninspectable() {
1555        let catalog_dir = tempfile::tempdir().unwrap();
1556        let wasm_dir = tempfile::tempdir().unwrap();
1557        let wasm_path = wasm_dir.path().join("broken.wasm");
1558        // Real wasm magic, garbage after it: passes the magic-byte sniff, fails
1559        // Module::new — the "recognised header, unreadable contents" case.
1560        std::fs::write(
1561            &wasm_path,
1562            b"\0asm\x01\x00\x00\x00garbage-not-a-real-module",
1563        )
1564        .unwrap();
1565
1566        let catalog = Catalog::open(catalog_dir.path());
1567        let err = catalog
1568            .add("broken@1", &wasm_path, &wasmtime::Engine::default())
1569            .unwrap_err();
1570        assert!(
1571            matches!(err, CatalogError::UninspectableArtifact { .. }),
1572            "{err:?}"
1573        );
1574    }
1575
1576    #[test]
1577    fn a_cf_signature_export_that_exists_but_returns_unparseable_bytes_is_uninspectable_not_permissive(
1578    ) {
1579        // Distinct from both prior tests: the module instantiates fine and
1580        // cf_signature exists with the right callable shape (() -> u32) — this
1581        // is the "present but broken" case, which must NOT be folded into the
1582        // "absent" case's permissive-default fallback. The descriptor it returns
1583        // points at zeroed memory (no data segment): reading it back yields an
1584        // empty buffer, which fails to parse as a Signature — read_signature
1585        // returns Err, and that must surface as UninspectableArtifact.
1586        let catalog_dir = tempfile::tempdir().unwrap();
1587        let wasm_dir = tempfile::tempdir().unwrap();
1588        let wasm_path = wasm_dir.path().join("broken_sig.wasm");
1589        std::fs::write(
1590            &wasm_path,
1591            wat::parse_str(
1592                r#"(module
1593                     (memory (export "memory") 1)
1594                     (func (export "cf_signature") (result i32) i32.const 0)
1595                   )"#,
1596            )
1597            .unwrap(),
1598        )
1599        .unwrap();
1600
1601        let catalog = Catalog::open(catalog_dir.path());
1602        let err = catalog
1603            .add("broken-sig@1", &wasm_path, &wasmtime::Engine::default())
1604            .unwrap_err();
1605        assert!(
1606            matches!(err, CatalogError::UninspectableArtifact { .. }),
1607            "present-but-unparseable cf_signature must be a hard failure, not the permissive default: {err:?}"
1608        );
1609    }
1610
1611    #[test]
1612    fn adding_a_bundle_reads_its_signature_from_the_manifest_never_instantiating_wasm() {
1613        let catalog_dir = tempfile::tempdir().unwrap();
1614        let bundle_dir = tempfile::tempdir().unwrap();
1615        let bundle_path = bundle_dir.path().join("digest.cfbundle");
1616        std::fs::write(
1617            &bundle_path,
1618            make_bundle(
1619                br#"{"nodes":[],"edges":[],"signature":"{path: text} -> {summary: text}"}"#,
1620            ),
1621        )
1622        .unwrap();
1623
1624        let catalog = Catalog::open(catalog_dir.path());
1625        let outcome = catalog
1626            .add("digest@1", &bundle_path, &wasmtime::Engine::default())
1627            .unwrap();
1628
1629        assert_eq!(outcome.kind, ArtifactKind::Bundle);
1630        assert_eq!(outcome.signature, "{path: text} -> {summary: text}");
1631        assert!(!outcome.is_permissive_default);
1632    }
1633
1634    #[test]
1635    fn adding_a_file_with_neither_magic_is_unrecognized_not_a_silent_guess() {
1636        let catalog_dir = tempfile::tempdir().unwrap();
1637        let junk_dir = tempfile::tempdir().unwrap();
1638        let junk_path = junk_dir.path().join("junk.bin");
1639        std::fs::write(&junk_path, b"not a wasm or bundle").unwrap();
1640
1641        let catalog = Catalog::open(catalog_dir.path());
1642        let err = catalog
1643            .add("junk@1", &junk_path, &wasmtime::Engine::default())
1644            .unwrap_err();
1645        assert!(
1646            matches!(err, CatalogError::UnrecognizedArtifact { .. }),
1647            "{err:?}"
1648        );
1649    }
1650
1651    #[test]
1652    fn re_adding_the_same_name_version_is_rejected() {
1653        let catalog_dir = tempfile::tempdir().unwrap();
1654        let wasm_dir = tempfile::tempdir().unwrap();
1655        let wasm_path = wasm_dir.path().join("a.wasm");
1656        std::fs::write(
1657            &wasm_path,
1658            wat::parse_str(r#"(module (memory (export "memory") 1))"#).unwrap(),
1659        )
1660        .unwrap();
1661
1662        let catalog = Catalog::open(catalog_dir.path());
1663        let engine = wasmtime::Engine::default();
1664        catalog.add("dup@1", &wasm_path, &engine).unwrap();
1665
1666        let err = catalog.add("dup@1", &wasm_path, &engine).unwrap_err();
1667        assert!(matches!(err, CatalogError::AlreadyExists { .. }), "{err:?}");
1668    }
1669
1670    #[test]
1671    fn list_show_rm_roundtrip() {
1672        let dir = tempfile::tempdir().unwrap();
1673        seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
1674        let catalog = Catalog::open(dir.path());
1675
1676        assert_eq!(catalog.list().unwrap().len(), 1);
1677        let shown = catalog
1678            .show("a@1")
1679            .expect("just-seeded entry must be visible");
1680        assert_eq!(shown.signature, "json -> json");
1681
1682        catalog.rm("a@1").unwrap();
1683        assert!(catalog.list().unwrap().is_empty());
1684    }
1685
1686    #[test]
1687    fn showing_a_missing_entry_reports_not_found_with_a_suggestion() {
1688        let dir = tempfile::tempdir().unwrap();
1689        seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
1690        let catalog = Catalog::open(dir.path());
1691
1692        let err = catalog.show("summarise@1").unwrap_err();
1693        let CatalogError::NotFound { did_you_mean, .. } = &err else {
1694            panic!("expected NotFound, got {err:?}")
1695        };
1696        assert_eq!(did_you_mean, &vec!["summarize@1".to_string()]);
1697    }
1698
1699    /// Write a valid, signature-less wasm block whose bytes vary with
1700    /// `body_marker`, so two calls can produce artifacts that are both valid
1701    /// and genuinely different content.
1702    fn distinct_wasm(dir: &Path, name: &str, body_marker: u32) -> PathBuf {
1703        let path = dir.join(format!("{name}.wasm"));
1704        std::fs::write(
1705            &path,
1706            wat::parse_str(format!(
1707                r#"(module (memory (export "memory") 1) (func (export "marker") (result i32) i32.const {body_marker}))"#
1708            ))
1709            .unwrap(),
1710        )
1711        .unwrap();
1712        path
1713    }
1714
1715    #[test]
1716    fn an_identifier_with_no_at_version_is_rejected_rather_than_catalogued_under_a_typo() {
1717        let catalog_dir = tempfile::tempdir().unwrap();
1718        let wasm_dir = tempfile::tempdir().unwrap();
1719        let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1720
1721        let err = Catalog::open(catalog_dir.path())
1722            .add("echo-summarize", &wasm, &wasmtime::Engine::default())
1723            .expect_err("dropping @version is a typo, not a name meaning itself");
1724
1725        assert!(
1726            matches!(err, CatalogError::InvalidNameVersion { .. }),
1727            "{err:?}"
1728        );
1729        assert!(
1730            Catalog::open(catalog_dir.path()).list().unwrap().is_empty(),
1731            "a rejected identifier must not leave an entry behind"
1732        );
1733    }
1734
1735    #[test]
1736    fn an_identifier_with_an_empty_name_or_version_is_rejected() {
1737        let catalog_dir = tempfile::tempdir().unwrap();
1738        let wasm_dir = tempfile::tempdir().unwrap();
1739        let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1740        let catalog = Catalog::open(catalog_dir.path());
1741        let engine = wasmtime::Engine::default();
1742
1743        for bad in ["@1", "name@", "", "   "] {
1744            let err = catalog
1745                .add(bad, &wasm, &engine)
1746                .expect_err("an empty name or version is not a name@version");
1747            assert!(
1748                matches!(err, CatalogError::InvalidNameVersion { .. }),
1749                "{bad:?} gave {err:?}"
1750            );
1751        }
1752    }
1753
1754    #[test]
1755    fn an_identifier_with_more_than_one_at_separator_is_rejected() {
1756        let catalog_dir = tempfile::tempdir().unwrap();
1757        let wasm_dir = tempfile::tempdir().unwrap();
1758        let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1759
1760        let err = Catalog::open(catalog_dir.path())
1761            .add("a@b@c", &wasm, &wasmtime::Engine::default())
1762            .expect_err("two '@' separators is not a name@version");
1763        assert!(
1764            matches!(err, CatalogError::InvalidNameVersion { .. }),
1765            "{err:?}"
1766        );
1767    }
1768
1769    #[test]
1770    fn an_identifier_containing_path_or_whitespace_characters_is_rejected() {
1771        let catalog_dir = tempfile::tempdir().unwrap();
1772        let wasm_dir = tempfile::tempdir().unwrap();
1773        let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1774        let catalog = Catalog::open(catalog_dir.path());
1775        let engine = wasmtime::Engine::default();
1776
1777        for bad in ["../../etc/passwd@1", "with space@1", "name@../../tmp/pwn"] {
1778            let err = catalog
1779                .add(bad, &wasm, &engine)
1780                .expect_err("{bad} must be rejected");
1781            assert!(
1782                matches!(err, CatalogError::InvalidNameVersion { .. }),
1783                "{bad:?} gave {err:?}"
1784            );
1785        }
1786    }
1787
1788    #[test]
1789    fn an_ordinary_name_at_version_still_catalogs() {
1790        let catalog_dir = tempfile::tempdir().unwrap();
1791        let wasm_dir = tempfile::tempdir().unwrap();
1792        let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1793
1794        Catalog::open(catalog_dir.path())
1795            .add(
1796                "echo-summarize@1.2.3-rc.1",
1797                &wasm,
1798                &wasmtime::Engine::default(),
1799            )
1800            .expect("letters, digits, '.', '-' and '_' are all legal");
1801    }
1802
1803    /// Validation guards the *write* path only. An index that already holds a
1804    /// junk key (written before this check existed, or hand-edited) must stay
1805    /// removable, or the fix would strand entries nothing can clean up.
1806    #[test]
1807    fn a_pre_existing_junk_identifier_can_still_be_shown_and_removed() {
1808        let dir = tempfile::tempdir().unwrap();
1809        seed(dir.path(), "no-at-sign", "2026-01-01T00:00:00Z");
1810        let catalog = Catalog::open(dir.path());
1811
1812        catalog
1813            .show("no-at-sign")
1814            .expect("an already-stored key must remain inspectable");
1815        catalog
1816            .rm("no-at-sign")
1817            .expect("an already-stored key must remain removable");
1818    }
1819
1820    #[test]
1821    fn re_adding_a_removed_version_with_the_same_bytes_is_allowed() {
1822        let catalog_dir = tempfile::tempdir().unwrap();
1823        let wasm_dir = tempfile::tempdir().unwrap();
1824        let wasm = distinct_wasm(wasm_dir.path(), "same", 7);
1825        let catalog = Catalog::open(catalog_dir.path());
1826        let engine = wasmtime::Engine::default();
1827
1828        catalog.add("thing@1", &wasm, &engine).unwrap();
1829        catalog.rm("thing@1").unwrap();
1830        catalog
1831            .add("thing@1", &wasm, &engine)
1832            .expect("re-adding identical bytes is an undo of the rm, not a rewrite of history");
1833
1834        assert_eq!(catalog.list().unwrap().len(), 1);
1835    }
1836
1837    /// The hazard the immutability promise exists to prevent: a name@version
1838    /// that someone already depends on silently coming to mean different
1839    /// content. Deleting the entry first must not launder that.
1840    #[test]
1841    fn re_adding_a_removed_version_with_different_bytes_is_rejected() {
1842        let catalog_dir = tempfile::tempdir().unwrap();
1843        let wasm_dir = tempfile::tempdir().unwrap();
1844        let original = distinct_wasm(wasm_dir.path(), "original", 1);
1845        let replacement = distinct_wasm(wasm_dir.path(), "replacement", 2);
1846        let catalog = Catalog::open(catalog_dir.path());
1847        let engine = wasmtime::Engine::default();
1848
1849        catalog.add("thing@1", &original, &engine).unwrap();
1850        catalog.rm("thing@1").unwrap();
1851
1852        let err = catalog
1853            .add("thing@1", &replacement, &engine)
1854            .expect_err("rm must not be a way to republish a version with new content");
1855        let CatalogError::RetiredWithDifferentContent {
1856            name_version,
1857            previous_hash,
1858            new_hash,
1859        } = &err
1860        else {
1861            panic!("expected RetiredWithDifferentContent, got {err:?}")
1862        };
1863        assert_eq!(name_version, "thing@1");
1864        assert_ne!(previous_hash, new_hash);
1865        assert!(
1866            catalog.list().unwrap().is_empty(),
1867            "the reject must not add"
1868        );
1869    }
1870
1871    /// An index written before retirement tracking existed has no `retired`
1872    /// field at all. It must still load as a normal, non-corrupt catalog
1873    /// rather than tripping the version check.
1874    #[test]
1875    fn an_index_written_without_the_retired_field_still_loads() {
1876        let dir = tempfile::tempdir().unwrap();
1877        std::fs::create_dir_all(dir.path()).unwrap();
1878        std::fs::write(
1879            dir.path().join("index.json"),
1880            br#"{"version":1,"entries":{"old@1":{"hash":"sha256:ab","kind":"block","signature":"json -> json","created_at":"2026-01-01T00:00:00Z"}}}"#,
1881        )
1882        .unwrap();
1883
1884        let index = read_index(dir.path()).expect("an index predating `retired` is not corrupt");
1885        assert!(index.entries.contains_key("old@1"));
1886        assert!(index.retired.is_empty());
1887    }
1888
1889    #[test]
1890    fn removing_a_missing_entry_is_not_found_not_a_silent_no_op() {
1891        let dir = tempfile::tempdir().unwrap();
1892        let catalog = Catalog::open(dir.path());
1893        let err = catalog.rm("nothing@1").unwrap_err();
1894        assert!(matches!(err, CatalogError::NotFound { .. }), "{err:?}");
1895    }
1896
1897    #[test]
1898    fn removing_an_entry_leaves_its_blob_on_disk_v1_has_no_garbage_collection() {
1899        let dir = tempfile::tempdir().unwrap();
1900        let hash = write_blob(dir.path(), b"some block bytes").unwrap();
1901        let hex = hash.strip_prefix("sha256:").unwrap();
1902        with_locked_index(dir.path(), |index| {
1903            index.entries.insert(
1904                "a@1".to_string(),
1905                Entry {
1906                    hash: hash.clone(),
1907                    kind: ArtifactKind::Block,
1908                    signature: "json -> json".to_string(),
1909                    created_at: "2026-01-01T00:00:00Z".to_string(),
1910                },
1911            );
1912            Ok::<_, CatalogError>(())
1913        })
1914        .unwrap();
1915
1916        let catalog = Catalog::open(dir.path());
1917        catalog.rm("a@1").unwrap();
1918
1919        assert!(
1920            matches!(catalog.show("a@1"), Err(CatalogError::NotFound { .. })),
1921            "rm must actually remove the index entry, not silently no-op"
1922        );
1923        assert!(
1924            dir.path().join("blobs").join(hex).exists(),
1925            "rm is index-only; the blob must remain"
1926        );
1927    }
1928
1929    #[test]
1930    fn list_returns_multiple_entries_sorted_by_name_at_version() {
1931        let dir = tempfile::tempdir().unwrap();
1932        seed(dir.path(), "b@1", "2026-01-01T00:00:00Z");
1933        seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
1934        seed(dir.path(), "c@1", "2026-01-01T00:00:00Z");
1935
1936        let catalog = Catalog::open(dir.path());
1937        let names: Vec<String> = catalog
1938            .list()
1939            .unwrap()
1940            .into_iter()
1941            .map(|(name_version, _)| name_version)
1942            .collect();
1943
1944        assert_eq!(
1945            names,
1946            vec!["a@1".to_string(), "b@1".to_string(), "c@1".to_string()]
1947        );
1948    }
1949
1950    #[test]
1951    fn resolve_a_dot_wasm_suffix_is_direct_even_if_the_file_does_not_exist() {
1952        let dir = tempfile::tempdir().unwrap();
1953        let catalog = Catalog::open(dir.path());
1954        let resolved = catalog
1955            .resolve("/nonexistent/block.wasm", ResolutionContext::Interactive)
1956            .unwrap();
1957        assert!(matches!(resolved, Resolved::Direct(_)));
1958    }
1959
1960    #[test]
1961    fn resolve_a_dot_cfbundle_suffix_is_direct_even_if_the_file_does_not_exist() {
1962        let dir = tempfile::tempdir().unwrap();
1963        let catalog = Catalog::open(dir.path());
1964        let resolved = catalog
1965            .resolve(
1966                "/nonexistent/bundle.cfbundle",
1967                ResolutionContext::Interactive,
1968            )
1969            .unwrap();
1970        assert!(matches!(resolved, Resolved::Direct(_)));
1971    }
1972
1973    #[test]
1974    fn resolve_an_existing_filesystem_path_is_direct_no_catalog_lookup() {
1975        let dir = tempfile::tempdir().unwrap();
1976        let real_file = tempfile::NamedTempFile::new().unwrap();
1977        let catalog = Catalog::open(dir.path());
1978        let resolved = catalog
1979            .resolve(
1980                real_file.path().to_str().unwrap(),
1981                ResolutionContext::Interactive,
1982            )
1983            .unwrap();
1984        assert!(matches!(resolved, Resolved::Direct(_)));
1985    }
1986
1987    #[test]
1988    fn resolve_exact_name_at_version_hits_case_sensitively() {
1989        let dir = tempfile::tempdir().unwrap();
1990        seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
1991        let catalog = Catalog::open(dir.path());
1992
1993        assert!(catalog
1994            .resolve("summarize@1", ResolutionContext::Interactive)
1995            .is_ok());
1996
1997        let err = catalog
1998            .resolve("Summarize@1", ResolutionContext::Interactive)
1999            .unwrap_err();
2000        let CatalogError::NotFound { did_you_mean, .. } = &err else {
2001            panic!("expected NotFound (case-sensitive miss), got {err:?}")
2002        };
2003        assert!(
2004            did_you_mean.contains(&"summarize@1".to_string()),
2005            "case-sensitivity rejects the hit, but edit distance 1 should still suggest it: {did_you_mean:?}"
2006        );
2007    }
2008
2009    #[test]
2010    fn resolve_unqualified_name_picks_the_latest_by_created_at() {
2011        let dir = tempfile::tempdir().unwrap();
2012        seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2013        seed(dir.path(), "a@2", "2026-06-01T00:00:00Z");
2014        let catalog = Catalog::open(dir.path());
2015
2016        let resolved = catalog
2017            .resolve("a", ResolutionContext::Interactive)
2018            .unwrap();
2019        let Resolved::Cataloged { name_version, .. } = resolved else {
2020            panic!("expected a cataloged resolution")
2021        };
2022        assert_eq!(name_version, "a@2");
2023    }
2024
2025    #[test]
2026    fn resolve_unqualified_name_is_legal_from_an_interactive_context() {
2027        let dir = tempfile::tempdir().unwrap();
2028        seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2029        let catalog = Catalog::open(dir.path());
2030        assert!(catalog.resolve("a", ResolutionContext::Interactive).is_ok());
2031    }
2032
2033    #[test]
2034    fn resolve_unqualified_name_is_rejected_in_a_durable_context() {
2035        let dir = tempfile::tempdir().unwrap();
2036        seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2037        let catalog = Catalog::open(dir.path());
2038        let err = catalog
2039            .resolve("a", ResolutionContext::Durable)
2040            .unwrap_err();
2041        assert!(
2042            matches!(err, CatalogError::UnqualifiedName { .. }),
2043            "{err:?}"
2044        );
2045    }
2046
2047    #[test]
2048    fn resolve_not_found_suggests_a_close_typo() {
2049        let dir = tempfile::tempdir().unwrap();
2050        seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
2051        let catalog = Catalog::open(dir.path());
2052        let err = catalog
2053            .resolve("summarise@1", ResolutionContext::Interactive)
2054            .unwrap_err();
2055        let CatalogError::NotFound { did_you_mean, .. } = &err else {
2056            panic!("expected NotFound, got {err:?}")
2057        };
2058        assert_eq!(did_you_mean, &vec!["summarize@1".to_string()]);
2059    }
2060
2061    #[test]
2062    fn read_blob_returns_what_add_wrote() {
2063        let dir = tempfile::tempdir().unwrap();
2064        let catalog = Catalog::open(dir.path());
2065        let engine = wasmtime::Engine::default();
2066        let wasm = wat::parse_str("(module)").unwrap();
2067        let path = dir.path().join("m.wasm");
2068        std::fs::write(&path, &wasm).unwrap();
2069
2070        let outcome = catalog.add("m@1", &path, &engine).unwrap();
2071        let entry = catalog.show("m@1").unwrap();
2072
2073        let bytes = catalog.read_blob(&entry).unwrap();
2074        assert_eq!(bytes, wasm);
2075        assert_eq!(outcome.name_version, "m@1");
2076    }
2077
2078    #[test]
2079    fn read_blob_on_a_hand_edited_missing_hash_errors_clearly() {
2080        let dir = tempfile::tempdir().unwrap();
2081        let catalog = Catalog::open(dir.path());
2082        let fake = Entry {
2083            hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
2084                .to_string(),
2085            ..entry_fixture("2026-01-01T00:00:00Z")
2086        };
2087        let err = catalog.read_blob(&fake).unwrap_err();
2088        match err {
2089            CatalogError::Io(ref io_err) => {
2090                assert_eq!(
2091                    io_err.kind(),
2092                    std::io::ErrorKind::NotFound,
2093                    "a well-formed hash with no matching blob file must surface as a plain \
2094                     not-found I/O error: {err:?}"
2095                );
2096            }
2097            other => {
2098                panic!("a well-formed but absent hash must be a plain Io(NotFound), not {other:?}")
2099            }
2100        }
2101    }
2102
2103    #[test]
2104    fn read_blob_rejects_a_path_traversal_hash_instead_of_touching_the_filesystem() {
2105        // A hand-edited (or maliciously crafted) index.json is never
2106        // format-validated on read anywhere else in this module — read_blob
2107        // is the last line of defense before a hash string becomes a
2108        // filesystem path. A well-formed sha256 digest is always exactly 64
2109        // lowercase hex digits (see write_blob's `format!("{:x}", ...)`), so
2110        // anything else — especially `../` traversal or an absolute path —
2111        // must be rejected before Path::join ever sees it.
2112        let dir = tempfile::tempdir().unwrap();
2113        // Plant a marker file outside blobs/ that a traversal would reach if
2114        // the guard were missing.
2115        std::fs::write(dir.path().join("outside.txt"), b"do not leak this").unwrap();
2116
2117        let catalog = Catalog::open(dir.path());
2118        let traversal = Entry {
2119            hash: "sha256:../outside.txt".to_string(),
2120            ..entry_fixture("2026-01-01T00:00:00Z")
2121        };
2122        let err = catalog.read_blob(&traversal).unwrap_err();
2123        assert!(
2124            matches!(err, CatalogError::MalformedHash { .. }),
2125            "a path-traversal hash must be rejected as MalformedHash before any path is \
2126             constructed, got {err:?}"
2127        );
2128
2129        let absolute = Entry {
2130            hash: "sha256:/etc/passwd".to_string(),
2131            ..entry_fixture("2026-01-01T00:00:00Z")
2132        };
2133        let err = catalog.read_blob(&absolute).unwrap_err();
2134        assert!(
2135            matches!(err, CatalogError::MalformedHash { .. }),
2136            "an absolute-path-like hash must be rejected as MalformedHash before any path is \
2137             constructed, got {err:?}"
2138        );
2139    }
2140}