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