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 the catalog and most of
823/// this crate's other on-disk layout is rooted under (the jobs directory is
824/// the exception: it honors `$CUTTLEFISH_JOBS_HOME` first, see
825/// `ledger::jobs_root`). `None` when neither `$CUTTLEFISH_HOME` nor a
826/// resolvable home directory is available — this library never exits the
827/// process on a caller's behalf, so reporting that is the caller's job (both
828/// `cuttlefish` and `cuttlefishd` already have their own error-reporting
829/// convention).
830pub(crate) fn cuttlefish_home() -> Option<PathBuf> {
831    if let Ok(home) = std::env::var("CUTTLEFISH_HOME") {
832        return Some(PathBuf::from(home));
833    }
834    dirs::home_dir().map(|home| home.join(".cuttlefish"))
835}
836
837/// Where the catalog lives when the caller doesn't say otherwise:
838/// `$CUTTLEFISH_HOME/catalog` if set, else `~/.cuttlefish/catalog`. `None`
839/// when neither is available — see `cuttlefish_home` (private).
840pub fn default_root() -> Option<PathBuf> {
841    cuttlefish_home().map(|h| h.join("catalog"))
842}
843
844/// The current UTC time, truncated to whole seconds and formatted as RFC
845/// 3339 (`2026-08-02T18:03:00Z`). Truncating avoids variable-width fractional
846/// seconds, so `created_at` strings sort correctly with plain string
847/// comparison (used for "give me the latest" and did-you-mean tie-breaking)
848/// without ever needing to be parsed back.
849pub(crate) fn now_rfc3339() -> String {
850    let now = time::OffsetDateTime::now_utc()
851        .replace_nanosecond(0)
852        .expect("0 is always a valid nanosecond value");
853    now.format(&time::format_description::well_known::Rfc3339)
854        .expect("Rfc3339 formatting cannot fail for a valid OffsetDateTime")
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use std::sync::atomic::{AtomicBool, Ordering};
861    use std::sync::{Arc, Barrier};
862
863    /// Enough threads to genuinely contend for the index lock on any machine
864    /// this runs on, without making the test slow.
865    const WRITERS: usize = 16;
866
867    #[test]
868    fn default_root_honors_cuttlefish_home() {
869        // No other test in this process mutates CUTTLEFISH_HOME, and
870        // catalog.rs's own tests never read it — set/remove is confined to
871        // this one test. (This toolchain's std::env::set_var/remove_var are
872        // safe fns, not unsafe — the crate forbids unsafe_code entirely, so
873        // an unsafe wrapper isn't an option regardless.)
874        std::env::set_var("CUTTLEFISH_HOME", "/tmp/cf-test-home");
875        let root = default_root();
876        std::env::remove_var("CUTTLEFISH_HOME");
877        assert_eq!(root, Some(PathBuf::from("/tmp/cf-test-home/catalog")));
878    }
879
880    #[test]
881    fn index_file_serializes_to_the_shape_the_spec_documents() {
882        let mut entries = BTreeMap::new();
883        entries.insert(
884            "chunk-text@1".to_string(),
885            Entry {
886                hash: "sha256:9f86d081".to_string(),
887                kind: ArtifactKind::Block,
888                signature: "{path: text} -> [text]".to_string(),
889                created_at: "2026-08-02T18:03:00Z".to_string(),
890            },
891        );
892        let index = IndexFile {
893            version: INDEX_VERSION,
894            entries,
895            retired: BTreeMap::new(),
896        };
897
898        let json = serde_json::to_string(&index).expect("IndexFile always serializes");
899        let parsed: serde_json::Value =
900            serde_json::from_str(&json).expect("what we just wrote must parse");
901
902        assert_eq!(parsed["version"], 1);
903        assert_eq!(parsed["entries"]["chunk-text@1"]["kind"], "block");
904        assert_eq!(
905            parsed["entries"]["chunk-text@1"]["signature"],
906            "{path: text} -> [text]"
907        );
908
909        let round_tripped: IndexFile =
910            serde_json::from_str(&json).expect("must deserialize what we just serialized");
911        assert_eq!(round_tripped.version, INDEX_VERSION);
912        assert!(round_tripped.entries.contains_key("chunk-text@1"));
913    }
914
915    #[test]
916    fn not_found_with_suggestions_reads_as_one_sentence() {
917        let err = CatalogError::NotFound {
918            name_version: "summarise@1".to_string(),
919            did_you_mean: vec!["summarize@1".to_string()],
920        };
921        assert_eq!(
922            err.to_string(),
923            "no such catalog entry: summarise@1 (did you mean: summarize@1?)"
924        );
925    }
926
927    #[test]
928    fn not_found_with_no_suggestions_has_no_dangling_parenthetical() {
929        let err = CatalogError::NotFound {
930            name_version: "xyz@1".to_string(),
931            did_you_mean: vec![],
932        };
933        assert_eq!(err.to_string(), "no such catalog entry: xyz@1");
934    }
935
936    fn entry_fixture(created_at: &str) -> Entry {
937        Entry {
938            hash: "sha256:deadbeef".to_string(),
939            kind: ArtifactKind::Block,
940            signature: "json -> json".to_string(),
941            created_at: created_at.to_string(),
942        }
943    }
944
945    fn seed(root: &Path, name_version: &str, created_at: &str) {
946        with_locked_index(root, |index| {
947            index
948                .entries
949                .insert(name_version.to_string(), entry_fixture(created_at));
950            Ok::<_, CatalogError>(())
951        })
952        .unwrap();
953    }
954
955    #[test]
956    fn levenshtein_matches_known_distances() {
957        assert_eq!(levenshtein("kitten", "sitting"), 3);
958        assert_eq!(levenshtein("summarize", "summarise"), 1);
959        assert_eq!(levenshtein("same", "same"), 0);
960    }
961
962    #[test]
963    fn did_you_mean_catches_a_one_character_typo_a_prefix_match_would_miss() {
964        // "summarise" and "summarize" share no prefix relationship (they diverge
965        // at the 8th character) — a starts-with prefix match would silently
966        // produce zero suggestions on exactly this typo.
967        let mut entries = BTreeMap::new();
968        entries.insert(
969            "summarize@1".to_string(),
970            entry_fixture("2026-01-01T00:00:00Z"),
971        );
972        assert_eq!(
973            pick_did_you_mean("summarise", &entries),
974            vec!["summarize@1".to_string()]
975        );
976    }
977
978    #[test]
979    fn did_you_mean_is_empty_when_nothing_registered_is_close() {
980        let mut entries = BTreeMap::new();
981        entries.insert(
982            "summarize@1".to_string(),
983            entry_fixture("2026-01-01T00:00:00Z"),
984        );
985        assert!(pick_did_you_mean("completely-unrelated-name", &entries).is_empty());
986    }
987
988    #[test]
989    fn did_you_mean_is_capped_at_five_closest_ordered_by_distance() {
990        let mut entries = BTreeMap::new();
991        // All within edit distance 1 of "cat" by construction (each swaps one
992        // letter), so the cap — not the distance threshold — is what's under test.
993        for (i, name) in ["bat", "cot", "car", "cap", "can", "cad"]
994            .iter()
995            .enumerate()
996        {
997            entries.insert(
998                format!("{name}@1"),
999                entry_fixture(&format!("2026-01-0{}T00:00:00Z", i + 1)),
1000            );
1001        }
1002        let suggestions = pick_did_you_mean("cat", &entries);
1003        assert_eq!(suggestions.len(), 5, "capped at 5: {suggestions:?}");
1004    }
1005
1006    #[test]
1007    fn did_you_mean_suggests_the_newest_version_when_multiple_versions_of_a_close_name_exist() {
1008        let mut entries = BTreeMap::new();
1009        entries.insert(
1010            "summarize@1".to_string(),
1011            entry_fixture("2026-01-01T00:00:00Z"),
1012        );
1013        entries.insert(
1014            "summarize@2".to_string(),
1015            entry_fixture("2026-06-01T00:00:00Z"),
1016        );
1017        assert_eq!(
1018            pick_did_you_mean("summarise", &entries),
1019            vec!["summarize@2".to_string()],
1020            "must suggest the newest version of a matching name, not every version"
1021        );
1022    }
1023
1024    #[test]
1025    fn wasm_magic_bytes_sniff_as_a_block() {
1026        assert_eq!(
1027            sniff_artifact_kind(b"\0asm\x01\x00\x00\x00"),
1028            Some(ArtifactKind::Block)
1029        );
1030    }
1031
1032    #[test]
1033    fn bundle_magic_bytes_sniff_as_a_bundle() {
1034        assert_eq!(
1035            sniff_artifact_kind(b"CFBD\x00\x00\x00\x00\x00\x00\x00\x00"),
1036            Some(ArtifactKind::Bundle)
1037        );
1038    }
1039
1040    #[test]
1041    fn unrecognised_bytes_sniff_to_none_not_a_guess() {
1042        assert_eq!(sniff_artifact_kind(b"whatever-this-is"), None);
1043    }
1044
1045    #[test]
1046    fn writing_then_reading_the_index_round_trips_through_disk() {
1047        let dir = tempfile::tempdir().unwrap();
1048        with_locked_index(dir.path(), |index| {
1049            index
1050                .entries
1051                .insert("a@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1052            Ok::<_, CatalogError>(())
1053        })
1054        .unwrap();
1055
1056        let index = read_index(dir.path()).unwrap();
1057        assert!(index.entries.contains_key("a@1"));
1058    }
1059
1060    #[test]
1061    fn reading_an_index_that_does_not_exist_yet_is_an_empty_catalog_not_an_error() {
1062        let dir = tempfile::tempdir().unwrap();
1063        let index = read_index(dir.path()).expect("no index.json yet is not corruption");
1064        assert!(index.entries.is_empty());
1065    }
1066
1067    #[test]
1068    fn a_truncated_index_is_a_corrupt_index_error_not_an_empty_catalog() {
1069        let dir = tempfile::tempdir().unwrap();
1070        std::fs::create_dir_all(dir.path()).unwrap();
1071        std::fs::write(dir.path().join("index.json"), b"{\"version\": 1, \"ent").unwrap();
1072
1073        let err = read_index(dir.path()).unwrap_err();
1074        assert!(
1075            matches!(err, CatalogError::CorruptIndex { .. }),
1076            "a truncated index must be a loud CorruptIndex, not treated as empty: {err:?}"
1077        );
1078    }
1079
1080    #[test]
1081    fn an_unsupported_index_version_is_a_corrupt_index_error() {
1082        let dir = tempfile::tempdir().unwrap();
1083        std::fs::create_dir_all(dir.path()).unwrap();
1084        std::fs::write(
1085            dir.path().join("index.json"),
1086            br#"{"version": 999, "entries": {}}"#,
1087        )
1088        .unwrap();
1089
1090        let err = read_index(dir.path()).unwrap_err();
1091        assert!(matches!(err, CatalogError::CorruptIndex { .. }), "{err:?}");
1092    }
1093
1094    #[test]
1095    fn concurrent_writes_from_two_threads_both_land_and_the_index_stays_parseable() {
1096        let dir = tempfile::tempdir().unwrap();
1097        let root_a = dir.path().to_path_buf();
1098        let root_b = dir.path().to_path_buf();
1099
1100        let t1 = std::thread::spawn(move || {
1101            with_locked_index(&root_a, |index| {
1102                index
1103                    .entries
1104                    .insert("a@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1105                Ok::<_, CatalogError>(())
1106            })
1107            .unwrap();
1108        });
1109        let t2 = std::thread::spawn(move || {
1110            with_locked_index(&root_b, |index| {
1111                index
1112                    .entries
1113                    .insert("b@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1114                Ok::<_, CatalogError>(())
1115            })
1116            .unwrap();
1117        });
1118        t1.join().unwrap();
1119        t2.join().unwrap();
1120
1121        let index = read_index(dir.path()).expect("the index must still parse after contention");
1122        assert!(index.entries.contains_key("a@1"));
1123        assert!(index.entries.contains_key("b@1"));
1124    }
1125
1126    /// `add`'s duplicate check runs *inside* the locked section, so a pack of
1127    /// writers racing for one key must resolve to exactly one winner — the
1128    /// "versions are immutable once published" promise only holds under
1129    /// contention if the check-then-insert is genuinely atomic. Every thread
1130    /// is held at a barrier so they collide on the lock rather than politely
1131    /// serializing.
1132    #[test]
1133    fn racing_inserts_of_the_same_key_leave_exactly_one_winner() {
1134        let dir = tempfile::tempdir().unwrap();
1135        let root = dir.path().to_path_buf();
1136        let barrier = Arc::new(Barrier::new(WRITERS));
1137
1138        let handles: Vec<_> = (0..WRITERS)
1139            .map(|_| {
1140                let root = root.clone();
1141                let barrier = barrier.clone();
1142                std::thread::spawn(move || {
1143                    barrier.wait();
1144                    with_locked_index(&root, |index| {
1145                        if index.entries.contains_key("race@1") {
1146                            return Err(CatalogError::AlreadyExists {
1147                                name_version: "race@1".to_string(),
1148                            });
1149                        }
1150                        index
1151                            .entries
1152                            .insert("race@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1153                        Ok(())
1154                    })
1155                })
1156            })
1157            .collect();
1158
1159        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1160
1161        let winners = results.iter().filter(|r| r.is_ok()).count();
1162        assert_eq!(
1163            winners, 1,
1164            "exactly one racing writer may claim a key; got {winners}"
1165        );
1166        assert!(
1167            results
1168                .iter()
1169                .all(|r| r.is_ok() || matches!(r, Err(CatalogError::AlreadyExists { .. }))),
1170            "every loser must lose with AlreadyExists, not an io or corruption error: {results:?}"
1171        );
1172
1173        let index = read_index(&root).expect("the index must still parse after contention");
1174        assert_eq!(index.entries.len(), 1);
1175    }
1176
1177    /// The two-thread test above proves the lock exists; this proves it holds
1178    /// up under real contention. Without a barrier, two threads usually finish
1179    /// one after another and never touch the lock at the same time, so a
1180    /// read-modify-write that lost updates could still pass.
1181    #[test]
1182    fn many_racing_writers_of_distinct_keys_all_land_with_no_lost_updates() {
1183        let dir = tempfile::tempdir().unwrap();
1184        let root = dir.path().to_path_buf();
1185        let barrier = Arc::new(Barrier::new(WRITERS));
1186
1187        let handles: Vec<_> = (0..WRITERS)
1188            .map(|w| {
1189                let root = root.clone();
1190                let barrier = barrier.clone();
1191                std::thread::spawn(move || {
1192                    barrier.wait();
1193                    with_locked_index(&root, |index| {
1194                        index.entries.insert(
1195                            format!("writer-{w}@1"),
1196                            entry_fixture("2026-01-01T00:00:00Z"),
1197                        );
1198                        Ok::<_, CatalogError>(())
1199                    })
1200                    .unwrap();
1201                })
1202            })
1203            .collect();
1204        for h in handles {
1205            h.join().unwrap();
1206        }
1207
1208        let index = read_index(&root).expect("the index must still parse after contention");
1209        assert_eq!(
1210            index.entries.len(),
1211            WRITERS,
1212            "every writer's entry must survive; a lost update means the \
1213             read-modify-write escaped the lock: {:?}",
1214            index.entries.keys().collect::<Vec<_>>()
1215        );
1216    }
1217
1218    /// `with_locked_index` documents that readers need no lock because a
1219    /// writer only ever publishes via rename, so a reader sees the wholly-old
1220    /// or wholly-new file and never a half-written one. Nothing asserted it:
1221    /// this hammers lock-free readers against writers and fails if any read
1222    /// ever comes back corrupt.
1223    #[test]
1224    fn lock_free_readers_never_observe_a_partial_index_while_writers_hammer() {
1225        let dir = tempfile::tempdir().unwrap();
1226        let root = dir.path().to_path_buf();
1227        // Seed first so index.json exists before any reader starts — a
1228        // missing index is legitimately empty, which would mask a torn read.
1229        seed(&root, "seed@1", "2026-01-01T00:00:00Z");
1230
1231        let stop = Arc::new(AtomicBool::new(false));
1232
1233        let writers: Vec<_> = (0..4)
1234            .map(|w| {
1235                let root = root.clone();
1236                std::thread::spawn(move || {
1237                    for i in 0..60 {
1238                        with_locked_index(&root, |index| {
1239                            index.entries.insert(
1240                                format!("w{w}-{i}@1"),
1241                                entry_fixture("2026-01-01T00:00:00Z"),
1242                            );
1243                            Ok::<_, CatalogError>(())
1244                        })
1245                        .unwrap();
1246                    }
1247                })
1248            })
1249            .collect();
1250
1251        let readers: Vec<_> = (0..4)
1252            .map(|_| {
1253                let root = root.clone();
1254                let stop = stop.clone();
1255                std::thread::spawn(move || {
1256                    let mut reads = 0u32;
1257                    while !stop.load(Ordering::Relaxed) {
1258                        let index = read_index(&root)
1259                            .expect("a lock-free reader must never see a partial or corrupt index");
1260                        // A torn read that still parsed would most likely show
1261                        // up as losing the seed entry that is only ever added.
1262                        assert!(
1263                            index.entries.contains_key("seed@1"),
1264                            "an entry that is never removed vanished from a concurrent read"
1265                        );
1266                        reads += 1;
1267                    }
1268                    reads
1269                })
1270            })
1271            .collect();
1272
1273        for w in writers {
1274            w.join().unwrap();
1275        }
1276        stop.store(true, Ordering::Relaxed);
1277
1278        let total: u32 = readers.into_iter().map(|r| r.join().unwrap()).sum();
1279        assert!(
1280            total > 0,
1281            "the readers must have actually observed the index"
1282        );
1283    }
1284
1285    /// Every other concurrency test here races `add` against `add`. Removals
1286    /// take the same lock and rewrite the same file, so a mixed workload is
1287    /// where an asymmetry would show up — e.g. a removal path that wrote the
1288    /// index outside the locked section. Each thread owns a disjoint key and
1289    /// adds then removes it, so the end state is exactly the untouched
1290    /// keep-alive entries regardless of interleaving.
1291    #[test]
1292    fn adds_and_removals_racing_on_one_index_leave_exactly_the_expected_entries() {
1293        let dir = tempfile::tempdir().unwrap();
1294        let root = dir.path().to_path_buf();
1295        seed(&root, "keep@1", "2026-01-01T00:00:00Z");
1296        seed(&root, "keep@2", "2026-01-01T00:00:00Z");
1297
1298        let barrier = Arc::new(Barrier::new(WRITERS));
1299        let handles: Vec<_> = (0..WRITERS)
1300            .map(|w| {
1301                let root = root.clone();
1302                let barrier = barrier.clone();
1303                std::thread::spawn(move || {
1304                    let key = format!("churn-{w}@1");
1305                    barrier.wait();
1306                    for _ in 0..10 {
1307                        with_locked_index(&root, |index| {
1308                            index
1309                                .entries
1310                                .insert(key.clone(), entry_fixture("2026-01-01T00:00:00Z"));
1311                            Ok::<_, CatalogError>(())
1312                        })
1313                        .unwrap();
1314                        with_locked_index(&root, |index| {
1315                            index.entries.remove(&key).expect(
1316                                "a key only this thread ever touches must still be present",
1317                            );
1318                            Ok::<_, CatalogError>(())
1319                        })
1320                        .unwrap();
1321                    }
1322                })
1323            })
1324            .collect();
1325        for h in handles {
1326            h.join().unwrap();
1327        }
1328
1329        let index = read_index(&root).expect("the index must still parse after mixed contention");
1330        let names: Vec<_> = index.entries.keys().cloned().collect();
1331        assert_eq!(
1332            names,
1333            vec!["keep@1".to_string(), "keep@2".to_string()],
1334            "churn keys must all be gone and the untouched entries must survive"
1335        );
1336    }
1337
1338    #[test]
1339    fn identical_bytes_under_two_writes_produce_exactly_one_blob_file() {
1340        let dir = tempfile::tempdir().unwrap();
1341        let hash1 = write_blob(dir.path(), b"hello world").unwrap();
1342        let hash2 = write_blob(dir.path(), b"hello world").unwrap();
1343
1344        assert_eq!(hash1, hash2);
1345        assert!(hash1.starts_with("sha256:"));
1346
1347        let blob_count = std::fs::read_dir(dir.path().join("blobs")).unwrap().count();
1348        assert_eq!(
1349            blob_count, 1,
1350            "identical bytes must dedupe to a single blob file"
1351        );
1352    }
1353
1354    #[test]
1355    fn the_blob_filename_on_disk_is_bare_hex_no_prefix() {
1356        let dir = tempfile::tempdir().unwrap();
1357        let hash = write_blob(dir.path(), b"hello world").unwrap();
1358        let hex = hash
1359            .strip_prefix("sha256:")
1360            .expect("index field is prefixed");
1361
1362        assert!(dir.path().join("blobs").join(hex).exists());
1363    }
1364
1365    #[test]
1366    fn many_concurrent_writers_of_identical_bytes_never_corrupt_the_blob() {
1367        let dir = tempfile::tempdir().unwrap();
1368        let root = dir.path().to_path_buf();
1369        let content = b"identical content raced by many concurrent writers";
1370
1371        let handles: Vec<_> = (0..16)
1372            .map(|_| {
1373                let root = root.clone();
1374                std::thread::spawn(move || write_blob(&root, content).unwrap())
1375            })
1376            .collect();
1377
1378        let hashes: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1379        assert!(
1380            hashes.iter().all(|h| h == &hashes[0]),
1381            "every writer must compute and report the same hash: {hashes:?}"
1382        );
1383
1384        let hex = hashes[0].strip_prefix("sha256:").unwrap();
1385        let blob_bytes = std::fs::read(root.join("blobs").join(hex)).unwrap();
1386        assert_eq!(
1387            blob_bytes, content,
1388            "the published blob must be exactly the input bytes, not truncated or corrupted by a racing writer"
1389        );
1390    }
1391
1392    fn make_bundle(manifest_json: &[u8]) -> Vec<u8> {
1393        let mut bytes = b"CFBD".to_vec();
1394        bytes.extend_from_slice(&(manifest_json.len() as u64).to_le_bytes());
1395        bytes.extend_from_slice(manifest_json);
1396        bytes
1397    }
1398
1399    #[test]
1400    fn reads_the_signature_field_out_of_a_valid_bundle_manifest() {
1401        let bundle = make_bundle(
1402            br#"{"nodes":[],"edges":[],"signature":"{path: text} -> {summary: text}"}"#,
1403        );
1404        let sig = read_bundle_signature(&bundle, "test.cfbundle").unwrap();
1405        assert_eq!(sig, "{path: text} -> {summary: text}");
1406    }
1407
1408    #[test]
1409    fn a_manifest_len_exceeding_the_actual_bytes_is_uninspectable() {
1410        let mut bundle = make_bundle(br#"{"nodes":[],"edges":[],"signature":"x -> x"}"#);
1411        bundle.truncate(bundle.len() - 5); // manifest_len now overshoots what's left
1412        let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1413        match err {
1414            CatalogError::UninspectableArtifact { reason, .. } => assert!(
1415                reason.contains("exceeds the file's actual length"),
1416                "{reason}"
1417            ),
1418            other => panic!("expected UninspectableArtifact, got {other:?}"),
1419        }
1420    }
1421
1422    #[test]
1423    fn invalid_manifest_json_is_uninspectable() {
1424        let bundle = make_bundle(b"not valid json at all");
1425        let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1426        match err {
1427            CatalogError::UninspectableArtifact { reason, .. } => {
1428                assert!(reason.contains("not valid JSON"), "{reason}")
1429            }
1430            other => panic!("expected UninspectableArtifact, got {other:?}"),
1431        }
1432    }
1433
1434    #[test]
1435    fn a_manifest_missing_the_signature_field_is_uninspectable() {
1436        let bundle = make_bundle(br#"{"nodes":[],"edges":[]}"#);
1437        let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1438        match err {
1439            CatalogError::UninspectableArtifact { reason, .. } => {
1440                assert!(reason.contains("no string field"), "{reason}")
1441            }
1442            other => panic!("expected UninspectableArtifact, got {other:?}"),
1443        }
1444    }
1445
1446    #[test]
1447    fn a_node_whose_offset_and_len_overflow_the_stage_bytes_is_uninspectable() {
1448        // No stage bytes follow the manifest at all here, so any non-zero
1449        // offset/len is already out of bounds — exactly the "internally
1450        // impossible node table" shape a hand-crafted or corrupt bundle
1451        // could smuggle past a check that only ever reads the `signature`
1452        // field.
1453        let bundle = make_bundle(
1454            br#"{"nodes":[{"name":"bad","kind":"block","resolved":null,
1455                 "signature":"json -> json","offset":99999,"len":99999}],
1456                 "signature":"json -> json"}"#,
1457        );
1458        let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1459        match err {
1460            CatalogError::UninspectableArtifact { reason, .. } => {
1461                assert!(reason.contains("doesn't fit"), "{reason}")
1462            }
1463            other => panic!("expected UninspectableArtifact, got {other:?}"),
1464        }
1465    }
1466
1467    #[test]
1468    fn a_node_whose_offset_and_len_exactly_fit_the_stage_bytes_is_fine() {
1469        let mut bundle = make_bundle(
1470            br#"{"nodes":[{"name":"ok","kind":"block","resolved":null,
1471                 "signature":"json -> json","offset":0,"len":3}],
1472                 "signature":"json -> json"}"#,
1473        );
1474        bundle.extend_from_slice(b"abc");
1475        let sig = read_bundle_signature(&bundle, "test.cfbundle").unwrap();
1476        assert_eq!(sig, "json -> json");
1477    }
1478
1479    #[test]
1480    fn an_overflowing_node_offset_plus_len_is_uninspectable_not_a_panic() {
1481        // Regression-shaped like the manifest_len overflow fix elsewhere in
1482        // this file: offset + len must not panic on overflow, it must
1483        // report a clean error.
1484        let bundle = make_bundle(
1485            format!(
1486                r#"{{"nodes":[{{"name":"bad","kind":"block","resolved":null,
1487                     "signature":"json -> json","offset":{},"len":10}}],
1488                     "signature":"json -> json"}}"#,
1489                u64::MAX
1490            )
1491            .as_bytes(),
1492        );
1493        let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1494        assert!(matches!(err, CatalogError::UninspectableArtifact { .. }));
1495    }
1496
1497    #[test]
1498    fn an_overflowing_manifest_len_is_uninspectable_not_a_panic() {
1499        // manifest_len near u64::MAX must not panic when added to BUNDLE_HEADER_LEN —
1500        // regression test for the checked_add fix (a bare `+` here panics with
1501        // "attempt to add with overflow" in debug builds, turning a crafted
1502        // bundle file into a crash instead of a clean error).
1503        let mut bytes = b"CFBD".to_vec();
1504        bytes.extend_from_slice(&u64::MAX.to_le_bytes());
1505        let err = read_bundle_signature(&bytes, "test.cfbundle").unwrap_err();
1506        match err {
1507            CatalogError::UninspectableArtifact { reason, .. } => {
1508                assert!(
1509                    reason.contains("exceeds the file's actual length"),
1510                    "{reason}"
1511                )
1512            }
1513            other => panic!("expected UninspectableArtifact, got {other:?}"),
1514        }
1515    }
1516
1517    #[test]
1518    fn a_file_shorter_than_the_header_is_uninspectable() {
1519        let err = read_bundle_signature(b"CFBD", "test.cfbundle").unwrap_err();
1520        match err {
1521            CatalogError::UninspectableArtifact { reason, .. } => {
1522                assert!(
1523                    reason.contains("shorter than the bundle header"),
1524                    "{reason}"
1525                )
1526            }
1527            other => panic!("expected UninspectableArtifact, got {other:?}"),
1528        }
1529    }
1530
1531    #[test]
1532    fn adding_a_wasm_block_with_no_cf_signature_export_caches_the_permissive_default_and_flags_it()
1533    {
1534        let catalog_dir = tempfile::tempdir().unwrap();
1535        let wasm_dir = tempfile::tempdir().unwrap();
1536        let wasm_path = wasm_dir.path().join("no_sig.wasm");
1537        std::fs::write(
1538            &wasm_path,
1539            wat::parse_str(r#"(module (memory (export "memory") 1))"#).unwrap(),
1540        )
1541        .unwrap();
1542
1543        let catalog = Catalog::open(catalog_dir.path());
1544        let outcome = catalog
1545            .add("no-sig@1", &wasm_path, &wasmtime::Engine::default())
1546            .expect("a block missing cf_signature is not an add-time error");
1547
1548        assert_eq!(outcome.signature, "json -> json");
1549        assert!(
1550            outcome.is_permissive_default,
1551            "a block with no cf_signature export must be flagged, not silently accepted"
1552        );
1553    }
1554
1555    #[test]
1556    fn adding_wasm_magic_bytes_with_an_invalid_module_body_is_uninspectable() {
1557        let catalog_dir = tempfile::tempdir().unwrap();
1558        let wasm_dir = tempfile::tempdir().unwrap();
1559        let wasm_path = wasm_dir.path().join("broken.wasm");
1560        // Real wasm magic, garbage after it: passes the magic-byte sniff, fails
1561        // Module::new — the "recognised header, unreadable contents" case.
1562        std::fs::write(
1563            &wasm_path,
1564            b"\0asm\x01\x00\x00\x00garbage-not-a-real-module",
1565        )
1566        .unwrap();
1567
1568        let catalog = Catalog::open(catalog_dir.path());
1569        let err = catalog
1570            .add("broken@1", &wasm_path, &wasmtime::Engine::default())
1571            .unwrap_err();
1572        assert!(
1573            matches!(err, CatalogError::UninspectableArtifact { .. }),
1574            "{err:?}"
1575        );
1576    }
1577
1578    #[test]
1579    fn a_cf_signature_export_that_exists_but_returns_unparseable_bytes_is_uninspectable_not_permissive(
1580    ) {
1581        // Distinct from both prior tests: the module instantiates fine and
1582        // cf_signature exists with the right callable shape (() -> u32) — this
1583        // is the "present but broken" case, which must NOT be folded into the
1584        // "absent" case's permissive-default fallback. The descriptor it returns
1585        // points at zeroed memory (no data segment): reading it back yields an
1586        // empty buffer, which fails to parse as a Signature — read_signature
1587        // returns Err, and that must surface as UninspectableArtifact.
1588        let catalog_dir = tempfile::tempdir().unwrap();
1589        let wasm_dir = tempfile::tempdir().unwrap();
1590        let wasm_path = wasm_dir.path().join("broken_sig.wasm");
1591        std::fs::write(
1592            &wasm_path,
1593            wat::parse_str(
1594                r#"(module
1595                     (memory (export "memory") 1)
1596                     (func (export "cf_signature") (result i32) i32.const 0)
1597                   )"#,
1598            )
1599            .unwrap(),
1600        )
1601        .unwrap();
1602
1603        let catalog = Catalog::open(catalog_dir.path());
1604        let err = catalog
1605            .add("broken-sig@1", &wasm_path, &wasmtime::Engine::default())
1606            .unwrap_err();
1607        assert!(
1608            matches!(err, CatalogError::UninspectableArtifact { .. }),
1609            "present-but-unparseable cf_signature must be a hard failure, not the permissive default: {err:?}"
1610        );
1611    }
1612
1613    #[test]
1614    fn adding_a_bundle_reads_its_signature_from_the_manifest_never_instantiating_wasm() {
1615        let catalog_dir = tempfile::tempdir().unwrap();
1616        let bundle_dir = tempfile::tempdir().unwrap();
1617        let bundle_path = bundle_dir.path().join("digest.cfbundle");
1618        std::fs::write(
1619            &bundle_path,
1620            make_bundle(
1621                br#"{"nodes":[],"edges":[],"signature":"{path: text} -> {summary: text}"}"#,
1622            ),
1623        )
1624        .unwrap();
1625
1626        let catalog = Catalog::open(catalog_dir.path());
1627        let outcome = catalog
1628            .add("digest@1", &bundle_path, &wasmtime::Engine::default())
1629            .unwrap();
1630
1631        assert_eq!(outcome.kind, ArtifactKind::Bundle);
1632        assert_eq!(outcome.signature, "{path: text} -> {summary: text}");
1633        assert!(!outcome.is_permissive_default);
1634    }
1635
1636    #[test]
1637    fn adding_a_file_with_neither_magic_is_unrecognized_not_a_silent_guess() {
1638        let catalog_dir = tempfile::tempdir().unwrap();
1639        let junk_dir = tempfile::tempdir().unwrap();
1640        let junk_path = junk_dir.path().join("junk.bin");
1641        std::fs::write(&junk_path, b"not a wasm or bundle").unwrap();
1642
1643        let catalog = Catalog::open(catalog_dir.path());
1644        let err = catalog
1645            .add("junk@1", &junk_path, &wasmtime::Engine::default())
1646            .unwrap_err();
1647        assert!(
1648            matches!(err, CatalogError::UnrecognizedArtifact { .. }),
1649            "{err:?}"
1650        );
1651    }
1652
1653    #[test]
1654    fn re_adding_the_same_name_version_is_rejected() {
1655        let catalog_dir = tempfile::tempdir().unwrap();
1656        let wasm_dir = tempfile::tempdir().unwrap();
1657        let wasm_path = wasm_dir.path().join("a.wasm");
1658        std::fs::write(
1659            &wasm_path,
1660            wat::parse_str(r#"(module (memory (export "memory") 1))"#).unwrap(),
1661        )
1662        .unwrap();
1663
1664        let catalog = Catalog::open(catalog_dir.path());
1665        let engine = wasmtime::Engine::default();
1666        catalog.add("dup@1", &wasm_path, &engine).unwrap();
1667
1668        let err = catalog.add("dup@1", &wasm_path, &engine).unwrap_err();
1669        assert!(matches!(err, CatalogError::AlreadyExists { .. }), "{err:?}");
1670    }
1671
1672    #[test]
1673    fn list_show_rm_roundtrip() {
1674        let dir = tempfile::tempdir().unwrap();
1675        seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
1676        let catalog = Catalog::open(dir.path());
1677
1678        assert_eq!(catalog.list().unwrap().len(), 1);
1679        let shown = catalog
1680            .show("a@1")
1681            .expect("just-seeded entry must be visible");
1682        assert_eq!(shown.signature, "json -> json");
1683
1684        catalog.rm("a@1").unwrap();
1685        assert!(catalog.list().unwrap().is_empty());
1686    }
1687
1688    #[test]
1689    fn showing_a_missing_entry_reports_not_found_with_a_suggestion() {
1690        let dir = tempfile::tempdir().unwrap();
1691        seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
1692        let catalog = Catalog::open(dir.path());
1693
1694        let err = catalog.show("summarise@1").unwrap_err();
1695        let CatalogError::NotFound { did_you_mean, .. } = &err else {
1696            panic!("expected NotFound, got {err:?}")
1697        };
1698        assert_eq!(did_you_mean, &vec!["summarize@1".to_string()]);
1699    }
1700
1701    /// Write a valid, signature-less wasm block whose bytes vary with
1702    /// `body_marker`, so two calls can produce artifacts that are both valid
1703    /// and genuinely different content.
1704    fn distinct_wasm(dir: &Path, name: &str, body_marker: u32) -> PathBuf {
1705        let path = dir.join(format!("{name}.wasm"));
1706        std::fs::write(
1707            &path,
1708            wat::parse_str(format!(
1709                r#"(module (memory (export "memory") 1) (func (export "marker") (result i32) i32.const {body_marker}))"#
1710            ))
1711            .unwrap(),
1712        )
1713        .unwrap();
1714        path
1715    }
1716
1717    #[test]
1718    fn an_identifier_with_no_at_version_is_rejected_rather_than_catalogued_under_a_typo() {
1719        let catalog_dir = tempfile::tempdir().unwrap();
1720        let wasm_dir = tempfile::tempdir().unwrap();
1721        let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1722
1723        let err = Catalog::open(catalog_dir.path())
1724            .add("echo-summarize", &wasm, &wasmtime::Engine::default())
1725            .expect_err("dropping @version is a typo, not a name meaning itself");
1726
1727        assert!(
1728            matches!(err, CatalogError::InvalidNameVersion { .. }),
1729            "{err:?}"
1730        );
1731        assert!(
1732            Catalog::open(catalog_dir.path()).list().unwrap().is_empty(),
1733            "a rejected identifier must not leave an entry behind"
1734        );
1735    }
1736
1737    #[test]
1738    fn an_identifier_with_an_empty_name_or_version_is_rejected() {
1739        let catalog_dir = tempfile::tempdir().unwrap();
1740        let wasm_dir = tempfile::tempdir().unwrap();
1741        let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1742        let catalog = Catalog::open(catalog_dir.path());
1743        let engine = wasmtime::Engine::default();
1744
1745        for bad in ["@1", "name@", "", "   "] {
1746            let err = catalog
1747                .add(bad, &wasm, &engine)
1748                .expect_err("an empty name or version is not a name@version");
1749            assert!(
1750                matches!(err, CatalogError::InvalidNameVersion { .. }),
1751                "{bad:?} gave {err:?}"
1752            );
1753        }
1754    }
1755
1756    #[test]
1757    fn an_identifier_with_more_than_one_at_separator_is_rejected() {
1758        let catalog_dir = tempfile::tempdir().unwrap();
1759        let wasm_dir = tempfile::tempdir().unwrap();
1760        let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1761
1762        let err = Catalog::open(catalog_dir.path())
1763            .add("a@b@c", &wasm, &wasmtime::Engine::default())
1764            .expect_err("two '@' separators is not a name@version");
1765        assert!(
1766            matches!(err, CatalogError::InvalidNameVersion { .. }),
1767            "{err:?}"
1768        );
1769    }
1770
1771    #[test]
1772    fn an_identifier_containing_path_or_whitespace_characters_is_rejected() {
1773        let catalog_dir = tempfile::tempdir().unwrap();
1774        let wasm_dir = tempfile::tempdir().unwrap();
1775        let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1776        let catalog = Catalog::open(catalog_dir.path());
1777        let engine = wasmtime::Engine::default();
1778
1779        for bad in ["../../etc/passwd@1", "with space@1", "name@../../tmp/pwn"] {
1780            let err = catalog
1781                .add(bad, &wasm, &engine)
1782                .expect_err("{bad} must be rejected");
1783            assert!(
1784                matches!(err, CatalogError::InvalidNameVersion { .. }),
1785                "{bad:?} gave {err:?}"
1786            );
1787        }
1788    }
1789
1790    #[test]
1791    fn an_ordinary_name_at_version_still_catalogs() {
1792        let catalog_dir = tempfile::tempdir().unwrap();
1793        let wasm_dir = tempfile::tempdir().unwrap();
1794        let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1795
1796        Catalog::open(catalog_dir.path())
1797            .add(
1798                "echo-summarize@1.2.3-rc.1",
1799                &wasm,
1800                &wasmtime::Engine::default(),
1801            )
1802            .expect("letters, digits, '.', '-' and '_' are all legal");
1803    }
1804
1805    /// Validation guards the *write* path only. An index that already holds a
1806    /// junk key (written before this check existed, or hand-edited) must stay
1807    /// removable, or the fix would strand entries nothing can clean up.
1808    #[test]
1809    fn a_pre_existing_junk_identifier_can_still_be_shown_and_removed() {
1810        let dir = tempfile::tempdir().unwrap();
1811        seed(dir.path(), "no-at-sign", "2026-01-01T00:00:00Z");
1812        let catalog = Catalog::open(dir.path());
1813
1814        catalog
1815            .show("no-at-sign")
1816            .expect("an already-stored key must remain inspectable");
1817        catalog
1818            .rm("no-at-sign")
1819            .expect("an already-stored key must remain removable");
1820    }
1821
1822    #[test]
1823    fn re_adding_a_removed_version_with_the_same_bytes_is_allowed() {
1824        let catalog_dir = tempfile::tempdir().unwrap();
1825        let wasm_dir = tempfile::tempdir().unwrap();
1826        let wasm = distinct_wasm(wasm_dir.path(), "same", 7);
1827        let catalog = Catalog::open(catalog_dir.path());
1828        let engine = wasmtime::Engine::default();
1829
1830        catalog.add("thing@1", &wasm, &engine).unwrap();
1831        catalog.rm("thing@1").unwrap();
1832        catalog
1833            .add("thing@1", &wasm, &engine)
1834            .expect("re-adding identical bytes is an undo of the rm, not a rewrite of history");
1835
1836        assert_eq!(catalog.list().unwrap().len(), 1);
1837    }
1838
1839    /// The hazard the immutability promise exists to prevent: a name@version
1840    /// that someone already depends on silently coming to mean different
1841    /// content. Deleting the entry first must not launder that.
1842    #[test]
1843    fn re_adding_a_removed_version_with_different_bytes_is_rejected() {
1844        let catalog_dir = tempfile::tempdir().unwrap();
1845        let wasm_dir = tempfile::tempdir().unwrap();
1846        let original = distinct_wasm(wasm_dir.path(), "original", 1);
1847        let replacement = distinct_wasm(wasm_dir.path(), "replacement", 2);
1848        let catalog = Catalog::open(catalog_dir.path());
1849        let engine = wasmtime::Engine::default();
1850
1851        catalog.add("thing@1", &original, &engine).unwrap();
1852        catalog.rm("thing@1").unwrap();
1853
1854        let err = catalog
1855            .add("thing@1", &replacement, &engine)
1856            .expect_err("rm must not be a way to republish a version with new content");
1857        let CatalogError::RetiredWithDifferentContent {
1858            name_version,
1859            previous_hash,
1860            new_hash,
1861        } = &err
1862        else {
1863            panic!("expected RetiredWithDifferentContent, got {err:?}")
1864        };
1865        assert_eq!(name_version, "thing@1");
1866        assert_ne!(previous_hash, new_hash);
1867        assert!(
1868            catalog.list().unwrap().is_empty(),
1869            "the reject must not add"
1870        );
1871    }
1872
1873    /// An index written before retirement tracking existed has no `retired`
1874    /// field at all. It must still load as a normal, non-corrupt catalog
1875    /// rather than tripping the version check.
1876    #[test]
1877    fn an_index_written_without_the_retired_field_still_loads() {
1878        let dir = tempfile::tempdir().unwrap();
1879        std::fs::create_dir_all(dir.path()).unwrap();
1880        std::fs::write(
1881            dir.path().join("index.json"),
1882            br#"{"version":1,"entries":{"old@1":{"hash":"sha256:ab","kind":"block","signature":"json -> json","created_at":"2026-01-01T00:00:00Z"}}}"#,
1883        )
1884        .unwrap();
1885
1886        let index = read_index(dir.path()).expect("an index predating `retired` is not corrupt");
1887        assert!(index.entries.contains_key("old@1"));
1888        assert!(index.retired.is_empty());
1889    }
1890
1891    #[test]
1892    fn removing_a_missing_entry_is_not_found_not_a_silent_no_op() {
1893        let dir = tempfile::tempdir().unwrap();
1894        let catalog = Catalog::open(dir.path());
1895        let err = catalog.rm("nothing@1").unwrap_err();
1896        assert!(matches!(err, CatalogError::NotFound { .. }), "{err:?}");
1897    }
1898
1899    #[test]
1900    fn removing_an_entry_leaves_its_blob_on_disk_v1_has_no_garbage_collection() {
1901        let dir = tempfile::tempdir().unwrap();
1902        let hash = write_blob(dir.path(), b"some block bytes").unwrap();
1903        let hex = hash.strip_prefix("sha256:").unwrap();
1904        with_locked_index(dir.path(), |index| {
1905            index.entries.insert(
1906                "a@1".to_string(),
1907                Entry {
1908                    hash: hash.clone(),
1909                    kind: ArtifactKind::Block,
1910                    signature: "json -> json".to_string(),
1911                    created_at: "2026-01-01T00:00:00Z".to_string(),
1912                },
1913            );
1914            Ok::<_, CatalogError>(())
1915        })
1916        .unwrap();
1917
1918        let catalog = Catalog::open(dir.path());
1919        catalog.rm("a@1").unwrap();
1920
1921        assert!(
1922            matches!(catalog.show("a@1"), Err(CatalogError::NotFound { .. })),
1923            "rm must actually remove the index entry, not silently no-op"
1924        );
1925        assert!(
1926            dir.path().join("blobs").join(hex).exists(),
1927            "rm is index-only; the blob must remain"
1928        );
1929    }
1930
1931    #[test]
1932    fn list_returns_multiple_entries_sorted_by_name_at_version() {
1933        let dir = tempfile::tempdir().unwrap();
1934        seed(dir.path(), "b@1", "2026-01-01T00:00:00Z");
1935        seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
1936        seed(dir.path(), "c@1", "2026-01-01T00:00:00Z");
1937
1938        let catalog = Catalog::open(dir.path());
1939        let names: Vec<String> = catalog
1940            .list()
1941            .unwrap()
1942            .into_iter()
1943            .map(|(name_version, _)| name_version)
1944            .collect();
1945
1946        assert_eq!(
1947            names,
1948            vec!["a@1".to_string(), "b@1".to_string(), "c@1".to_string()]
1949        );
1950    }
1951
1952    #[test]
1953    fn resolve_a_dot_wasm_suffix_is_direct_even_if_the_file_does_not_exist() {
1954        let dir = tempfile::tempdir().unwrap();
1955        let catalog = Catalog::open(dir.path());
1956        let resolved = catalog
1957            .resolve("/nonexistent/block.wasm", ResolutionContext::Interactive)
1958            .unwrap();
1959        assert!(matches!(resolved, Resolved::Direct(_)));
1960    }
1961
1962    #[test]
1963    fn resolve_a_dot_cfbundle_suffix_is_direct_even_if_the_file_does_not_exist() {
1964        let dir = tempfile::tempdir().unwrap();
1965        let catalog = Catalog::open(dir.path());
1966        let resolved = catalog
1967            .resolve(
1968                "/nonexistent/bundle.cfbundle",
1969                ResolutionContext::Interactive,
1970            )
1971            .unwrap();
1972        assert!(matches!(resolved, Resolved::Direct(_)));
1973    }
1974
1975    #[test]
1976    fn resolve_an_existing_filesystem_path_is_direct_no_catalog_lookup() {
1977        let dir = tempfile::tempdir().unwrap();
1978        let real_file = tempfile::NamedTempFile::new().unwrap();
1979        let catalog = Catalog::open(dir.path());
1980        let resolved = catalog
1981            .resolve(
1982                real_file.path().to_str().unwrap(),
1983                ResolutionContext::Interactive,
1984            )
1985            .unwrap();
1986        assert!(matches!(resolved, Resolved::Direct(_)));
1987    }
1988
1989    #[test]
1990    fn resolve_exact_name_at_version_hits_case_sensitively() {
1991        let dir = tempfile::tempdir().unwrap();
1992        seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
1993        let catalog = Catalog::open(dir.path());
1994
1995        assert!(catalog
1996            .resolve("summarize@1", ResolutionContext::Interactive)
1997            .is_ok());
1998
1999        let err = catalog
2000            .resolve("Summarize@1", ResolutionContext::Interactive)
2001            .unwrap_err();
2002        let CatalogError::NotFound { did_you_mean, .. } = &err else {
2003            panic!("expected NotFound (case-sensitive miss), got {err:?}")
2004        };
2005        assert!(
2006            did_you_mean.contains(&"summarize@1".to_string()),
2007            "case-sensitivity rejects the hit, but edit distance 1 should still suggest it: {did_you_mean:?}"
2008        );
2009    }
2010
2011    #[test]
2012    fn resolve_unqualified_name_picks_the_latest_by_created_at() {
2013        let dir = tempfile::tempdir().unwrap();
2014        seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2015        seed(dir.path(), "a@2", "2026-06-01T00:00:00Z");
2016        let catalog = Catalog::open(dir.path());
2017
2018        let resolved = catalog
2019            .resolve("a", ResolutionContext::Interactive)
2020            .unwrap();
2021        let Resolved::Cataloged { name_version, .. } = resolved else {
2022            panic!("expected a cataloged resolution")
2023        };
2024        assert_eq!(name_version, "a@2");
2025    }
2026
2027    #[test]
2028    fn resolve_unqualified_name_is_legal_from_an_interactive_context() {
2029        let dir = tempfile::tempdir().unwrap();
2030        seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2031        let catalog = Catalog::open(dir.path());
2032        assert!(catalog.resolve("a", ResolutionContext::Interactive).is_ok());
2033    }
2034
2035    #[test]
2036    fn resolve_unqualified_name_is_rejected_in_a_durable_context() {
2037        let dir = tempfile::tempdir().unwrap();
2038        seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2039        let catalog = Catalog::open(dir.path());
2040        let err = catalog
2041            .resolve("a", ResolutionContext::Durable)
2042            .unwrap_err();
2043        assert!(
2044            matches!(err, CatalogError::UnqualifiedName { .. }),
2045            "{err:?}"
2046        );
2047    }
2048
2049    #[test]
2050    fn resolve_not_found_suggests_a_close_typo() {
2051        let dir = tempfile::tempdir().unwrap();
2052        seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
2053        let catalog = Catalog::open(dir.path());
2054        let err = catalog
2055            .resolve("summarise@1", ResolutionContext::Interactive)
2056            .unwrap_err();
2057        let CatalogError::NotFound { did_you_mean, .. } = &err else {
2058            panic!("expected NotFound, got {err:?}")
2059        };
2060        assert_eq!(did_you_mean, &vec!["summarize@1".to_string()]);
2061    }
2062
2063    #[test]
2064    fn read_blob_returns_what_add_wrote() {
2065        let dir = tempfile::tempdir().unwrap();
2066        let catalog = Catalog::open(dir.path());
2067        let engine = wasmtime::Engine::default();
2068        let wasm = wat::parse_str("(module)").unwrap();
2069        let path = dir.path().join("m.wasm");
2070        std::fs::write(&path, &wasm).unwrap();
2071
2072        let outcome = catalog.add("m@1", &path, &engine).unwrap();
2073        let entry = catalog.show("m@1").unwrap();
2074
2075        let bytes = catalog.read_blob(&entry).unwrap();
2076        assert_eq!(bytes, wasm);
2077        assert_eq!(outcome.name_version, "m@1");
2078    }
2079
2080    #[test]
2081    fn read_blob_on_a_hand_edited_missing_hash_errors_clearly() {
2082        let dir = tempfile::tempdir().unwrap();
2083        let catalog = Catalog::open(dir.path());
2084        let fake = Entry {
2085            hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
2086                .to_string(),
2087            ..entry_fixture("2026-01-01T00:00:00Z")
2088        };
2089        let err = catalog.read_blob(&fake).unwrap_err();
2090        match err {
2091            CatalogError::Io(ref io_err) => {
2092                assert_eq!(
2093                    io_err.kind(),
2094                    std::io::ErrorKind::NotFound,
2095                    "a well-formed hash with no matching blob file must surface as a plain \
2096                     not-found I/O error: {err:?}"
2097                );
2098            }
2099            other => {
2100                panic!("a well-formed but absent hash must be a plain Io(NotFound), not {other:?}")
2101            }
2102        }
2103    }
2104
2105    #[test]
2106    fn read_blob_rejects_a_path_traversal_hash_instead_of_touching_the_filesystem() {
2107        // A hand-edited (or maliciously crafted) index.json is never
2108        // format-validated on read anywhere else in this module — read_blob
2109        // is the last line of defense before a hash string becomes a
2110        // filesystem path. A well-formed sha256 digest is always exactly 64
2111        // lowercase hex digits (see write_blob's `format!("{:x}", ...)`), so
2112        // anything else — especially `../` traversal or an absolute path —
2113        // must be rejected before Path::join ever sees it.
2114        let dir = tempfile::tempdir().unwrap();
2115        // Plant a marker file outside blobs/ that a traversal would reach if
2116        // the guard were missing.
2117        std::fs::write(dir.path().join("outside.txt"), b"do not leak this").unwrap();
2118
2119        let catalog = Catalog::open(dir.path());
2120        let traversal = Entry {
2121            hash: "sha256:../outside.txt".to_string(),
2122            ..entry_fixture("2026-01-01T00:00:00Z")
2123        };
2124        let err = catalog.read_blob(&traversal).unwrap_err();
2125        assert!(
2126            matches!(err, CatalogError::MalformedHash { .. }),
2127            "a path-traversal hash must be rejected as MalformedHash before any path is \
2128             constructed, got {err:?}"
2129        );
2130
2131        let absolute = Entry {
2132            hash: "sha256:/etc/passwd".to_string(),
2133            ..entry_fixture("2026-01-01T00:00:00Z")
2134        };
2135        let err = catalog.read_blob(&absolute).unwrap_err();
2136        assert!(
2137            matches!(err, CatalogError::MalformedHash { .. }),
2138            "an absolute-path-like hash must be rejected as MalformedHash before any path is \
2139             constructed, got {err:?}"
2140        );
2141    }
2142}