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 `crate::hex::encode(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    // Compile (parse, don't run) the whole script now, at the earliest point
654    // a human or agent can act on it — same reasoning as the signature-header
655    // check above. Without this, a script with a valid header but a broken
656    // body catalogs fine and only fails at run time, inside
657    // `blocks/rhai-interpreter`, labeled `schema_validation_failed` — a
658    // misleading code for what is really a syntax error the catalog could
659    // have caught up front.
660    rhai::Engine::new()
661        .compile(text)
662        .map_err(|e| CatalogError::UninspectableArtifact {
663            path: PathBuf::from(label),
664            reason: format!("script does not parse: {e}"),
665        })?;
666
667    Ok(header.to_string())
668}
669
670/// Write `bytes` into the content-addressed blob store, deduplicating by
671/// hash (two names cataloging identical bytes cost nothing extra), and
672/// return the hash as `sha256:<hex>` — self-describing in the index even
673/// though the on-disk filename is bare hex (it's already inside a directory
674/// named `blobs`; a prefix there would be redundant).
675fn write_blob(root: &Path, bytes: &[u8]) -> Result<String, CatalogError> {
676    use sha2::{Digest, Sha256};
677    use std::sync::atomic::{AtomicU64, Ordering};
678
679    let hex = crate::hex::encode(Sha256::digest(bytes));
680    let dir = blobs_dir(root);
681    fs::create_dir_all(&dir)?;
682
683    let blob_path = dir.join(&hex);
684    if !blob_path.exists() {
685        // Unique per call (process id + a process-lifetime counter), not
686        // derived from the hash — two concurrent writers of identical bytes
687        // must never share a temp path. A deterministic {hex}.tmp name let
688        // one writer's O_TRUNC-on-open truncate the other's in-flight or
689        // already-written-but-not-yet-renamed file, risking a truncated
690        // file landing at blob_path under a hash it doesn't actually match.
691        static COUNTER: AtomicU64 = AtomicU64::new(0);
692        let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
693        let tmp_path = dir.join(format!("{hex}.tmp.{}.{unique}", std::process::id()));
694        {
695            let mut tmp = File::create(&tmp_path)?;
696            tmp.write_all(bytes)?;
697            tmp.sync_all()?;
698        }
699        fs::rename(&tmp_path, &blob_path)?;
700    }
701
702    Ok(format!("sha256:{hex}"))
703}
704
705/// A local block catalog rooted at a directory. This type has no opinion
706/// about environment variables or home directories — the caller (the CLI)
707/// decides where `root` points.
708pub struct Catalog {
709    root: PathBuf,
710}
711
712/// What `add` actually did, for a caller to report.
713#[derive(Debug, Clone)]
714pub struct AddOutcome {
715    /// The name@version now cataloged.
716    pub name_version: String,
717    /// Block, bundle, or script.
718    pub kind: ArtifactKind,
719    /// The cached signature string.
720    pub signature: String,
721    /// True when `signature` is the permissive `json -> json` default — the
722    /// caller should print a warning, not fail; the permissive fallback
723    /// itself is existing, intentional behavior, unchanged by the catalog.
724    pub is_permissive_default: bool,
725}
726
727/// Where a pipeline entry string is being resolved from — determines whether
728/// an unqualified name (no `@version`) is legal. The dividing line is
729/// source-spec-text vs. compiled-artifact-reference, not "which command
730/// invoked it": `cuttlefish build` resolves an unqualified name from the
731/// spec it was given exactly once, at build time, and records the exact
732/// resolution in the manifest it emits — the unqualified form itself never
733/// survives into that manifest.
734#[derive(Debug, Clone, Copy, PartialEq, Eq)]
735pub enum ResolutionContext {
736    /// Resolving a reference found directly in a source `.cuttlefish` spec,
737    /// on behalf of a top-level interactive command (`cuttlefish run`, or
738    /// `cuttlefish build` pointed at that spec file). Unqualified names are
739    /// legal here.
740    Interactive,
741    /// Resolving a node reference already recorded inside a bundle's
742    /// manifest. Unqualified names are illegal here.
743    Durable,
744}
745
746/// The result of resolving one pipeline entry string.
747#[derive(Debug, Clone)]
748pub enum Resolved {
749    /// `s` was a direct filesystem path or ended in `.wasm` — used as-is, no
750    /// catalog lookup at all.
751    Direct(PathBuf),
752    /// `s` resolved through the catalog to this entry.
753    Cataloged {
754        /// The exact name@version resolved to, even if `s` itself was
755        /// unqualified.
756        name_version: String,
757        /// The resolved entry.
758        entry: Entry,
759    },
760}
761
762impl Catalog {
763    /// Open (without yet creating on disk) a catalog rooted at `root`.
764    pub fn open(root: impl Into<PathBuf>) -> Self {
765        Self { root: root.into() }
766    }
767
768    /// Catalog the artifact at `artifact_path` under `name_version`.
769    ///
770    /// `engine` is only used if the artifact turns out to be a wasm block —
771    /// a bundle's signature is read straight from its own manifest, never
772    /// wasm-instantiated (bundle bytes are a custom container, not a wasm
773    /// module; instantiating them would simply fail to parse).
774    pub fn add(
775        &self,
776        name_version: &str,
777        artifact_path: &Path,
778        engine: &wasmtime::Engine,
779    ) -> Result<AddOutcome, CatalogError> {
780        validate_name_version(name_version)?;
781
782        let bytes = fs::read(artifact_path)?;
783        let kind = match sniff_artifact_kind(&bytes) {
784            Some(k) => k,
785            None if artifact_path.extension().is_some_and(|e| e == "rhai") => ArtifactKind::Script,
786            None => {
787                return Err(CatalogError::UnrecognizedArtifact {
788                    path: artifact_path.to_path_buf(),
789                    header: bytes.iter().take(8).copied().collect(),
790                })
791            }
792        };
793
794        let (signature, is_permissive_default) = match kind {
795            ArtifactKind::Block => {
796                let sig = crate::runner::read_signature(engine, &bytes).map_err(|e| {
797                    CatalogError::UninspectableArtifact {
798                        path: artifact_path.to_path_buf(),
799                        reason: format!("{e:#}"),
800                    }
801                })?;
802                let permissive = cuttlefish_abi::Signature {
803                    input: cuttlefish_abi::Ty::Json,
804                    output: cuttlefish_abi::Ty::Json,
805                };
806                let is_permissive = sig == permissive;
807                (sig.to_string(), is_permissive)
808            }
809            ArtifactKind::Bundle => {
810                let sig = read_bundle_signature(&bytes, &artifact_path.to_string_lossy())?;
811                (sig, false)
812            }
813            ArtifactKind::Script => {
814                let sig = read_script_signature(&bytes, &artifact_path.to_string_lossy())?;
815                (sig, false)
816            }
817        };
818
819        let hash = write_blob(&self.root, &bytes)?;
820        let created_at = now_rfc3339();
821        let name_version = name_version.to_string();
822
823        with_locked_index(&self.root, |index| {
824            if index.entries.contains_key(&name_version) {
825                return Err(CatalogError::AlreadyExists {
826                    name_version: name_version.clone(),
827                });
828            }
829            // A removed version keeps its claim on the identity. Re-adding the
830            // exact bytes it was published with is an undo of the `rm`;
831            // re-adding anything else is a republish, which is the thing
832            // immutability exists to forbid.
833            if let Some(previous_hash) = index.retired.get(&name_version) {
834                if previous_hash != &hash {
835                    return Err(CatalogError::RetiredWithDifferentContent {
836                        name_version: name_version.clone(),
837                        previous_hash: previous_hash.clone(),
838                        new_hash: hash.clone(),
839                    });
840                }
841                index.retired.remove(&name_version);
842            }
843            index.entries.insert(
844                name_version.clone(),
845                Entry {
846                    hash,
847                    kind,
848                    signature: signature.clone(),
849                    created_at,
850                },
851            );
852            Ok(())
853        })?;
854
855        Ok(AddOutcome {
856            name_version,
857            kind,
858            signature,
859            is_permissive_default,
860        })
861    }
862
863    /// List every cataloged entry, in deterministic (sorted-by-name@version)
864    /// order.
865    pub fn list(&self) -> Result<Vec<(String, Entry)>, CatalogError> {
866        let index = read_index(&self.root)?;
867        Ok(index.entries.into_iter().collect())
868    }
869
870    /// Look up one entry's cached `Entry` by exact, case-sensitive
871    /// `name@version` — a catalog name is an opaque string, like a version is;
872    /// no case-folding, no normalization.
873    pub fn show(&self, name_version: &str) -> Result<Entry, CatalogError> {
874        let index = read_index(&self.root)?;
875        index.entries.get(name_version).cloned().ok_or_else(|| {
876            let name = name_version.split('@').next().unwrap_or(name_version);
877            CatalogError::NotFound {
878                name_version: name_version.to_string(),
879                did_you_mean: pick_did_you_mean(name, &index.entries),
880            }
881        })
882    }
883
884    /// Read an entry's raw bytes back out of the blob store. The catalog's
885    /// only way to get from an `Entry` to actual artifact bytes — `blobs/`
886    /// stays an implementation detail, same as `add()` already hides
887    /// `write_blob`.
888    pub fn read_blob(&self, entry: &Entry) -> Result<Vec<u8>, CatalogError> {
889        let hex = entry.hash.strip_prefix("sha256:").unwrap_or(&entry.hash);
890        if !is_well_formed_sha256_hex(hex) {
891            return Err(CatalogError::MalformedHash {
892                hash: entry.hash.clone(),
893            });
894        }
895        Ok(fs::read(blobs_dir(&self.root).join(hex))?)
896    }
897
898    /// Remove a `name@version` from the index. The blob it pointed at is left on
899    /// disk — no garbage collection in v1 (an orphaned blob is wasted space, not
900    /// a correctness problem; see the design doc).
901    pub fn rm(&self, name_version: &str) -> Result<(), CatalogError> {
902        with_locked_index(&self.root, |index| {
903            if let Some(entry) = index.entries.remove(name_version) {
904                // Record what this identity was published as, so a later
905                // `add` can tell an undo from a republish.
906                index
907                    .retired
908                    .insert(name_version.to_string(), entry.hash.clone());
909                Ok(())
910            } else {
911                let name = name_version.split('@').next().unwrap_or(name_version);
912                Err(CatalogError::NotFound {
913                    name_version: name_version.to_string(),
914                    did_you_mean: pick_did_you_mean(name, &index.entries),
915                })
916            }
917        })
918    }
919
920    /// Resolve one pipeline entry string per the catalog spec's three-step
921    /// algorithm: direct path/`.wasm`/`.cfbundle` first, then an exact
922    /// catalog lookup if `@version` is present, then latest-by-`created_at`
923    /// if it's not and `context` allows an unqualified name.
924    ///
925    /// A compiled-artifact suffix (`.wasm` or `.cfbundle`) is always treated
926    /// as Direct, even when nothing actually exists at that path — a
927    /// genuinely missing artifact should fail with a clear "no such file",
928    /// not be silently reinterpreted as a catalog name that happens to
929    /// contain a `.` in it. Both suffixes get identical treatment here:
930    /// `pipeline::resolve_and_load`'s own decision to prefer a joined path
931    /// for one of these suffixes (even before checking existence) is only
932    /// correct if this function honors the same suffixes the same way.
933    pub fn resolve(&self, s: &str, context: ResolutionContext) -> Result<Resolved, CatalogError> {
934        if s.ends_with(".wasm") || s.ends_with(".cfbundle") || Path::new(s).exists() {
935            return Ok(Resolved::Direct(PathBuf::from(s)));
936        }
937
938        let index = read_index(&self.root)?;
939
940        if let Some((name, version)) = s.rsplit_once('@') {
941            let name_version = format!("{name}@{version}");
942            let entry = index.entries.get(&name_version).cloned().ok_or_else(|| {
943                CatalogError::NotFound {
944                    name_version: name_version.clone(),
945                    did_you_mean: pick_did_you_mean(name, &index.entries),
946                }
947            })?;
948            return Ok(Resolved::Cataloged {
949                name_version,
950                entry,
951            });
952        }
953
954        if context == ResolutionContext::Durable {
955            return Err(CatalogError::UnqualifiedName {
956                name: s.to_string(),
957            });
958        }
959
960        let mut versions: Vec<(&String, &Entry)> = index
961            .entries
962            .iter()
963            .filter(|(nv, _)| nv.rsplit_once('@').map(|(n, _)| n) == Some(s))
964            .collect();
965        versions.sort_by(|a, b| a.1.created_at.cmp(&b.1.created_at));
966
967        let (name_version, entry) =
968            versions
969                .last()
970                .copied()
971                .ok_or_else(|| CatalogError::NotFound {
972                    name_version: s.to_string(),
973                    did_you_mean: pick_did_you_mean(s, &index.entries),
974                })?;
975
976        Ok(Resolved::Cataloged {
977            name_version: name_version.clone(),
978            entry: entry.clone(),
979        })
980    }
981}
982
983/// The bare `$CUTTLEFISH_HOME`/`~/.cuttlefish` root the catalog and most of
984/// this crate's other on-disk layout is rooted under (the jobs directory is
985/// the exception: it honors `$CUTTLEFISH_JOBS_HOME` first, see
986/// `ledger::jobs_root`). `None` when neither `$CUTTLEFISH_HOME` nor a
987/// resolvable home directory is available — this library never exits the
988/// process on a caller's behalf, so reporting that is the caller's job (both
989/// `cuttlefish` and `cuttlefishd` already have their own error-reporting
990/// convention).
991pub(crate) fn cuttlefish_home() -> Option<PathBuf> {
992    if let Ok(home) = std::env::var("CUTTLEFISH_HOME") {
993        return Some(PathBuf::from(home));
994    }
995    dirs::home_dir().map(|home| home.join(".cuttlefish"))
996}
997
998/// Where the catalog lives when the caller doesn't say otherwise:
999/// `$CUTTLEFISH_HOME/catalog` if set, else `~/.cuttlefish/catalog`. `None`
1000/// when neither is available — see `cuttlefish_home` (private).
1001pub fn default_root() -> Option<PathBuf> {
1002    cuttlefish_home().map(|h| h.join("catalog"))
1003}
1004
1005/// The current UTC time, truncated to whole seconds and formatted as RFC
1006/// 3339 (`2026-08-02T18:03:00Z`). Truncating avoids variable-width fractional
1007/// seconds, so `created_at` strings sort correctly with plain string
1008/// comparison (used for "give me the latest" and did-you-mean tie-breaking)
1009/// without ever needing to be parsed back.
1010pub(crate) fn now_rfc3339() -> String {
1011    let now = time::OffsetDateTime::now_utc()
1012        .replace_nanosecond(0)
1013        .expect("0 is always a valid nanosecond value");
1014    now.format(&time::format_description::well_known::Rfc3339)
1015        .expect("Rfc3339 formatting cannot fail for a valid OffsetDateTime")
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020    use super::*;
1021    use std::sync::atomic::{AtomicBool, Ordering};
1022    use std::sync::{Arc, Barrier};
1023
1024    /// Enough threads to genuinely contend for the index lock on any machine
1025    /// this runs on, without making the test slow.
1026    const WRITERS: usize = 16;
1027
1028    #[test]
1029    fn default_root_honors_cuttlefish_home() {
1030        // No other test in this process mutates CUTTLEFISH_HOME, and
1031        // catalog.rs's own tests never read it — set/remove is confined to
1032        // this one test. (This toolchain's std::env::set_var/remove_var are
1033        // safe fns, not unsafe — the crate forbids unsafe_code entirely, so
1034        // an unsafe wrapper isn't an option regardless.)
1035        std::env::set_var("CUTTLEFISH_HOME", "/tmp/cf-test-home");
1036        let root = default_root();
1037        std::env::remove_var("CUTTLEFISH_HOME");
1038        assert_eq!(root, Some(PathBuf::from("/tmp/cf-test-home/catalog")));
1039    }
1040
1041    #[test]
1042    fn index_file_serializes_to_the_shape_the_spec_documents() {
1043        let mut entries = BTreeMap::new();
1044        entries.insert(
1045            "chunk-text@1".to_string(),
1046            Entry {
1047                hash: "sha256:9f86d081".to_string(),
1048                kind: ArtifactKind::Block,
1049                signature: "{path: text} -> [text]".to_string(),
1050                created_at: "2026-08-02T18:03:00Z".to_string(),
1051            },
1052        );
1053        let index = IndexFile {
1054            version: INDEX_VERSION,
1055            entries,
1056            retired: BTreeMap::new(),
1057        };
1058
1059        let json = serde_json::to_string(&index).expect("IndexFile always serializes");
1060        let parsed: serde_json::Value =
1061            serde_json::from_str(&json).expect("what we just wrote must parse");
1062
1063        assert_eq!(parsed["version"], 1);
1064        assert_eq!(parsed["entries"]["chunk-text@1"]["kind"], "block");
1065        assert_eq!(
1066            parsed["entries"]["chunk-text@1"]["signature"],
1067            "{path: text} -> [text]"
1068        );
1069
1070        let round_tripped: IndexFile =
1071            serde_json::from_str(&json).expect("must deserialize what we just serialized");
1072        assert_eq!(round_tripped.version, INDEX_VERSION);
1073        assert!(round_tripped.entries.contains_key("chunk-text@1"));
1074    }
1075
1076    #[test]
1077    fn not_found_with_suggestions_reads_as_one_sentence() {
1078        let err = CatalogError::NotFound {
1079            name_version: "summarise@1".to_string(),
1080            did_you_mean: vec!["summarize@1".to_string()],
1081        };
1082        assert_eq!(
1083            err.to_string(),
1084            "no such catalog entry: summarise@1 (did you mean: summarize@1?)"
1085        );
1086    }
1087
1088    #[test]
1089    fn not_found_with_no_suggestions_has_no_dangling_parenthetical() {
1090        let err = CatalogError::NotFound {
1091            name_version: "xyz@1".to_string(),
1092            did_you_mean: vec![],
1093        };
1094        assert_eq!(err.to_string(), "no such catalog entry: xyz@1");
1095    }
1096
1097    fn entry_fixture(created_at: &str) -> Entry {
1098        Entry {
1099            hash: "sha256:deadbeef".to_string(),
1100            kind: ArtifactKind::Block,
1101            signature: "json -> json".to_string(),
1102            created_at: created_at.to_string(),
1103        }
1104    }
1105
1106    fn seed(root: &Path, name_version: &str, created_at: &str) {
1107        with_locked_index(root, |index| {
1108            index
1109                .entries
1110                .insert(name_version.to_string(), entry_fixture(created_at));
1111            Ok::<_, CatalogError>(())
1112        })
1113        .unwrap();
1114    }
1115
1116    #[test]
1117    fn levenshtein_matches_known_distances() {
1118        assert_eq!(levenshtein("kitten", "sitting"), 3);
1119        assert_eq!(levenshtein("summarize", "summarise"), 1);
1120        assert_eq!(levenshtein("same", "same"), 0);
1121    }
1122
1123    #[test]
1124    fn did_you_mean_catches_a_one_character_typo_a_prefix_match_would_miss() {
1125        // "summarise" and "summarize" share no prefix relationship (they diverge
1126        // at the 8th character) — a starts-with prefix match would silently
1127        // produce zero suggestions on exactly this typo.
1128        let mut entries = BTreeMap::new();
1129        entries.insert(
1130            "summarize@1".to_string(),
1131            entry_fixture("2026-01-01T00:00:00Z"),
1132        );
1133        assert_eq!(
1134            pick_did_you_mean("summarise", &entries),
1135            vec!["summarize@1".to_string()]
1136        );
1137    }
1138
1139    #[test]
1140    fn did_you_mean_is_empty_when_nothing_registered_is_close() {
1141        let mut entries = BTreeMap::new();
1142        entries.insert(
1143            "summarize@1".to_string(),
1144            entry_fixture("2026-01-01T00:00:00Z"),
1145        );
1146        assert!(pick_did_you_mean("completely-unrelated-name", &entries).is_empty());
1147    }
1148
1149    #[test]
1150    fn did_you_mean_is_capped_at_five_closest_ordered_by_distance() {
1151        let mut entries = BTreeMap::new();
1152        // All within edit distance 1 of "cat" by construction (each swaps one
1153        // letter), so the cap — not the distance threshold — is what's under test.
1154        for (i, name) in ["bat", "cot", "car", "cap", "can", "cad"]
1155            .iter()
1156            .enumerate()
1157        {
1158            entries.insert(
1159                format!("{name}@1"),
1160                entry_fixture(&format!("2026-01-0{}T00:00:00Z", i + 1)),
1161            );
1162        }
1163        let suggestions = pick_did_you_mean("cat", &entries);
1164        assert_eq!(suggestions.len(), 5, "capped at 5: {suggestions:?}");
1165    }
1166
1167    #[test]
1168    fn did_you_mean_suggests_the_newest_version_when_multiple_versions_of_a_close_name_exist() {
1169        let mut entries = BTreeMap::new();
1170        entries.insert(
1171            "summarize@1".to_string(),
1172            entry_fixture("2026-01-01T00:00:00Z"),
1173        );
1174        entries.insert(
1175            "summarize@2".to_string(),
1176            entry_fixture("2026-06-01T00:00:00Z"),
1177        );
1178        assert_eq!(
1179            pick_did_you_mean("summarise", &entries),
1180            vec!["summarize@2".to_string()],
1181            "must suggest the newest version of a matching name, not every version"
1182        );
1183    }
1184
1185    #[test]
1186    fn wasm_magic_bytes_sniff_as_a_block() {
1187        assert_eq!(
1188            sniff_artifact_kind(b"\0asm\x01\x00\x00\x00"),
1189            Some(ArtifactKind::Block)
1190        );
1191    }
1192
1193    #[test]
1194    fn bundle_magic_bytes_sniff_as_a_bundle() {
1195        assert_eq!(
1196            sniff_artifact_kind(b"CFBD\x00\x00\x00\x00\x00\x00\x00\x00"),
1197            Some(ArtifactKind::Bundle)
1198        );
1199    }
1200
1201    #[test]
1202    fn unrecognised_bytes_sniff_to_none_not_a_guess() {
1203        assert_eq!(sniff_artifact_kind(b"whatever-this-is"), None);
1204    }
1205
1206    #[test]
1207    fn writing_then_reading_the_index_round_trips_through_disk() {
1208        let dir = tempfile::tempdir().unwrap();
1209        with_locked_index(dir.path(), |index| {
1210            index
1211                .entries
1212                .insert("a@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1213            Ok::<_, CatalogError>(())
1214        })
1215        .unwrap();
1216
1217        let index = read_index(dir.path()).unwrap();
1218        assert!(index.entries.contains_key("a@1"));
1219    }
1220
1221    #[test]
1222    fn reading_an_index_that_does_not_exist_yet_is_an_empty_catalog_not_an_error() {
1223        let dir = tempfile::tempdir().unwrap();
1224        let index = read_index(dir.path()).expect("no index.json yet is not corruption");
1225        assert!(index.entries.is_empty());
1226    }
1227
1228    #[test]
1229    fn a_truncated_index_is_a_corrupt_index_error_not_an_empty_catalog() {
1230        let dir = tempfile::tempdir().unwrap();
1231        std::fs::create_dir_all(dir.path()).unwrap();
1232        std::fs::write(dir.path().join("index.json"), b"{\"version\": 1, \"ent").unwrap();
1233
1234        let err = read_index(dir.path()).unwrap_err();
1235        assert!(
1236            matches!(err, CatalogError::CorruptIndex { .. }),
1237            "a truncated index must be a loud CorruptIndex, not treated as empty: {err:?}"
1238        );
1239    }
1240
1241    #[test]
1242    fn an_unsupported_index_version_is_a_corrupt_index_error() {
1243        let dir = tempfile::tempdir().unwrap();
1244        std::fs::create_dir_all(dir.path()).unwrap();
1245        std::fs::write(
1246            dir.path().join("index.json"),
1247            br#"{"version": 999, "entries": {}}"#,
1248        )
1249        .unwrap();
1250
1251        let err = read_index(dir.path()).unwrap_err();
1252        assert!(matches!(err, CatalogError::CorruptIndex { .. }), "{err:?}");
1253    }
1254
1255    #[test]
1256    fn concurrent_writes_from_two_threads_both_land_and_the_index_stays_parseable() {
1257        let dir = tempfile::tempdir().unwrap();
1258        let root_a = dir.path().to_path_buf();
1259        let root_b = dir.path().to_path_buf();
1260
1261        let t1 = std::thread::spawn(move || {
1262            with_locked_index(&root_a, |index| {
1263                index
1264                    .entries
1265                    .insert("a@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1266                Ok::<_, CatalogError>(())
1267            })
1268            .unwrap();
1269        });
1270        let t2 = std::thread::spawn(move || {
1271            with_locked_index(&root_b, |index| {
1272                index
1273                    .entries
1274                    .insert("b@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1275                Ok::<_, CatalogError>(())
1276            })
1277            .unwrap();
1278        });
1279        t1.join().unwrap();
1280        t2.join().unwrap();
1281
1282        let index = read_index(dir.path()).expect("the index must still parse after contention");
1283        assert!(index.entries.contains_key("a@1"));
1284        assert!(index.entries.contains_key("b@1"));
1285    }
1286
1287    /// `add`'s duplicate check runs *inside* the locked section, so a pack of
1288    /// writers racing for one key must resolve to exactly one winner — the
1289    /// "versions are immutable once published" promise only holds under
1290    /// contention if the check-then-insert is genuinely atomic. Every thread
1291    /// is held at a barrier so they collide on the lock rather than politely
1292    /// serializing.
1293    #[test]
1294    fn racing_inserts_of_the_same_key_leave_exactly_one_winner() {
1295        let dir = tempfile::tempdir().unwrap();
1296        let root = dir.path().to_path_buf();
1297        let barrier = Arc::new(Barrier::new(WRITERS));
1298
1299        let handles: Vec<_> = (0..WRITERS)
1300            .map(|_| {
1301                let root = root.clone();
1302                let barrier = barrier.clone();
1303                std::thread::spawn(move || {
1304                    barrier.wait();
1305                    with_locked_index(&root, |index| {
1306                        if index.entries.contains_key("race@1") {
1307                            return Err(CatalogError::AlreadyExists {
1308                                name_version: "race@1".to_string(),
1309                            });
1310                        }
1311                        index
1312                            .entries
1313                            .insert("race@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1314                        Ok(())
1315                    })
1316                })
1317            })
1318            .collect();
1319
1320        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1321
1322        let winners = results.iter().filter(|r| r.is_ok()).count();
1323        assert_eq!(
1324            winners, 1,
1325            "exactly one racing writer may claim a key; got {winners}"
1326        );
1327        assert!(
1328            results
1329                .iter()
1330                .all(|r| r.is_ok() || matches!(r, Err(CatalogError::AlreadyExists { .. }))),
1331            "every loser must lose with AlreadyExists, not an io or corruption error: {results:?}"
1332        );
1333
1334        let index = read_index(&root).expect("the index must still parse after contention");
1335        assert_eq!(index.entries.len(), 1);
1336    }
1337
1338    /// The two-thread test above proves the lock exists; this proves it holds
1339    /// up under real contention. Without a barrier, two threads usually finish
1340    /// one after another and never touch the lock at the same time, so a
1341    /// read-modify-write that lost updates could still pass.
1342    #[test]
1343    fn many_racing_writers_of_distinct_keys_all_land_with_no_lost_updates() {
1344        let dir = tempfile::tempdir().unwrap();
1345        let root = dir.path().to_path_buf();
1346        let barrier = Arc::new(Barrier::new(WRITERS));
1347
1348        let handles: Vec<_> = (0..WRITERS)
1349            .map(|w| {
1350                let root = root.clone();
1351                let barrier = barrier.clone();
1352                std::thread::spawn(move || {
1353                    barrier.wait();
1354                    with_locked_index(&root, |index| {
1355                        index.entries.insert(
1356                            format!("writer-{w}@1"),
1357                            entry_fixture("2026-01-01T00:00:00Z"),
1358                        );
1359                        Ok::<_, CatalogError>(())
1360                    })
1361                    .unwrap();
1362                })
1363            })
1364            .collect();
1365        for h in handles {
1366            h.join().unwrap();
1367        }
1368
1369        let index = read_index(&root).expect("the index must still parse after contention");
1370        assert_eq!(
1371            index.entries.len(),
1372            WRITERS,
1373            "every writer's entry must survive; a lost update means the \
1374             read-modify-write escaped the lock: {:?}",
1375            index.entries.keys().collect::<Vec<_>>()
1376        );
1377    }
1378
1379    /// `with_locked_index` documents that readers need no lock because a
1380    /// writer only ever publishes via rename, so a reader sees the wholly-old
1381    /// or wholly-new file and never a half-written one. Nothing asserted it:
1382    /// this hammers lock-free readers against writers and fails if any read
1383    /// ever comes back corrupt.
1384    #[test]
1385    fn lock_free_readers_never_observe_a_partial_index_while_writers_hammer() {
1386        let dir = tempfile::tempdir().unwrap();
1387        let root = dir.path().to_path_buf();
1388        // Seed first so index.json exists before any reader starts — a
1389        // missing index is legitimately empty, which would mask a torn read.
1390        seed(&root, "seed@1", "2026-01-01T00:00:00Z");
1391
1392        let stop = Arc::new(AtomicBool::new(false));
1393
1394        let writers: Vec<_> = (0..4)
1395            .map(|w| {
1396                let root = root.clone();
1397                std::thread::spawn(move || {
1398                    for i in 0..60 {
1399                        with_locked_index(&root, |index| {
1400                            index.entries.insert(
1401                                format!("w{w}-{i}@1"),
1402                                entry_fixture("2026-01-01T00:00:00Z"),
1403                            );
1404                            Ok::<_, CatalogError>(())
1405                        })
1406                        .unwrap();
1407                    }
1408                })
1409            })
1410            .collect();
1411
1412        let readers: Vec<_> = (0..4)
1413            .map(|_| {
1414                let root = root.clone();
1415                let stop = stop.clone();
1416                std::thread::spawn(move || {
1417                    let mut reads = 0u32;
1418                    while !stop.load(Ordering::Relaxed) {
1419                        let index = read_index(&root)
1420                            .expect("a lock-free reader must never see a partial or corrupt index");
1421                        // A torn read that still parsed would most likely show
1422                        // up as losing the seed entry that is only ever added.
1423                        assert!(
1424                            index.entries.contains_key("seed@1"),
1425                            "an entry that is never removed vanished from a concurrent read"
1426                        );
1427                        reads += 1;
1428                    }
1429                    reads
1430                })
1431            })
1432            .collect();
1433
1434        for w in writers {
1435            w.join().unwrap();
1436        }
1437        stop.store(true, Ordering::Relaxed);
1438
1439        let total: u32 = readers.into_iter().map(|r| r.join().unwrap()).sum();
1440        assert!(
1441            total > 0,
1442            "the readers must have actually observed the index"
1443        );
1444    }
1445
1446    /// Every other concurrency test here races `add` against `add`. Removals
1447    /// take the same lock and rewrite the same file, so a mixed workload is
1448    /// where an asymmetry would show up — e.g. a removal path that wrote the
1449    /// index outside the locked section. Each thread owns a disjoint key and
1450    /// adds then removes it, so the end state is exactly the untouched
1451    /// keep-alive entries regardless of interleaving.
1452    #[test]
1453    fn adds_and_removals_racing_on_one_index_leave_exactly_the_expected_entries() {
1454        let dir = tempfile::tempdir().unwrap();
1455        let root = dir.path().to_path_buf();
1456        seed(&root, "keep@1", "2026-01-01T00:00:00Z");
1457        seed(&root, "keep@2", "2026-01-01T00:00:00Z");
1458
1459        let barrier = Arc::new(Barrier::new(WRITERS));
1460        let handles: Vec<_> = (0..WRITERS)
1461            .map(|w| {
1462                let root = root.clone();
1463                let barrier = barrier.clone();
1464                std::thread::spawn(move || {
1465                    let key = format!("churn-{w}@1");
1466                    barrier.wait();
1467                    for _ in 0..10 {
1468                        with_locked_index(&root, |index| {
1469                            index
1470                                .entries
1471                                .insert(key.clone(), entry_fixture("2026-01-01T00:00:00Z"));
1472                            Ok::<_, CatalogError>(())
1473                        })
1474                        .unwrap();
1475                        with_locked_index(&root, |index| {
1476                            index.entries.remove(&key).expect(
1477                                "a key only this thread ever touches must still be present",
1478                            );
1479                            Ok::<_, CatalogError>(())
1480                        })
1481                        .unwrap();
1482                    }
1483                })
1484            })
1485            .collect();
1486        for h in handles {
1487            h.join().unwrap();
1488        }
1489
1490        let index = read_index(&root).expect("the index must still parse after mixed contention");
1491        let names: Vec<_> = index.entries.keys().cloned().collect();
1492        assert_eq!(
1493            names,
1494            vec!["keep@1".to_string(), "keep@2".to_string()],
1495            "churn keys must all be gone and the untouched entries must survive"
1496        );
1497    }
1498
1499    #[test]
1500    fn identical_bytes_under_two_writes_produce_exactly_one_blob_file() {
1501        let dir = tempfile::tempdir().unwrap();
1502        let hash1 = write_blob(dir.path(), b"hello world").unwrap();
1503        let hash2 = write_blob(dir.path(), b"hello world").unwrap();
1504
1505        assert_eq!(hash1, hash2);
1506        assert!(hash1.starts_with("sha256:"));
1507
1508        let blob_count = std::fs::read_dir(dir.path().join("blobs")).unwrap().count();
1509        assert_eq!(
1510            blob_count, 1,
1511            "identical bytes must dedupe to a single blob file"
1512        );
1513    }
1514
1515    #[test]
1516    fn the_blob_filename_on_disk_is_bare_hex_no_prefix() {
1517        let dir = tempfile::tempdir().unwrap();
1518        let hash = write_blob(dir.path(), b"hello world").unwrap();
1519        let hex = hash
1520            .strip_prefix("sha256:")
1521            .expect("index field is prefixed");
1522
1523        assert!(dir.path().join("blobs").join(hex).exists());
1524    }
1525
1526    #[test]
1527    fn many_concurrent_writers_of_identical_bytes_never_corrupt_the_blob() {
1528        let dir = tempfile::tempdir().unwrap();
1529        let root = dir.path().to_path_buf();
1530        let content = b"identical content raced by many concurrent writers";
1531
1532        let handles: Vec<_> = (0..16)
1533            .map(|_| {
1534                let root = root.clone();
1535                std::thread::spawn(move || write_blob(&root, content).unwrap())
1536            })
1537            .collect();
1538
1539        let hashes: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1540        assert!(
1541            hashes.iter().all(|h| h == &hashes[0]),
1542            "every writer must compute and report the same hash: {hashes:?}"
1543        );
1544
1545        let hex = hashes[0].strip_prefix("sha256:").unwrap();
1546        let blob_bytes = std::fs::read(root.join("blobs").join(hex)).unwrap();
1547        assert_eq!(
1548            blob_bytes, content,
1549            "the published blob must be exactly the input bytes, not truncated or corrupted by a racing writer"
1550        );
1551    }
1552
1553    fn make_bundle(manifest_json: &[u8]) -> Vec<u8> {
1554        let mut bytes = b"CFBD".to_vec();
1555        bytes.extend_from_slice(&(manifest_json.len() as u64).to_le_bytes());
1556        bytes.extend_from_slice(manifest_json);
1557        bytes
1558    }
1559
1560    #[test]
1561    fn reads_the_signature_field_out_of_a_valid_bundle_manifest() {
1562        let bundle = make_bundle(
1563            br#"{"nodes":[],"edges":[],"signature":"{path: text} -> {summary: text}"}"#,
1564        );
1565        let sig = read_bundle_signature(&bundle, "test.cfbundle").unwrap();
1566        assert_eq!(sig, "{path: text} -> {summary: text}");
1567    }
1568
1569    #[test]
1570    fn a_manifest_len_exceeding_the_actual_bytes_is_uninspectable() {
1571        let mut bundle = make_bundle(br#"{"nodes":[],"edges":[],"signature":"x -> x"}"#);
1572        bundle.truncate(bundle.len() - 5); // manifest_len now overshoots what's left
1573        let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1574        match err {
1575            CatalogError::UninspectableArtifact { reason, .. } => assert!(
1576                reason.contains("exceeds the file's actual length"),
1577                "{reason}"
1578            ),
1579            other => panic!("expected UninspectableArtifact, got {other:?}"),
1580        }
1581    }
1582
1583    #[test]
1584    fn invalid_manifest_json_is_uninspectable() {
1585        let bundle = make_bundle(b"not valid json at all");
1586        let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1587        match err {
1588            CatalogError::UninspectableArtifact { reason, .. } => {
1589                assert!(reason.contains("not valid JSON"), "{reason}")
1590            }
1591            other => panic!("expected UninspectableArtifact, got {other:?}"),
1592        }
1593    }
1594
1595    #[test]
1596    fn a_manifest_missing_the_signature_field_is_uninspectable() {
1597        let bundle = make_bundle(br#"{"nodes":[],"edges":[]}"#);
1598        let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1599        match err {
1600            CatalogError::UninspectableArtifact { reason, .. } => {
1601                assert!(reason.contains("no string field"), "{reason}")
1602            }
1603            other => panic!("expected UninspectableArtifact, got {other:?}"),
1604        }
1605    }
1606
1607    #[test]
1608    fn a_node_whose_offset_and_len_overflow_the_stage_bytes_is_uninspectable() {
1609        // No stage bytes follow the manifest at all here, so any non-zero
1610        // offset/len is already out of bounds — exactly the "internally
1611        // impossible node table" shape a hand-crafted or corrupt bundle
1612        // could smuggle past a check that only ever reads the `signature`
1613        // field.
1614        let bundle = make_bundle(
1615            br#"{"nodes":[{"name":"bad","kind":"block","resolved":null,
1616                 "signature":"json -> json","offset":99999,"len":99999}],
1617                 "signature":"json -> json"}"#,
1618        );
1619        let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1620        match err {
1621            CatalogError::UninspectableArtifact { reason, .. } => {
1622                assert!(reason.contains("doesn't fit"), "{reason}")
1623            }
1624            other => panic!("expected UninspectableArtifact, got {other:?}"),
1625        }
1626    }
1627
1628    #[test]
1629    fn a_node_whose_offset_and_len_exactly_fit_the_stage_bytes_is_fine() {
1630        let mut bundle = make_bundle(
1631            br#"{"nodes":[{"name":"ok","kind":"block","resolved":null,
1632                 "signature":"json -> json","offset":0,"len":3}],
1633                 "signature":"json -> json"}"#,
1634        );
1635        bundle.extend_from_slice(b"abc");
1636        let sig = read_bundle_signature(&bundle, "test.cfbundle").unwrap();
1637        assert_eq!(sig, "json -> json");
1638    }
1639
1640    #[test]
1641    fn an_overflowing_node_offset_plus_len_is_uninspectable_not_a_panic() {
1642        // Regression-shaped like the manifest_len overflow fix elsewhere in
1643        // this file: offset + len must not panic on overflow, it must
1644        // report a clean error.
1645        let bundle = make_bundle(
1646            format!(
1647                r#"{{"nodes":[{{"name":"bad","kind":"block","resolved":null,
1648                     "signature":"json -> json","offset":{},"len":10}}],
1649                     "signature":"json -> json"}}"#,
1650                u64::MAX
1651            )
1652            .as_bytes(),
1653        );
1654        let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1655        assert!(matches!(err, CatalogError::UninspectableArtifact { .. }));
1656    }
1657
1658    #[test]
1659    fn an_overflowing_manifest_len_is_uninspectable_not_a_panic() {
1660        // manifest_len near u64::MAX must not panic when added to BUNDLE_HEADER_LEN —
1661        // regression test for the checked_add fix (a bare `+` here panics with
1662        // "attempt to add with overflow" in debug builds, turning a crafted
1663        // bundle file into a crash instead of a clean error).
1664        let mut bytes = b"CFBD".to_vec();
1665        bytes.extend_from_slice(&u64::MAX.to_le_bytes());
1666        let err = read_bundle_signature(&bytes, "test.cfbundle").unwrap_err();
1667        match err {
1668            CatalogError::UninspectableArtifact { reason, .. } => {
1669                assert!(
1670                    reason.contains("exceeds the file's actual length"),
1671                    "{reason}"
1672                )
1673            }
1674            other => panic!("expected UninspectableArtifact, got {other:?}"),
1675        }
1676    }
1677
1678    #[test]
1679    fn a_file_shorter_than_the_header_is_uninspectable() {
1680        let err = read_bundle_signature(b"CFBD", "test.cfbundle").unwrap_err();
1681        match err {
1682            CatalogError::UninspectableArtifact { reason, .. } => {
1683                assert!(
1684                    reason.contains("shorter than the bundle header"),
1685                    "{reason}"
1686                )
1687            }
1688            other => panic!("expected UninspectableArtifact, got {other:?}"),
1689        }
1690    }
1691
1692    #[test]
1693    fn adding_a_wasm_block_with_no_cf_signature_export_caches_the_permissive_default_and_flags_it()
1694    {
1695        let catalog_dir = tempfile::tempdir().unwrap();
1696        let wasm_dir = tempfile::tempdir().unwrap();
1697        let wasm_path = wasm_dir.path().join("no_sig.wasm");
1698        std::fs::write(
1699            &wasm_path,
1700            wat::parse_str(r#"(module (memory (export "memory") 1))"#).unwrap(),
1701        )
1702        .unwrap();
1703
1704        let catalog = Catalog::open(catalog_dir.path());
1705        let outcome = catalog
1706            .add("no-sig@1", &wasm_path, &wasmtime::Engine::default())
1707            .expect("a block missing cf_signature is not an add-time error");
1708
1709        assert_eq!(outcome.signature, "json -> json");
1710        assert!(
1711            outcome.is_permissive_default,
1712            "a block with no cf_signature export must be flagged, not silently accepted"
1713        );
1714    }
1715
1716    #[test]
1717    fn adding_wasm_magic_bytes_with_an_invalid_module_body_is_uninspectable() {
1718        let catalog_dir = tempfile::tempdir().unwrap();
1719        let wasm_dir = tempfile::tempdir().unwrap();
1720        let wasm_path = wasm_dir.path().join("broken.wasm");
1721        // Real wasm magic, garbage after it: passes the magic-byte sniff, fails
1722        // Module::new — the "recognised header, unreadable contents" case.
1723        std::fs::write(
1724            &wasm_path,
1725            b"\0asm\x01\x00\x00\x00garbage-not-a-real-module",
1726        )
1727        .unwrap();
1728
1729        let catalog = Catalog::open(catalog_dir.path());
1730        let err = catalog
1731            .add("broken@1", &wasm_path, &wasmtime::Engine::default())
1732            .unwrap_err();
1733        assert!(
1734            matches!(err, CatalogError::UninspectableArtifact { .. }),
1735            "{err:?}"
1736        );
1737    }
1738
1739    #[test]
1740    fn a_cf_signature_export_that_exists_but_returns_unparseable_bytes_is_uninspectable_not_permissive(
1741    ) {
1742        // Distinct from both prior tests: the module instantiates fine and
1743        // cf_signature exists with the right callable shape (() -> u32) — this
1744        // is the "present but broken" case, which must NOT be folded into the
1745        // "absent" case's permissive-default fallback. The descriptor it returns
1746        // points at zeroed memory (no data segment): reading it back yields an
1747        // empty buffer, which fails to parse as a Signature — read_signature
1748        // returns Err, and that must surface as UninspectableArtifact.
1749        let catalog_dir = tempfile::tempdir().unwrap();
1750        let wasm_dir = tempfile::tempdir().unwrap();
1751        let wasm_path = wasm_dir.path().join("broken_sig.wasm");
1752        std::fs::write(
1753            &wasm_path,
1754            wat::parse_str(
1755                r#"(module
1756                     (memory (export "memory") 1)
1757                     (func (export "cf_signature") (result i32) i32.const 0)
1758                   )"#,
1759            )
1760            .unwrap(),
1761        )
1762        .unwrap();
1763
1764        let catalog = Catalog::open(catalog_dir.path());
1765        let err = catalog
1766            .add("broken-sig@1", &wasm_path, &wasmtime::Engine::default())
1767            .unwrap_err();
1768        assert!(
1769            matches!(err, CatalogError::UninspectableArtifact { .. }),
1770            "present-but-unparseable cf_signature must be a hard failure, not the permissive default: {err:?}"
1771        );
1772    }
1773
1774    #[test]
1775    fn adding_a_bundle_reads_its_signature_from_the_manifest_never_instantiating_wasm() {
1776        let catalog_dir = tempfile::tempdir().unwrap();
1777        let bundle_dir = tempfile::tempdir().unwrap();
1778        let bundle_path = bundle_dir.path().join("digest.cfbundle");
1779        std::fs::write(
1780            &bundle_path,
1781            make_bundle(
1782                br#"{"nodes":[],"edges":[],"signature":"{path: text} -> {summary: text}"}"#,
1783            ),
1784        )
1785        .unwrap();
1786
1787        let catalog = Catalog::open(catalog_dir.path());
1788        let outcome = catalog
1789            .add("digest@1", &bundle_path, &wasmtime::Engine::default())
1790            .unwrap();
1791
1792        assert_eq!(outcome.kind, ArtifactKind::Bundle);
1793        assert_eq!(outcome.signature, "{path: text} -> {summary: text}");
1794        assert!(!outcome.is_permissive_default);
1795    }
1796
1797    #[test]
1798    fn adding_a_file_with_neither_magic_is_unrecognized_not_a_silent_guess() {
1799        let catalog_dir = tempfile::tempdir().unwrap();
1800        let junk_dir = tempfile::tempdir().unwrap();
1801        let junk_path = junk_dir.path().join("junk.bin");
1802        std::fs::write(&junk_path, b"not a wasm or bundle").unwrap();
1803
1804        let catalog = Catalog::open(catalog_dir.path());
1805        let err = catalog
1806            .add("junk@1", &junk_path, &wasmtime::Engine::default())
1807            .unwrap_err();
1808        assert!(
1809            matches!(err, CatalogError::UnrecognizedArtifact { .. }),
1810            "{err:?}"
1811        );
1812    }
1813
1814    #[test]
1815    fn re_adding_the_same_name_version_is_rejected() {
1816        let catalog_dir = tempfile::tempdir().unwrap();
1817        let wasm_dir = tempfile::tempdir().unwrap();
1818        let wasm_path = wasm_dir.path().join("a.wasm");
1819        std::fs::write(
1820            &wasm_path,
1821            wat::parse_str(r#"(module (memory (export "memory") 1))"#).unwrap(),
1822        )
1823        .unwrap();
1824
1825        let catalog = Catalog::open(catalog_dir.path());
1826        let engine = wasmtime::Engine::default();
1827        catalog.add("dup@1", &wasm_path, &engine).unwrap();
1828
1829        let err = catalog.add("dup@1", &wasm_path, &engine).unwrap_err();
1830        assert!(matches!(err, CatalogError::AlreadyExists { .. }), "{err:?}");
1831    }
1832
1833    #[test]
1834    fn list_show_rm_roundtrip() {
1835        let dir = tempfile::tempdir().unwrap();
1836        seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
1837        let catalog = Catalog::open(dir.path());
1838
1839        assert_eq!(catalog.list().unwrap().len(), 1);
1840        let shown = catalog
1841            .show("a@1")
1842            .expect("just-seeded entry must be visible");
1843        assert_eq!(shown.signature, "json -> json");
1844
1845        catalog.rm("a@1").unwrap();
1846        assert!(catalog.list().unwrap().is_empty());
1847    }
1848
1849    #[test]
1850    fn showing_a_missing_entry_reports_not_found_with_a_suggestion() {
1851        let dir = tempfile::tempdir().unwrap();
1852        seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
1853        let catalog = Catalog::open(dir.path());
1854
1855        let err = catalog.show("summarise@1").unwrap_err();
1856        let CatalogError::NotFound { did_you_mean, .. } = &err else {
1857            panic!("expected NotFound, got {err:?}")
1858        };
1859        assert_eq!(did_you_mean, &vec!["summarize@1".to_string()]);
1860    }
1861
1862    /// Write a valid, signature-less wasm block whose bytes vary with
1863    /// `body_marker`, so two calls can produce artifacts that are both valid
1864    /// and genuinely different content.
1865    fn distinct_wasm(dir: &Path, name: &str, body_marker: u32) -> PathBuf {
1866        let path = dir.join(format!("{name}.wasm"));
1867        std::fs::write(
1868            &path,
1869            wat::parse_str(format!(
1870                r#"(module (memory (export "memory") 1) (func (export "marker") (result i32) i32.const {body_marker}))"#
1871            ))
1872            .unwrap(),
1873        )
1874        .unwrap();
1875        path
1876    }
1877
1878    #[test]
1879    fn an_identifier_with_no_at_version_is_rejected_rather_than_catalogued_under_a_typo() {
1880        let catalog_dir = tempfile::tempdir().unwrap();
1881        let wasm_dir = tempfile::tempdir().unwrap();
1882        let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1883
1884        let err = Catalog::open(catalog_dir.path())
1885            .add("echo-summarize", &wasm, &wasmtime::Engine::default())
1886            .expect_err("dropping @version is a typo, not a name meaning itself");
1887
1888        assert!(
1889            matches!(err, CatalogError::InvalidNameVersion { .. }),
1890            "{err:?}"
1891        );
1892        assert!(
1893            Catalog::open(catalog_dir.path()).list().unwrap().is_empty(),
1894            "a rejected identifier must not leave an entry behind"
1895        );
1896    }
1897
1898    #[test]
1899    fn an_identifier_with_an_empty_name_or_version_is_rejected() {
1900        let catalog_dir = tempfile::tempdir().unwrap();
1901        let wasm_dir = tempfile::tempdir().unwrap();
1902        let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1903        let catalog = Catalog::open(catalog_dir.path());
1904        let engine = wasmtime::Engine::default();
1905
1906        for bad in ["@1", "name@", "", "   "] {
1907            let err = catalog
1908                .add(bad, &wasm, &engine)
1909                .expect_err("an empty name or version is not a name@version");
1910            assert!(
1911                matches!(err, CatalogError::InvalidNameVersion { .. }),
1912                "{bad:?} gave {err:?}"
1913            );
1914        }
1915    }
1916
1917    #[test]
1918    fn an_identifier_with_more_than_one_at_separator_is_rejected() {
1919        let catalog_dir = tempfile::tempdir().unwrap();
1920        let wasm_dir = tempfile::tempdir().unwrap();
1921        let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1922
1923        let err = Catalog::open(catalog_dir.path())
1924            .add("a@b@c", &wasm, &wasmtime::Engine::default())
1925            .expect_err("two '@' separators is not a name@version");
1926        assert!(
1927            matches!(err, CatalogError::InvalidNameVersion { .. }),
1928            "{err:?}"
1929        );
1930    }
1931
1932    #[test]
1933    fn an_identifier_containing_path_or_whitespace_characters_is_rejected() {
1934        let catalog_dir = tempfile::tempdir().unwrap();
1935        let wasm_dir = tempfile::tempdir().unwrap();
1936        let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1937        let catalog = Catalog::open(catalog_dir.path());
1938        let engine = wasmtime::Engine::default();
1939
1940        for bad in ["../../etc/passwd@1", "with space@1", "name@../../tmp/pwn"] {
1941            let err = catalog
1942                .add(bad, &wasm, &engine)
1943                .expect_err("{bad} must be rejected");
1944            assert!(
1945                matches!(err, CatalogError::InvalidNameVersion { .. }),
1946                "{bad:?} gave {err:?}"
1947            );
1948        }
1949    }
1950
1951    #[test]
1952    fn an_ordinary_name_at_version_still_catalogs() {
1953        let catalog_dir = tempfile::tempdir().unwrap();
1954        let wasm_dir = tempfile::tempdir().unwrap();
1955        let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1956
1957        Catalog::open(catalog_dir.path())
1958            .add(
1959                "echo-summarize@1.2.3-rc.1",
1960                &wasm,
1961                &wasmtime::Engine::default(),
1962            )
1963            .expect("letters, digits, '.', '-' and '_' are all legal");
1964    }
1965
1966    /// Validation guards the *write* path only. An index that already holds a
1967    /// junk key (written before this check existed, or hand-edited) must stay
1968    /// removable, or the fix would strand entries nothing can clean up.
1969    #[test]
1970    fn a_pre_existing_junk_identifier_can_still_be_shown_and_removed() {
1971        let dir = tempfile::tempdir().unwrap();
1972        seed(dir.path(), "no-at-sign", "2026-01-01T00:00:00Z");
1973        let catalog = Catalog::open(dir.path());
1974
1975        catalog
1976            .show("no-at-sign")
1977            .expect("an already-stored key must remain inspectable");
1978        catalog
1979            .rm("no-at-sign")
1980            .expect("an already-stored key must remain removable");
1981    }
1982
1983    #[test]
1984    fn re_adding_a_removed_version_with_the_same_bytes_is_allowed() {
1985        let catalog_dir = tempfile::tempdir().unwrap();
1986        let wasm_dir = tempfile::tempdir().unwrap();
1987        let wasm = distinct_wasm(wasm_dir.path(), "same", 7);
1988        let catalog = Catalog::open(catalog_dir.path());
1989        let engine = wasmtime::Engine::default();
1990
1991        catalog.add("thing@1", &wasm, &engine).unwrap();
1992        catalog.rm("thing@1").unwrap();
1993        catalog
1994            .add("thing@1", &wasm, &engine)
1995            .expect("re-adding identical bytes is an undo of the rm, not a rewrite of history");
1996
1997        assert_eq!(catalog.list().unwrap().len(), 1);
1998    }
1999
2000    /// The hazard the immutability promise exists to prevent: a name@version
2001    /// that someone already depends on silently coming to mean different
2002    /// content. Deleting the entry first must not launder that.
2003    #[test]
2004    fn re_adding_a_removed_version_with_different_bytes_is_rejected() {
2005        let catalog_dir = tempfile::tempdir().unwrap();
2006        let wasm_dir = tempfile::tempdir().unwrap();
2007        let original = distinct_wasm(wasm_dir.path(), "original", 1);
2008        let replacement = distinct_wasm(wasm_dir.path(), "replacement", 2);
2009        let catalog = Catalog::open(catalog_dir.path());
2010        let engine = wasmtime::Engine::default();
2011
2012        catalog.add("thing@1", &original, &engine).unwrap();
2013        catalog.rm("thing@1").unwrap();
2014
2015        let err = catalog
2016            .add("thing@1", &replacement, &engine)
2017            .expect_err("rm must not be a way to republish a version with new content");
2018        let CatalogError::RetiredWithDifferentContent {
2019            name_version,
2020            previous_hash,
2021            new_hash,
2022        } = &err
2023        else {
2024            panic!("expected RetiredWithDifferentContent, got {err:?}")
2025        };
2026        assert_eq!(name_version, "thing@1");
2027        assert_ne!(previous_hash, new_hash);
2028        assert!(
2029            catalog.list().unwrap().is_empty(),
2030            "the reject must not add"
2031        );
2032    }
2033
2034    /// An index written before retirement tracking existed has no `retired`
2035    /// field at all. It must still load as a normal, non-corrupt catalog
2036    /// rather than tripping the version check.
2037    #[test]
2038    fn an_index_written_without_the_retired_field_still_loads() {
2039        let dir = tempfile::tempdir().unwrap();
2040        std::fs::create_dir_all(dir.path()).unwrap();
2041        std::fs::write(
2042            dir.path().join("index.json"),
2043            br#"{"version":1,"entries":{"old@1":{"hash":"sha256:ab","kind":"block","signature":"json -> json","created_at":"2026-01-01T00:00:00Z"}}}"#,
2044        )
2045        .unwrap();
2046
2047        let index = read_index(dir.path()).expect("an index predating `retired` is not corrupt");
2048        assert!(index.entries.contains_key("old@1"));
2049        assert!(index.retired.is_empty());
2050    }
2051
2052    #[test]
2053    fn removing_a_missing_entry_is_not_found_not_a_silent_no_op() {
2054        let dir = tempfile::tempdir().unwrap();
2055        let catalog = Catalog::open(dir.path());
2056        let err = catalog.rm("nothing@1").unwrap_err();
2057        assert!(matches!(err, CatalogError::NotFound { .. }), "{err:?}");
2058    }
2059
2060    #[test]
2061    fn removing_an_entry_leaves_its_blob_on_disk_v1_has_no_garbage_collection() {
2062        let dir = tempfile::tempdir().unwrap();
2063        let hash = write_blob(dir.path(), b"some block bytes").unwrap();
2064        let hex = hash.strip_prefix("sha256:").unwrap();
2065        with_locked_index(dir.path(), |index| {
2066            index.entries.insert(
2067                "a@1".to_string(),
2068                Entry {
2069                    hash: hash.clone(),
2070                    kind: ArtifactKind::Block,
2071                    signature: "json -> json".to_string(),
2072                    created_at: "2026-01-01T00:00:00Z".to_string(),
2073                },
2074            );
2075            Ok::<_, CatalogError>(())
2076        })
2077        .unwrap();
2078
2079        let catalog = Catalog::open(dir.path());
2080        catalog.rm("a@1").unwrap();
2081
2082        assert!(
2083            matches!(catalog.show("a@1"), Err(CatalogError::NotFound { .. })),
2084            "rm must actually remove the index entry, not silently no-op"
2085        );
2086        assert!(
2087            dir.path().join("blobs").join(hex).exists(),
2088            "rm is index-only; the blob must remain"
2089        );
2090    }
2091
2092    #[test]
2093    fn list_returns_multiple_entries_sorted_by_name_at_version() {
2094        let dir = tempfile::tempdir().unwrap();
2095        seed(dir.path(), "b@1", "2026-01-01T00:00:00Z");
2096        seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2097        seed(dir.path(), "c@1", "2026-01-01T00:00:00Z");
2098
2099        let catalog = Catalog::open(dir.path());
2100        let names: Vec<String> = catalog
2101            .list()
2102            .unwrap()
2103            .into_iter()
2104            .map(|(name_version, _)| name_version)
2105            .collect();
2106
2107        assert_eq!(
2108            names,
2109            vec!["a@1".to_string(), "b@1".to_string(), "c@1".to_string()]
2110        );
2111    }
2112
2113    #[test]
2114    fn resolve_a_dot_wasm_suffix_is_direct_even_if_the_file_does_not_exist() {
2115        let dir = tempfile::tempdir().unwrap();
2116        let catalog = Catalog::open(dir.path());
2117        let resolved = catalog
2118            .resolve("/nonexistent/block.wasm", ResolutionContext::Interactive)
2119            .unwrap();
2120        assert!(matches!(resolved, Resolved::Direct(_)));
2121    }
2122
2123    #[test]
2124    fn resolve_a_dot_cfbundle_suffix_is_direct_even_if_the_file_does_not_exist() {
2125        let dir = tempfile::tempdir().unwrap();
2126        let catalog = Catalog::open(dir.path());
2127        let resolved = catalog
2128            .resolve(
2129                "/nonexistent/bundle.cfbundle",
2130                ResolutionContext::Interactive,
2131            )
2132            .unwrap();
2133        assert!(matches!(resolved, Resolved::Direct(_)));
2134    }
2135
2136    #[test]
2137    fn resolve_an_existing_filesystem_path_is_direct_no_catalog_lookup() {
2138        let dir = tempfile::tempdir().unwrap();
2139        let real_file = tempfile::NamedTempFile::new().unwrap();
2140        let catalog = Catalog::open(dir.path());
2141        let resolved = catalog
2142            .resolve(
2143                real_file.path().to_str().unwrap(),
2144                ResolutionContext::Interactive,
2145            )
2146            .unwrap();
2147        assert!(matches!(resolved, Resolved::Direct(_)));
2148    }
2149
2150    #[test]
2151    fn resolve_exact_name_at_version_hits_case_sensitively() {
2152        let dir = tempfile::tempdir().unwrap();
2153        seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
2154        let catalog = Catalog::open(dir.path());
2155
2156        assert!(catalog
2157            .resolve("summarize@1", ResolutionContext::Interactive)
2158            .is_ok());
2159
2160        let err = catalog
2161            .resolve("Summarize@1", ResolutionContext::Interactive)
2162            .unwrap_err();
2163        let CatalogError::NotFound { did_you_mean, .. } = &err else {
2164            panic!("expected NotFound (case-sensitive miss), got {err:?}")
2165        };
2166        assert!(
2167            did_you_mean.contains(&"summarize@1".to_string()),
2168            "case-sensitivity rejects the hit, but edit distance 1 should still suggest it: {did_you_mean:?}"
2169        );
2170    }
2171
2172    #[test]
2173    fn resolve_unqualified_name_picks_the_latest_by_created_at() {
2174        let dir = tempfile::tempdir().unwrap();
2175        seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2176        seed(dir.path(), "a@2", "2026-06-01T00:00:00Z");
2177        let catalog = Catalog::open(dir.path());
2178
2179        let resolved = catalog
2180            .resolve("a", ResolutionContext::Interactive)
2181            .unwrap();
2182        let Resolved::Cataloged { name_version, .. } = resolved else {
2183            panic!("expected a cataloged resolution")
2184        };
2185        assert_eq!(name_version, "a@2");
2186    }
2187
2188    #[test]
2189    fn resolve_unqualified_name_is_legal_from_an_interactive_context() {
2190        let dir = tempfile::tempdir().unwrap();
2191        seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2192        let catalog = Catalog::open(dir.path());
2193        assert!(catalog.resolve("a", ResolutionContext::Interactive).is_ok());
2194    }
2195
2196    #[test]
2197    fn resolve_unqualified_name_is_rejected_in_a_durable_context() {
2198        let dir = tempfile::tempdir().unwrap();
2199        seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2200        let catalog = Catalog::open(dir.path());
2201        let err = catalog
2202            .resolve("a", ResolutionContext::Durable)
2203            .unwrap_err();
2204        assert!(
2205            matches!(err, CatalogError::UnqualifiedName { .. }),
2206            "{err:?}"
2207        );
2208    }
2209
2210    #[test]
2211    fn resolve_not_found_suggests_a_close_typo() {
2212        let dir = tempfile::tempdir().unwrap();
2213        seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
2214        let catalog = Catalog::open(dir.path());
2215        let err = catalog
2216            .resolve("summarise@1", ResolutionContext::Interactive)
2217            .unwrap_err();
2218        let CatalogError::NotFound { did_you_mean, .. } = &err else {
2219            panic!("expected NotFound, got {err:?}")
2220        };
2221        assert_eq!(did_you_mean, &vec!["summarize@1".to_string()]);
2222    }
2223
2224    #[test]
2225    fn read_blob_returns_what_add_wrote() {
2226        let dir = tempfile::tempdir().unwrap();
2227        let catalog = Catalog::open(dir.path());
2228        let engine = wasmtime::Engine::default();
2229        let wasm = wat::parse_str("(module)").unwrap();
2230        let path = dir.path().join("m.wasm");
2231        std::fs::write(&path, &wasm).unwrap();
2232
2233        let outcome = catalog.add("m@1", &path, &engine).unwrap();
2234        let entry = catalog.show("m@1").unwrap();
2235
2236        let bytes = catalog.read_blob(&entry).unwrap();
2237        assert_eq!(bytes, wasm);
2238        assert_eq!(outcome.name_version, "m@1");
2239    }
2240
2241    #[test]
2242    fn read_blob_on_a_hand_edited_missing_hash_errors_clearly() {
2243        let dir = tempfile::tempdir().unwrap();
2244        let catalog = Catalog::open(dir.path());
2245        let fake = Entry {
2246            hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
2247                .to_string(),
2248            ..entry_fixture("2026-01-01T00:00:00Z")
2249        };
2250        let err = catalog.read_blob(&fake).unwrap_err();
2251        match err {
2252            CatalogError::Io(ref io_err) => {
2253                assert_eq!(
2254                    io_err.kind(),
2255                    std::io::ErrorKind::NotFound,
2256                    "a well-formed hash with no matching blob file must surface as a plain \
2257                     not-found I/O error: {err:?}"
2258                );
2259            }
2260            other => {
2261                panic!("a well-formed but absent hash must be a plain Io(NotFound), not {other:?}")
2262            }
2263        }
2264    }
2265
2266    #[test]
2267    fn read_blob_rejects_a_path_traversal_hash_instead_of_touching_the_filesystem() {
2268        // A hand-edited (or maliciously crafted) index.json is never
2269        // format-validated on read anywhere else in this module — read_blob
2270        // is the last line of defense before a hash string becomes a
2271        // filesystem path. A well-formed sha256 digest is always exactly 64
2272        // lowercase hex digits (see write_blob's `hex::encode`), so
2273        // anything else — especially `../` traversal or an absolute path —
2274        // must be rejected before Path::join ever sees it.
2275        let dir = tempfile::tempdir().unwrap();
2276        // Plant a marker file outside blobs/ that a traversal would reach if
2277        // the guard were missing.
2278        std::fs::write(dir.path().join("outside.txt"), b"do not leak this").unwrap();
2279
2280        let catalog = Catalog::open(dir.path());
2281        let traversal = Entry {
2282            hash: "sha256:../outside.txt".to_string(),
2283            ..entry_fixture("2026-01-01T00:00:00Z")
2284        };
2285        let err = catalog.read_blob(&traversal).unwrap_err();
2286        assert!(
2287            matches!(err, CatalogError::MalformedHash { .. }),
2288            "a path-traversal hash must be rejected as MalformedHash before any path is \
2289             constructed, got {err:?}"
2290        );
2291
2292        let absolute = Entry {
2293            hash: "sha256:/etc/passwd".to_string(),
2294            ..entry_fixture("2026-01-01T00:00:00Z")
2295        };
2296        let err = catalog.read_blob(&absolute).unwrap_err();
2297        assert!(
2298            matches!(err, CatalogError::MalformedHash { .. }),
2299            "an absolute-path-like hash must be rejected as MalformedHash before any path is \
2300             constructed, got {err:?}"
2301        );
2302    }
2303
2304    #[test]
2305    fn a_simple_lowercase_name_is_valid() {
2306        assert!(validate_block_name("my-block").is_ok());
2307    }
2308
2309    #[test]
2310    fn a_name_with_a_dot_is_rejected() {
2311        let err = validate_block_name("my.block").unwrap_err();
2312        assert!(err.to_string().contains('.'), "{err}");
2313    }
2314
2315    #[test]
2316    fn a_name_starting_with_a_digit_is_rejected() {
2317        assert!(validate_block_name("1block").is_err());
2318    }
2319
2320    #[test]
2321    fn a_windows_reserved_device_name_is_rejected_case_insensitively() {
2322        for bad in ["con", "CON", "Con", "aux", "nul", "com1", "lpt9"] {
2323            assert!(
2324                validate_block_name(bad).is_err(),
2325                "{bad} should be rejected"
2326            );
2327        }
2328    }
2329
2330    #[test]
2331    fn a_name_that_only_resembles_a_reserved_name_is_accepted() {
2332        assert!(validate_block_name("console").is_ok());
2333        assert!(validate_block_name("commander").is_ok());
2334    }
2335}