1use std::collections::BTreeMap;
24use std::fs::{self, File};
25use std::io::Write;
26use std::path::{Path, PathBuf};
27
28use serde::{Deserialize, Serialize};
29
30const INDEX_VERSION: u32 = 1;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ArtifactKind {
39 Block,
41 Bundle,
43 Script,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Entry {
52 pub hash: String,
54 pub kind: ArtifactKind,
56 pub signature: String,
60 pub created_at: String,
65}
66
67#[derive(Debug, Serialize, Deserialize)]
69struct IndexFile {
70 version: u32,
71 entries: BTreeMap<String, Entry>,
72 #[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
95fn is_legal_identifier_char(c: char) -> bool {
100 c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')
101}
102
103fn 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
142const 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
150pub 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#[derive(Debug, thiserror::Error)]
194pub enum CatalogError {
195 #[error("{name_version} is already catalogued; versions are immutable once published")]
197 AlreadyExists {
198 name_version: String,
200 },
201 #[error("{name_version:?} is not a name@version ({reason})")]
203 InvalidNameVersion {
204 name_version: String,
206 reason: String,
208 },
209 #[error("{name:?} is not a valid block name ({reason})")]
216 InvalidBlockName {
217 name: String,
219 reason: String,
221 },
222 #[error(
227 "{name_version} was previously catalogued with different content; versions are \
228 immutable once published ({previous_hash} -> {new_hash})"
229 )]
230 RetiredWithDifferentContent {
231 name_version: String,
233 previous_hash: String,
235 new_hash: String,
237 },
238 #[error(
240 "no such catalog entry: {name_version}{}",
241 format_did_you_mean(did_you_mean)
242 )]
243 NotFound {
244 name_version: String,
246 did_you_mean: Vec<String>,
249 },
250 #[error("{name} has no version — an exact name@version is required here")]
254 UnqualifiedName {
255 name: String,
257 },
258 #[error("catalog index at {path} is corrupt: {reason}")]
260 CorruptIndex {
261 path: PathBuf,
263 reason: String,
265 },
266 #[error("{path}: not a recognised artifact (header: {header:02x?})")]
268 UnrecognizedArtifact {
269 path: PathBuf,
271 header: Vec<u8>,
273 },
274 #[error("{path}: {reason}")]
286 UninspectableArtifact {
287 path: PathBuf,
295 reason: String,
297 },
298 #[error(
309 "catalog entry has a malformed hash {hash:?}: expected sha256:<64 lowercase hex digits>"
310 )]
311 MalformedHash {
312 hash: String,
314 },
315 #[error(transparent)]
318 Io(#[from] std::io::Error),
319}
320
321fn 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
331fn 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
349fn 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 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";
391pub(crate) const BUNDLE_MAGIC: &[u8; 4] = b"CFBD";
396pub(crate) const BUNDLE_HEADER_LEN: usize = BUNDLE_MAGIC.len() + 8;
400
401pub(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
423fn 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
455fn 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
496fn 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
509pub(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 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
590pub(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 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 header.parse::<cuttlefish_abi::Signature>().map_err(|e| {
647 CatalogError::UninspectableArtifact {
648 path: PathBuf::from(label),
649 reason: format!("signature header `{header}` does not parse: {e}"),
650 }
651 })?;
652
653 Ok(header.to_string())
654}
655
656fn write_blob(root: &Path, bytes: &[u8]) -> Result<String, CatalogError> {
662 use sha2::{Digest, Sha256};
663 use std::sync::atomic::{AtomicU64, Ordering};
664
665 let hex = format!("{:x}", Sha256::digest(bytes));
666 let dir = blobs_dir(root);
667 fs::create_dir_all(&dir)?;
668
669 let blob_path = dir.join(&hex);
670 if !blob_path.exists() {
671 static COUNTER: AtomicU64 = AtomicU64::new(0);
678 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
679 let tmp_path = dir.join(format!("{hex}.tmp.{}.{unique}", std::process::id()));
680 {
681 let mut tmp = File::create(&tmp_path)?;
682 tmp.write_all(bytes)?;
683 tmp.sync_all()?;
684 }
685 fs::rename(&tmp_path, &blob_path)?;
686 }
687
688 Ok(format!("sha256:{hex}"))
689}
690
691pub struct Catalog {
695 root: PathBuf,
696}
697
698#[derive(Debug, Clone)]
700pub struct AddOutcome {
701 pub name_version: String,
703 pub kind: ArtifactKind,
705 pub signature: String,
707 pub is_permissive_default: bool,
711}
712
713#[derive(Debug, Clone, Copy, PartialEq, Eq)]
721pub enum ResolutionContext {
722 Interactive,
727 Durable,
730}
731
732#[derive(Debug, Clone)]
734pub enum Resolved {
735 Direct(PathBuf),
738 Cataloged {
740 name_version: String,
743 entry: Entry,
745 },
746}
747
748impl Catalog {
749 pub fn open(root: impl Into<PathBuf>) -> Self {
751 Self { root: root.into() }
752 }
753
754 pub fn add(
761 &self,
762 name_version: &str,
763 artifact_path: &Path,
764 engine: &wasmtime::Engine,
765 ) -> Result<AddOutcome, CatalogError> {
766 validate_name_version(name_version)?;
767
768 let bytes = fs::read(artifact_path)?;
769 let kind = match sniff_artifact_kind(&bytes) {
770 Some(k) => k,
771 None if artifact_path.extension().is_some_and(|e| e == "rhai") => ArtifactKind::Script,
772 None => {
773 return Err(CatalogError::UnrecognizedArtifact {
774 path: artifact_path.to_path_buf(),
775 header: bytes.iter().take(8).copied().collect(),
776 })
777 }
778 };
779
780 let (signature, is_permissive_default) = match kind {
781 ArtifactKind::Block => {
782 let sig = crate::runner::read_signature(engine, &bytes).map_err(|e| {
783 CatalogError::UninspectableArtifact {
784 path: artifact_path.to_path_buf(),
785 reason: format!("{e:#}"),
786 }
787 })?;
788 let permissive = cuttlefish_abi::Signature {
789 input: cuttlefish_abi::Ty::Json,
790 output: cuttlefish_abi::Ty::Json,
791 };
792 let is_permissive = sig == permissive;
793 (sig.to_string(), is_permissive)
794 }
795 ArtifactKind::Bundle => {
796 let sig = read_bundle_signature(&bytes, &artifact_path.to_string_lossy())?;
797 (sig, false)
798 }
799 ArtifactKind::Script => {
800 let sig = read_script_signature(&bytes, &artifact_path.to_string_lossy())?;
801 (sig, false)
802 }
803 };
804
805 let hash = write_blob(&self.root, &bytes)?;
806 let created_at = now_rfc3339();
807 let name_version = name_version.to_string();
808
809 with_locked_index(&self.root, |index| {
810 if index.entries.contains_key(&name_version) {
811 return Err(CatalogError::AlreadyExists {
812 name_version: name_version.clone(),
813 });
814 }
815 if let Some(previous_hash) = index.retired.get(&name_version) {
820 if previous_hash != &hash {
821 return Err(CatalogError::RetiredWithDifferentContent {
822 name_version: name_version.clone(),
823 previous_hash: previous_hash.clone(),
824 new_hash: hash.clone(),
825 });
826 }
827 index.retired.remove(&name_version);
828 }
829 index.entries.insert(
830 name_version.clone(),
831 Entry {
832 hash,
833 kind,
834 signature: signature.clone(),
835 created_at,
836 },
837 );
838 Ok(())
839 })?;
840
841 Ok(AddOutcome {
842 name_version,
843 kind,
844 signature,
845 is_permissive_default,
846 })
847 }
848
849 pub fn list(&self) -> Result<Vec<(String, Entry)>, CatalogError> {
852 let index = read_index(&self.root)?;
853 Ok(index.entries.into_iter().collect())
854 }
855
856 pub fn show(&self, name_version: &str) -> Result<Entry, CatalogError> {
860 let index = read_index(&self.root)?;
861 index.entries.get(name_version).cloned().ok_or_else(|| {
862 let name = name_version.split('@').next().unwrap_or(name_version);
863 CatalogError::NotFound {
864 name_version: name_version.to_string(),
865 did_you_mean: pick_did_you_mean(name, &index.entries),
866 }
867 })
868 }
869
870 pub fn read_blob(&self, entry: &Entry) -> Result<Vec<u8>, CatalogError> {
875 let hex = entry.hash.strip_prefix("sha256:").unwrap_or(&entry.hash);
876 if !is_well_formed_sha256_hex(hex) {
877 return Err(CatalogError::MalformedHash {
878 hash: entry.hash.clone(),
879 });
880 }
881 Ok(fs::read(blobs_dir(&self.root).join(hex))?)
882 }
883
884 pub fn rm(&self, name_version: &str) -> Result<(), CatalogError> {
888 with_locked_index(&self.root, |index| {
889 if let Some(entry) = index.entries.remove(name_version) {
890 index
893 .retired
894 .insert(name_version.to_string(), entry.hash.clone());
895 Ok(())
896 } else {
897 let name = name_version.split('@').next().unwrap_or(name_version);
898 Err(CatalogError::NotFound {
899 name_version: name_version.to_string(),
900 did_you_mean: pick_did_you_mean(name, &index.entries),
901 })
902 }
903 })
904 }
905
906 pub fn resolve(&self, s: &str, context: ResolutionContext) -> Result<Resolved, CatalogError> {
920 if s.ends_with(".wasm") || s.ends_with(".cfbundle") || Path::new(s).exists() {
921 return Ok(Resolved::Direct(PathBuf::from(s)));
922 }
923
924 let index = read_index(&self.root)?;
925
926 if let Some((name, version)) = s.rsplit_once('@') {
927 let name_version = format!("{name}@{version}");
928 let entry = index.entries.get(&name_version).cloned().ok_or_else(|| {
929 CatalogError::NotFound {
930 name_version: name_version.clone(),
931 did_you_mean: pick_did_you_mean(name, &index.entries),
932 }
933 })?;
934 return Ok(Resolved::Cataloged {
935 name_version,
936 entry,
937 });
938 }
939
940 if context == ResolutionContext::Durable {
941 return Err(CatalogError::UnqualifiedName {
942 name: s.to_string(),
943 });
944 }
945
946 let mut versions: Vec<(&String, &Entry)> = index
947 .entries
948 .iter()
949 .filter(|(nv, _)| nv.rsplit_once('@').map(|(n, _)| n) == Some(s))
950 .collect();
951 versions.sort_by(|a, b| a.1.created_at.cmp(&b.1.created_at));
952
953 let (name_version, entry) =
954 versions
955 .last()
956 .copied()
957 .ok_or_else(|| CatalogError::NotFound {
958 name_version: s.to_string(),
959 did_you_mean: pick_did_you_mean(s, &index.entries),
960 })?;
961
962 Ok(Resolved::Cataloged {
963 name_version: name_version.clone(),
964 entry: entry.clone(),
965 })
966 }
967}
968
969pub(crate) fn cuttlefish_home() -> Option<PathBuf> {
978 if let Ok(home) = std::env::var("CUTTLEFISH_HOME") {
979 return Some(PathBuf::from(home));
980 }
981 dirs::home_dir().map(|home| home.join(".cuttlefish"))
982}
983
984pub fn default_root() -> Option<PathBuf> {
988 cuttlefish_home().map(|h| h.join("catalog"))
989}
990
991pub(crate) fn now_rfc3339() -> String {
997 let now = time::OffsetDateTime::now_utc()
998 .replace_nanosecond(0)
999 .expect("0 is always a valid nanosecond value");
1000 now.format(&time::format_description::well_known::Rfc3339)
1001 .expect("Rfc3339 formatting cannot fail for a valid OffsetDateTime")
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006 use super::*;
1007 use std::sync::atomic::{AtomicBool, Ordering};
1008 use std::sync::{Arc, Barrier};
1009
1010 const WRITERS: usize = 16;
1013
1014 #[test]
1015 fn default_root_honors_cuttlefish_home() {
1016 std::env::set_var("CUTTLEFISH_HOME", "/tmp/cf-test-home");
1022 let root = default_root();
1023 std::env::remove_var("CUTTLEFISH_HOME");
1024 assert_eq!(root, Some(PathBuf::from("/tmp/cf-test-home/catalog")));
1025 }
1026
1027 #[test]
1028 fn index_file_serializes_to_the_shape_the_spec_documents() {
1029 let mut entries = BTreeMap::new();
1030 entries.insert(
1031 "chunk-text@1".to_string(),
1032 Entry {
1033 hash: "sha256:9f86d081".to_string(),
1034 kind: ArtifactKind::Block,
1035 signature: "{path: text} -> [text]".to_string(),
1036 created_at: "2026-08-02T18:03:00Z".to_string(),
1037 },
1038 );
1039 let index = IndexFile {
1040 version: INDEX_VERSION,
1041 entries,
1042 retired: BTreeMap::new(),
1043 };
1044
1045 let json = serde_json::to_string(&index).expect("IndexFile always serializes");
1046 let parsed: serde_json::Value =
1047 serde_json::from_str(&json).expect("what we just wrote must parse");
1048
1049 assert_eq!(parsed["version"], 1);
1050 assert_eq!(parsed["entries"]["chunk-text@1"]["kind"], "block");
1051 assert_eq!(
1052 parsed["entries"]["chunk-text@1"]["signature"],
1053 "{path: text} -> [text]"
1054 );
1055
1056 let round_tripped: IndexFile =
1057 serde_json::from_str(&json).expect("must deserialize what we just serialized");
1058 assert_eq!(round_tripped.version, INDEX_VERSION);
1059 assert!(round_tripped.entries.contains_key("chunk-text@1"));
1060 }
1061
1062 #[test]
1063 fn not_found_with_suggestions_reads_as_one_sentence() {
1064 let err = CatalogError::NotFound {
1065 name_version: "summarise@1".to_string(),
1066 did_you_mean: vec!["summarize@1".to_string()],
1067 };
1068 assert_eq!(
1069 err.to_string(),
1070 "no such catalog entry: summarise@1 (did you mean: summarize@1?)"
1071 );
1072 }
1073
1074 #[test]
1075 fn not_found_with_no_suggestions_has_no_dangling_parenthetical() {
1076 let err = CatalogError::NotFound {
1077 name_version: "xyz@1".to_string(),
1078 did_you_mean: vec![],
1079 };
1080 assert_eq!(err.to_string(), "no such catalog entry: xyz@1");
1081 }
1082
1083 fn entry_fixture(created_at: &str) -> Entry {
1084 Entry {
1085 hash: "sha256:deadbeef".to_string(),
1086 kind: ArtifactKind::Block,
1087 signature: "json -> json".to_string(),
1088 created_at: created_at.to_string(),
1089 }
1090 }
1091
1092 fn seed(root: &Path, name_version: &str, created_at: &str) {
1093 with_locked_index(root, |index| {
1094 index
1095 .entries
1096 .insert(name_version.to_string(), entry_fixture(created_at));
1097 Ok::<_, CatalogError>(())
1098 })
1099 .unwrap();
1100 }
1101
1102 #[test]
1103 fn levenshtein_matches_known_distances() {
1104 assert_eq!(levenshtein("kitten", "sitting"), 3);
1105 assert_eq!(levenshtein("summarize", "summarise"), 1);
1106 assert_eq!(levenshtein("same", "same"), 0);
1107 }
1108
1109 #[test]
1110 fn did_you_mean_catches_a_one_character_typo_a_prefix_match_would_miss() {
1111 let mut entries = BTreeMap::new();
1115 entries.insert(
1116 "summarize@1".to_string(),
1117 entry_fixture("2026-01-01T00:00:00Z"),
1118 );
1119 assert_eq!(
1120 pick_did_you_mean("summarise", &entries),
1121 vec!["summarize@1".to_string()]
1122 );
1123 }
1124
1125 #[test]
1126 fn did_you_mean_is_empty_when_nothing_registered_is_close() {
1127 let mut entries = BTreeMap::new();
1128 entries.insert(
1129 "summarize@1".to_string(),
1130 entry_fixture("2026-01-01T00:00:00Z"),
1131 );
1132 assert!(pick_did_you_mean("completely-unrelated-name", &entries).is_empty());
1133 }
1134
1135 #[test]
1136 fn did_you_mean_is_capped_at_five_closest_ordered_by_distance() {
1137 let mut entries = BTreeMap::new();
1138 for (i, name) in ["bat", "cot", "car", "cap", "can", "cad"]
1141 .iter()
1142 .enumerate()
1143 {
1144 entries.insert(
1145 format!("{name}@1"),
1146 entry_fixture(&format!("2026-01-0{}T00:00:00Z", i + 1)),
1147 );
1148 }
1149 let suggestions = pick_did_you_mean("cat", &entries);
1150 assert_eq!(suggestions.len(), 5, "capped at 5: {suggestions:?}");
1151 }
1152
1153 #[test]
1154 fn did_you_mean_suggests_the_newest_version_when_multiple_versions_of_a_close_name_exist() {
1155 let mut entries = BTreeMap::new();
1156 entries.insert(
1157 "summarize@1".to_string(),
1158 entry_fixture("2026-01-01T00:00:00Z"),
1159 );
1160 entries.insert(
1161 "summarize@2".to_string(),
1162 entry_fixture("2026-06-01T00:00:00Z"),
1163 );
1164 assert_eq!(
1165 pick_did_you_mean("summarise", &entries),
1166 vec!["summarize@2".to_string()],
1167 "must suggest the newest version of a matching name, not every version"
1168 );
1169 }
1170
1171 #[test]
1172 fn wasm_magic_bytes_sniff_as_a_block() {
1173 assert_eq!(
1174 sniff_artifact_kind(b"\0asm\x01\x00\x00\x00"),
1175 Some(ArtifactKind::Block)
1176 );
1177 }
1178
1179 #[test]
1180 fn bundle_magic_bytes_sniff_as_a_bundle() {
1181 assert_eq!(
1182 sniff_artifact_kind(b"CFBD\x00\x00\x00\x00\x00\x00\x00\x00"),
1183 Some(ArtifactKind::Bundle)
1184 );
1185 }
1186
1187 #[test]
1188 fn unrecognised_bytes_sniff_to_none_not_a_guess() {
1189 assert_eq!(sniff_artifact_kind(b"whatever-this-is"), None);
1190 }
1191
1192 #[test]
1193 fn writing_then_reading_the_index_round_trips_through_disk() {
1194 let dir = tempfile::tempdir().unwrap();
1195 with_locked_index(dir.path(), |index| {
1196 index
1197 .entries
1198 .insert("a@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1199 Ok::<_, CatalogError>(())
1200 })
1201 .unwrap();
1202
1203 let index = read_index(dir.path()).unwrap();
1204 assert!(index.entries.contains_key("a@1"));
1205 }
1206
1207 #[test]
1208 fn reading_an_index_that_does_not_exist_yet_is_an_empty_catalog_not_an_error() {
1209 let dir = tempfile::tempdir().unwrap();
1210 let index = read_index(dir.path()).expect("no index.json yet is not corruption");
1211 assert!(index.entries.is_empty());
1212 }
1213
1214 #[test]
1215 fn a_truncated_index_is_a_corrupt_index_error_not_an_empty_catalog() {
1216 let dir = tempfile::tempdir().unwrap();
1217 std::fs::create_dir_all(dir.path()).unwrap();
1218 std::fs::write(dir.path().join("index.json"), b"{\"version\": 1, \"ent").unwrap();
1219
1220 let err = read_index(dir.path()).unwrap_err();
1221 assert!(
1222 matches!(err, CatalogError::CorruptIndex { .. }),
1223 "a truncated index must be a loud CorruptIndex, not treated as empty: {err:?}"
1224 );
1225 }
1226
1227 #[test]
1228 fn an_unsupported_index_version_is_a_corrupt_index_error() {
1229 let dir = tempfile::tempdir().unwrap();
1230 std::fs::create_dir_all(dir.path()).unwrap();
1231 std::fs::write(
1232 dir.path().join("index.json"),
1233 br#"{"version": 999, "entries": {}}"#,
1234 )
1235 .unwrap();
1236
1237 let err = read_index(dir.path()).unwrap_err();
1238 assert!(matches!(err, CatalogError::CorruptIndex { .. }), "{err:?}");
1239 }
1240
1241 #[test]
1242 fn concurrent_writes_from_two_threads_both_land_and_the_index_stays_parseable() {
1243 let dir = tempfile::tempdir().unwrap();
1244 let root_a = dir.path().to_path_buf();
1245 let root_b = dir.path().to_path_buf();
1246
1247 let t1 = std::thread::spawn(move || {
1248 with_locked_index(&root_a, |index| {
1249 index
1250 .entries
1251 .insert("a@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1252 Ok::<_, CatalogError>(())
1253 })
1254 .unwrap();
1255 });
1256 let t2 = std::thread::spawn(move || {
1257 with_locked_index(&root_b, |index| {
1258 index
1259 .entries
1260 .insert("b@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1261 Ok::<_, CatalogError>(())
1262 })
1263 .unwrap();
1264 });
1265 t1.join().unwrap();
1266 t2.join().unwrap();
1267
1268 let index = read_index(dir.path()).expect("the index must still parse after contention");
1269 assert!(index.entries.contains_key("a@1"));
1270 assert!(index.entries.contains_key("b@1"));
1271 }
1272
1273 #[test]
1280 fn racing_inserts_of_the_same_key_leave_exactly_one_winner() {
1281 let dir = tempfile::tempdir().unwrap();
1282 let root = dir.path().to_path_buf();
1283 let barrier = Arc::new(Barrier::new(WRITERS));
1284
1285 let handles: Vec<_> = (0..WRITERS)
1286 .map(|_| {
1287 let root = root.clone();
1288 let barrier = barrier.clone();
1289 std::thread::spawn(move || {
1290 barrier.wait();
1291 with_locked_index(&root, |index| {
1292 if index.entries.contains_key("race@1") {
1293 return Err(CatalogError::AlreadyExists {
1294 name_version: "race@1".to_string(),
1295 });
1296 }
1297 index
1298 .entries
1299 .insert("race@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1300 Ok(())
1301 })
1302 })
1303 })
1304 .collect();
1305
1306 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1307
1308 let winners = results.iter().filter(|r| r.is_ok()).count();
1309 assert_eq!(
1310 winners, 1,
1311 "exactly one racing writer may claim a key; got {winners}"
1312 );
1313 assert!(
1314 results
1315 .iter()
1316 .all(|r| r.is_ok() || matches!(r, Err(CatalogError::AlreadyExists { .. }))),
1317 "every loser must lose with AlreadyExists, not an io or corruption error: {results:?}"
1318 );
1319
1320 let index = read_index(&root).expect("the index must still parse after contention");
1321 assert_eq!(index.entries.len(), 1);
1322 }
1323
1324 #[test]
1329 fn many_racing_writers_of_distinct_keys_all_land_with_no_lost_updates() {
1330 let dir = tempfile::tempdir().unwrap();
1331 let root = dir.path().to_path_buf();
1332 let barrier = Arc::new(Barrier::new(WRITERS));
1333
1334 let handles: Vec<_> = (0..WRITERS)
1335 .map(|w| {
1336 let root = root.clone();
1337 let barrier = barrier.clone();
1338 std::thread::spawn(move || {
1339 barrier.wait();
1340 with_locked_index(&root, |index| {
1341 index.entries.insert(
1342 format!("writer-{w}@1"),
1343 entry_fixture("2026-01-01T00:00:00Z"),
1344 );
1345 Ok::<_, CatalogError>(())
1346 })
1347 .unwrap();
1348 })
1349 })
1350 .collect();
1351 for h in handles {
1352 h.join().unwrap();
1353 }
1354
1355 let index = read_index(&root).expect("the index must still parse after contention");
1356 assert_eq!(
1357 index.entries.len(),
1358 WRITERS,
1359 "every writer's entry must survive; a lost update means the \
1360 read-modify-write escaped the lock: {:?}",
1361 index.entries.keys().collect::<Vec<_>>()
1362 );
1363 }
1364
1365 #[test]
1371 fn lock_free_readers_never_observe_a_partial_index_while_writers_hammer() {
1372 let dir = tempfile::tempdir().unwrap();
1373 let root = dir.path().to_path_buf();
1374 seed(&root, "seed@1", "2026-01-01T00:00:00Z");
1377
1378 let stop = Arc::new(AtomicBool::new(false));
1379
1380 let writers: Vec<_> = (0..4)
1381 .map(|w| {
1382 let root = root.clone();
1383 std::thread::spawn(move || {
1384 for i in 0..60 {
1385 with_locked_index(&root, |index| {
1386 index.entries.insert(
1387 format!("w{w}-{i}@1"),
1388 entry_fixture("2026-01-01T00:00:00Z"),
1389 );
1390 Ok::<_, CatalogError>(())
1391 })
1392 .unwrap();
1393 }
1394 })
1395 })
1396 .collect();
1397
1398 let readers: Vec<_> = (0..4)
1399 .map(|_| {
1400 let root = root.clone();
1401 let stop = stop.clone();
1402 std::thread::spawn(move || {
1403 let mut reads = 0u32;
1404 while !stop.load(Ordering::Relaxed) {
1405 let index = read_index(&root)
1406 .expect("a lock-free reader must never see a partial or corrupt index");
1407 assert!(
1410 index.entries.contains_key("seed@1"),
1411 "an entry that is never removed vanished from a concurrent read"
1412 );
1413 reads += 1;
1414 }
1415 reads
1416 })
1417 })
1418 .collect();
1419
1420 for w in writers {
1421 w.join().unwrap();
1422 }
1423 stop.store(true, Ordering::Relaxed);
1424
1425 let total: u32 = readers.into_iter().map(|r| r.join().unwrap()).sum();
1426 assert!(
1427 total > 0,
1428 "the readers must have actually observed the index"
1429 );
1430 }
1431
1432 #[test]
1439 fn adds_and_removals_racing_on_one_index_leave_exactly_the_expected_entries() {
1440 let dir = tempfile::tempdir().unwrap();
1441 let root = dir.path().to_path_buf();
1442 seed(&root, "keep@1", "2026-01-01T00:00:00Z");
1443 seed(&root, "keep@2", "2026-01-01T00:00:00Z");
1444
1445 let barrier = Arc::new(Barrier::new(WRITERS));
1446 let handles: Vec<_> = (0..WRITERS)
1447 .map(|w| {
1448 let root = root.clone();
1449 let barrier = barrier.clone();
1450 std::thread::spawn(move || {
1451 let key = format!("churn-{w}@1");
1452 barrier.wait();
1453 for _ in 0..10 {
1454 with_locked_index(&root, |index| {
1455 index
1456 .entries
1457 .insert(key.clone(), entry_fixture("2026-01-01T00:00:00Z"));
1458 Ok::<_, CatalogError>(())
1459 })
1460 .unwrap();
1461 with_locked_index(&root, |index| {
1462 index.entries.remove(&key).expect(
1463 "a key only this thread ever touches must still be present",
1464 );
1465 Ok::<_, CatalogError>(())
1466 })
1467 .unwrap();
1468 }
1469 })
1470 })
1471 .collect();
1472 for h in handles {
1473 h.join().unwrap();
1474 }
1475
1476 let index = read_index(&root).expect("the index must still parse after mixed contention");
1477 let names: Vec<_> = index.entries.keys().cloned().collect();
1478 assert_eq!(
1479 names,
1480 vec!["keep@1".to_string(), "keep@2".to_string()],
1481 "churn keys must all be gone and the untouched entries must survive"
1482 );
1483 }
1484
1485 #[test]
1486 fn identical_bytes_under_two_writes_produce_exactly_one_blob_file() {
1487 let dir = tempfile::tempdir().unwrap();
1488 let hash1 = write_blob(dir.path(), b"hello world").unwrap();
1489 let hash2 = write_blob(dir.path(), b"hello world").unwrap();
1490
1491 assert_eq!(hash1, hash2);
1492 assert!(hash1.starts_with("sha256:"));
1493
1494 let blob_count = std::fs::read_dir(dir.path().join("blobs")).unwrap().count();
1495 assert_eq!(
1496 blob_count, 1,
1497 "identical bytes must dedupe to a single blob file"
1498 );
1499 }
1500
1501 #[test]
1502 fn the_blob_filename_on_disk_is_bare_hex_no_prefix() {
1503 let dir = tempfile::tempdir().unwrap();
1504 let hash = write_blob(dir.path(), b"hello world").unwrap();
1505 let hex = hash
1506 .strip_prefix("sha256:")
1507 .expect("index field is prefixed");
1508
1509 assert!(dir.path().join("blobs").join(hex).exists());
1510 }
1511
1512 #[test]
1513 fn many_concurrent_writers_of_identical_bytes_never_corrupt_the_blob() {
1514 let dir = tempfile::tempdir().unwrap();
1515 let root = dir.path().to_path_buf();
1516 let content = b"identical content raced by many concurrent writers";
1517
1518 let handles: Vec<_> = (0..16)
1519 .map(|_| {
1520 let root = root.clone();
1521 std::thread::spawn(move || write_blob(&root, content).unwrap())
1522 })
1523 .collect();
1524
1525 let hashes: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1526 assert!(
1527 hashes.iter().all(|h| h == &hashes[0]),
1528 "every writer must compute and report the same hash: {hashes:?}"
1529 );
1530
1531 let hex = hashes[0].strip_prefix("sha256:").unwrap();
1532 let blob_bytes = std::fs::read(root.join("blobs").join(hex)).unwrap();
1533 assert_eq!(
1534 blob_bytes, content,
1535 "the published blob must be exactly the input bytes, not truncated or corrupted by a racing writer"
1536 );
1537 }
1538
1539 fn make_bundle(manifest_json: &[u8]) -> Vec<u8> {
1540 let mut bytes = b"CFBD".to_vec();
1541 bytes.extend_from_slice(&(manifest_json.len() as u64).to_le_bytes());
1542 bytes.extend_from_slice(manifest_json);
1543 bytes
1544 }
1545
1546 #[test]
1547 fn reads_the_signature_field_out_of_a_valid_bundle_manifest() {
1548 let bundle = make_bundle(
1549 br#"{"nodes":[],"edges":[],"signature":"{path: text} -> {summary: text}"}"#,
1550 );
1551 let sig = read_bundle_signature(&bundle, "test.cfbundle").unwrap();
1552 assert_eq!(sig, "{path: text} -> {summary: text}");
1553 }
1554
1555 #[test]
1556 fn a_manifest_len_exceeding_the_actual_bytes_is_uninspectable() {
1557 let mut bundle = make_bundle(br#"{"nodes":[],"edges":[],"signature":"x -> x"}"#);
1558 bundle.truncate(bundle.len() - 5); let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1560 match err {
1561 CatalogError::UninspectableArtifact { reason, .. } => assert!(
1562 reason.contains("exceeds the file's actual length"),
1563 "{reason}"
1564 ),
1565 other => panic!("expected UninspectableArtifact, got {other:?}"),
1566 }
1567 }
1568
1569 #[test]
1570 fn invalid_manifest_json_is_uninspectable() {
1571 let bundle = make_bundle(b"not valid json at all");
1572 let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1573 match err {
1574 CatalogError::UninspectableArtifact { reason, .. } => {
1575 assert!(reason.contains("not valid JSON"), "{reason}")
1576 }
1577 other => panic!("expected UninspectableArtifact, got {other:?}"),
1578 }
1579 }
1580
1581 #[test]
1582 fn a_manifest_missing_the_signature_field_is_uninspectable() {
1583 let bundle = make_bundle(br#"{"nodes":[],"edges":[]}"#);
1584 let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1585 match err {
1586 CatalogError::UninspectableArtifact { reason, .. } => {
1587 assert!(reason.contains("no string field"), "{reason}")
1588 }
1589 other => panic!("expected UninspectableArtifact, got {other:?}"),
1590 }
1591 }
1592
1593 #[test]
1594 fn a_node_whose_offset_and_len_overflow_the_stage_bytes_is_uninspectable() {
1595 let bundle = make_bundle(
1601 br#"{"nodes":[{"name":"bad","kind":"block","resolved":null,
1602 "signature":"json -> json","offset":99999,"len":99999}],
1603 "signature":"json -> json"}"#,
1604 );
1605 let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1606 match err {
1607 CatalogError::UninspectableArtifact { reason, .. } => {
1608 assert!(reason.contains("doesn't fit"), "{reason}")
1609 }
1610 other => panic!("expected UninspectableArtifact, got {other:?}"),
1611 }
1612 }
1613
1614 #[test]
1615 fn a_node_whose_offset_and_len_exactly_fit_the_stage_bytes_is_fine() {
1616 let mut bundle = make_bundle(
1617 br#"{"nodes":[{"name":"ok","kind":"block","resolved":null,
1618 "signature":"json -> json","offset":0,"len":3}],
1619 "signature":"json -> json"}"#,
1620 );
1621 bundle.extend_from_slice(b"abc");
1622 let sig = read_bundle_signature(&bundle, "test.cfbundle").unwrap();
1623 assert_eq!(sig, "json -> json");
1624 }
1625
1626 #[test]
1627 fn an_overflowing_node_offset_plus_len_is_uninspectable_not_a_panic() {
1628 let bundle = make_bundle(
1632 format!(
1633 r#"{{"nodes":[{{"name":"bad","kind":"block","resolved":null,
1634 "signature":"json -> json","offset":{},"len":10}}],
1635 "signature":"json -> json"}}"#,
1636 u64::MAX
1637 )
1638 .as_bytes(),
1639 );
1640 let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1641 assert!(matches!(err, CatalogError::UninspectableArtifact { .. }));
1642 }
1643
1644 #[test]
1645 fn an_overflowing_manifest_len_is_uninspectable_not_a_panic() {
1646 let mut bytes = b"CFBD".to_vec();
1651 bytes.extend_from_slice(&u64::MAX.to_le_bytes());
1652 let err = read_bundle_signature(&bytes, "test.cfbundle").unwrap_err();
1653 match err {
1654 CatalogError::UninspectableArtifact { reason, .. } => {
1655 assert!(
1656 reason.contains("exceeds the file's actual length"),
1657 "{reason}"
1658 )
1659 }
1660 other => panic!("expected UninspectableArtifact, got {other:?}"),
1661 }
1662 }
1663
1664 #[test]
1665 fn a_file_shorter_than_the_header_is_uninspectable() {
1666 let err = read_bundle_signature(b"CFBD", "test.cfbundle").unwrap_err();
1667 match err {
1668 CatalogError::UninspectableArtifact { reason, .. } => {
1669 assert!(
1670 reason.contains("shorter than the bundle header"),
1671 "{reason}"
1672 )
1673 }
1674 other => panic!("expected UninspectableArtifact, got {other:?}"),
1675 }
1676 }
1677
1678 #[test]
1679 fn adding_a_wasm_block_with_no_cf_signature_export_caches_the_permissive_default_and_flags_it()
1680 {
1681 let catalog_dir = tempfile::tempdir().unwrap();
1682 let wasm_dir = tempfile::tempdir().unwrap();
1683 let wasm_path = wasm_dir.path().join("no_sig.wasm");
1684 std::fs::write(
1685 &wasm_path,
1686 wat::parse_str(r#"(module (memory (export "memory") 1))"#).unwrap(),
1687 )
1688 .unwrap();
1689
1690 let catalog = Catalog::open(catalog_dir.path());
1691 let outcome = catalog
1692 .add("no-sig@1", &wasm_path, &wasmtime::Engine::default())
1693 .expect("a block missing cf_signature is not an add-time error");
1694
1695 assert_eq!(outcome.signature, "json -> json");
1696 assert!(
1697 outcome.is_permissive_default,
1698 "a block with no cf_signature export must be flagged, not silently accepted"
1699 );
1700 }
1701
1702 #[test]
1703 fn adding_wasm_magic_bytes_with_an_invalid_module_body_is_uninspectable() {
1704 let catalog_dir = tempfile::tempdir().unwrap();
1705 let wasm_dir = tempfile::tempdir().unwrap();
1706 let wasm_path = wasm_dir.path().join("broken.wasm");
1707 std::fs::write(
1710 &wasm_path,
1711 b"\0asm\x01\x00\x00\x00garbage-not-a-real-module",
1712 )
1713 .unwrap();
1714
1715 let catalog = Catalog::open(catalog_dir.path());
1716 let err = catalog
1717 .add("broken@1", &wasm_path, &wasmtime::Engine::default())
1718 .unwrap_err();
1719 assert!(
1720 matches!(err, CatalogError::UninspectableArtifact { .. }),
1721 "{err:?}"
1722 );
1723 }
1724
1725 #[test]
1726 fn a_cf_signature_export_that_exists_but_returns_unparseable_bytes_is_uninspectable_not_permissive(
1727 ) {
1728 let catalog_dir = tempfile::tempdir().unwrap();
1736 let wasm_dir = tempfile::tempdir().unwrap();
1737 let wasm_path = wasm_dir.path().join("broken_sig.wasm");
1738 std::fs::write(
1739 &wasm_path,
1740 wat::parse_str(
1741 r#"(module
1742 (memory (export "memory") 1)
1743 (func (export "cf_signature") (result i32) i32.const 0)
1744 )"#,
1745 )
1746 .unwrap(),
1747 )
1748 .unwrap();
1749
1750 let catalog = Catalog::open(catalog_dir.path());
1751 let err = catalog
1752 .add("broken-sig@1", &wasm_path, &wasmtime::Engine::default())
1753 .unwrap_err();
1754 assert!(
1755 matches!(err, CatalogError::UninspectableArtifact { .. }),
1756 "present-but-unparseable cf_signature must be a hard failure, not the permissive default: {err:?}"
1757 );
1758 }
1759
1760 #[test]
1761 fn adding_a_bundle_reads_its_signature_from_the_manifest_never_instantiating_wasm() {
1762 let catalog_dir = tempfile::tempdir().unwrap();
1763 let bundle_dir = tempfile::tempdir().unwrap();
1764 let bundle_path = bundle_dir.path().join("digest.cfbundle");
1765 std::fs::write(
1766 &bundle_path,
1767 make_bundle(
1768 br#"{"nodes":[],"edges":[],"signature":"{path: text} -> {summary: text}"}"#,
1769 ),
1770 )
1771 .unwrap();
1772
1773 let catalog = Catalog::open(catalog_dir.path());
1774 let outcome = catalog
1775 .add("digest@1", &bundle_path, &wasmtime::Engine::default())
1776 .unwrap();
1777
1778 assert_eq!(outcome.kind, ArtifactKind::Bundle);
1779 assert_eq!(outcome.signature, "{path: text} -> {summary: text}");
1780 assert!(!outcome.is_permissive_default);
1781 }
1782
1783 #[test]
1784 fn adding_a_file_with_neither_magic_is_unrecognized_not_a_silent_guess() {
1785 let catalog_dir = tempfile::tempdir().unwrap();
1786 let junk_dir = tempfile::tempdir().unwrap();
1787 let junk_path = junk_dir.path().join("junk.bin");
1788 std::fs::write(&junk_path, b"not a wasm or bundle").unwrap();
1789
1790 let catalog = Catalog::open(catalog_dir.path());
1791 let err = catalog
1792 .add("junk@1", &junk_path, &wasmtime::Engine::default())
1793 .unwrap_err();
1794 assert!(
1795 matches!(err, CatalogError::UnrecognizedArtifact { .. }),
1796 "{err:?}"
1797 );
1798 }
1799
1800 #[test]
1801 fn re_adding_the_same_name_version_is_rejected() {
1802 let catalog_dir = tempfile::tempdir().unwrap();
1803 let wasm_dir = tempfile::tempdir().unwrap();
1804 let wasm_path = wasm_dir.path().join("a.wasm");
1805 std::fs::write(
1806 &wasm_path,
1807 wat::parse_str(r#"(module (memory (export "memory") 1))"#).unwrap(),
1808 )
1809 .unwrap();
1810
1811 let catalog = Catalog::open(catalog_dir.path());
1812 let engine = wasmtime::Engine::default();
1813 catalog.add("dup@1", &wasm_path, &engine).unwrap();
1814
1815 let err = catalog.add("dup@1", &wasm_path, &engine).unwrap_err();
1816 assert!(matches!(err, CatalogError::AlreadyExists { .. }), "{err:?}");
1817 }
1818
1819 #[test]
1820 fn list_show_rm_roundtrip() {
1821 let dir = tempfile::tempdir().unwrap();
1822 seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
1823 let catalog = Catalog::open(dir.path());
1824
1825 assert_eq!(catalog.list().unwrap().len(), 1);
1826 let shown = catalog
1827 .show("a@1")
1828 .expect("just-seeded entry must be visible");
1829 assert_eq!(shown.signature, "json -> json");
1830
1831 catalog.rm("a@1").unwrap();
1832 assert!(catalog.list().unwrap().is_empty());
1833 }
1834
1835 #[test]
1836 fn showing_a_missing_entry_reports_not_found_with_a_suggestion() {
1837 let dir = tempfile::tempdir().unwrap();
1838 seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
1839 let catalog = Catalog::open(dir.path());
1840
1841 let err = catalog.show("summarise@1").unwrap_err();
1842 let CatalogError::NotFound { did_you_mean, .. } = &err else {
1843 panic!("expected NotFound, got {err:?}")
1844 };
1845 assert_eq!(did_you_mean, &vec!["summarize@1".to_string()]);
1846 }
1847
1848 fn distinct_wasm(dir: &Path, name: &str, body_marker: u32) -> PathBuf {
1852 let path = dir.join(format!("{name}.wasm"));
1853 std::fs::write(
1854 &path,
1855 wat::parse_str(format!(
1856 r#"(module (memory (export "memory") 1) (func (export "marker") (result i32) i32.const {body_marker}))"#
1857 ))
1858 .unwrap(),
1859 )
1860 .unwrap();
1861 path
1862 }
1863
1864 #[test]
1865 fn an_identifier_with_no_at_version_is_rejected_rather_than_catalogued_under_a_typo() {
1866 let catalog_dir = tempfile::tempdir().unwrap();
1867 let wasm_dir = tempfile::tempdir().unwrap();
1868 let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1869
1870 let err = Catalog::open(catalog_dir.path())
1871 .add("echo-summarize", &wasm, &wasmtime::Engine::default())
1872 .expect_err("dropping @version is a typo, not a name meaning itself");
1873
1874 assert!(
1875 matches!(err, CatalogError::InvalidNameVersion { .. }),
1876 "{err:?}"
1877 );
1878 assert!(
1879 Catalog::open(catalog_dir.path()).list().unwrap().is_empty(),
1880 "a rejected identifier must not leave an entry behind"
1881 );
1882 }
1883
1884 #[test]
1885 fn an_identifier_with_an_empty_name_or_version_is_rejected() {
1886 let catalog_dir = tempfile::tempdir().unwrap();
1887 let wasm_dir = tempfile::tempdir().unwrap();
1888 let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1889 let catalog = Catalog::open(catalog_dir.path());
1890 let engine = wasmtime::Engine::default();
1891
1892 for bad in ["@1", "name@", "", " "] {
1893 let err = catalog
1894 .add(bad, &wasm, &engine)
1895 .expect_err("an empty name or version is not a name@version");
1896 assert!(
1897 matches!(err, CatalogError::InvalidNameVersion { .. }),
1898 "{bad:?} gave {err:?}"
1899 );
1900 }
1901 }
1902
1903 #[test]
1904 fn an_identifier_with_more_than_one_at_separator_is_rejected() {
1905 let catalog_dir = tempfile::tempdir().unwrap();
1906 let wasm_dir = tempfile::tempdir().unwrap();
1907 let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1908
1909 let err = Catalog::open(catalog_dir.path())
1910 .add("a@b@c", &wasm, &wasmtime::Engine::default())
1911 .expect_err("two '@' separators is not a name@version");
1912 assert!(
1913 matches!(err, CatalogError::InvalidNameVersion { .. }),
1914 "{err:?}"
1915 );
1916 }
1917
1918 #[test]
1919 fn an_identifier_containing_path_or_whitespace_characters_is_rejected() {
1920 let catalog_dir = tempfile::tempdir().unwrap();
1921 let wasm_dir = tempfile::tempdir().unwrap();
1922 let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1923 let catalog = Catalog::open(catalog_dir.path());
1924 let engine = wasmtime::Engine::default();
1925
1926 for bad in ["../../etc/passwd@1", "with space@1", "name@../../tmp/pwn"] {
1927 let err = catalog
1928 .add(bad, &wasm, &engine)
1929 .expect_err("{bad} must be rejected");
1930 assert!(
1931 matches!(err, CatalogError::InvalidNameVersion { .. }),
1932 "{bad:?} gave {err:?}"
1933 );
1934 }
1935 }
1936
1937 #[test]
1938 fn an_ordinary_name_at_version_still_catalogs() {
1939 let catalog_dir = tempfile::tempdir().unwrap();
1940 let wasm_dir = tempfile::tempdir().unwrap();
1941 let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1942
1943 Catalog::open(catalog_dir.path())
1944 .add(
1945 "echo-summarize@1.2.3-rc.1",
1946 &wasm,
1947 &wasmtime::Engine::default(),
1948 )
1949 .expect("letters, digits, '.', '-' and '_' are all legal");
1950 }
1951
1952 #[test]
1956 fn a_pre_existing_junk_identifier_can_still_be_shown_and_removed() {
1957 let dir = tempfile::tempdir().unwrap();
1958 seed(dir.path(), "no-at-sign", "2026-01-01T00:00:00Z");
1959 let catalog = Catalog::open(dir.path());
1960
1961 catalog
1962 .show("no-at-sign")
1963 .expect("an already-stored key must remain inspectable");
1964 catalog
1965 .rm("no-at-sign")
1966 .expect("an already-stored key must remain removable");
1967 }
1968
1969 #[test]
1970 fn re_adding_a_removed_version_with_the_same_bytes_is_allowed() {
1971 let catalog_dir = tempfile::tempdir().unwrap();
1972 let wasm_dir = tempfile::tempdir().unwrap();
1973 let wasm = distinct_wasm(wasm_dir.path(), "same", 7);
1974 let catalog = Catalog::open(catalog_dir.path());
1975 let engine = wasmtime::Engine::default();
1976
1977 catalog.add("thing@1", &wasm, &engine).unwrap();
1978 catalog.rm("thing@1").unwrap();
1979 catalog
1980 .add("thing@1", &wasm, &engine)
1981 .expect("re-adding identical bytes is an undo of the rm, not a rewrite of history");
1982
1983 assert_eq!(catalog.list().unwrap().len(), 1);
1984 }
1985
1986 #[test]
1990 fn re_adding_a_removed_version_with_different_bytes_is_rejected() {
1991 let catalog_dir = tempfile::tempdir().unwrap();
1992 let wasm_dir = tempfile::tempdir().unwrap();
1993 let original = distinct_wasm(wasm_dir.path(), "original", 1);
1994 let replacement = distinct_wasm(wasm_dir.path(), "replacement", 2);
1995 let catalog = Catalog::open(catalog_dir.path());
1996 let engine = wasmtime::Engine::default();
1997
1998 catalog.add("thing@1", &original, &engine).unwrap();
1999 catalog.rm("thing@1").unwrap();
2000
2001 let err = catalog
2002 .add("thing@1", &replacement, &engine)
2003 .expect_err("rm must not be a way to republish a version with new content");
2004 let CatalogError::RetiredWithDifferentContent {
2005 name_version,
2006 previous_hash,
2007 new_hash,
2008 } = &err
2009 else {
2010 panic!("expected RetiredWithDifferentContent, got {err:?}")
2011 };
2012 assert_eq!(name_version, "thing@1");
2013 assert_ne!(previous_hash, new_hash);
2014 assert!(
2015 catalog.list().unwrap().is_empty(),
2016 "the reject must not add"
2017 );
2018 }
2019
2020 #[test]
2024 fn an_index_written_without_the_retired_field_still_loads() {
2025 let dir = tempfile::tempdir().unwrap();
2026 std::fs::create_dir_all(dir.path()).unwrap();
2027 std::fs::write(
2028 dir.path().join("index.json"),
2029 br#"{"version":1,"entries":{"old@1":{"hash":"sha256:ab","kind":"block","signature":"json -> json","created_at":"2026-01-01T00:00:00Z"}}}"#,
2030 )
2031 .unwrap();
2032
2033 let index = read_index(dir.path()).expect("an index predating `retired` is not corrupt");
2034 assert!(index.entries.contains_key("old@1"));
2035 assert!(index.retired.is_empty());
2036 }
2037
2038 #[test]
2039 fn removing_a_missing_entry_is_not_found_not_a_silent_no_op() {
2040 let dir = tempfile::tempdir().unwrap();
2041 let catalog = Catalog::open(dir.path());
2042 let err = catalog.rm("nothing@1").unwrap_err();
2043 assert!(matches!(err, CatalogError::NotFound { .. }), "{err:?}");
2044 }
2045
2046 #[test]
2047 fn removing_an_entry_leaves_its_blob_on_disk_v1_has_no_garbage_collection() {
2048 let dir = tempfile::tempdir().unwrap();
2049 let hash = write_blob(dir.path(), b"some block bytes").unwrap();
2050 let hex = hash.strip_prefix("sha256:").unwrap();
2051 with_locked_index(dir.path(), |index| {
2052 index.entries.insert(
2053 "a@1".to_string(),
2054 Entry {
2055 hash: hash.clone(),
2056 kind: ArtifactKind::Block,
2057 signature: "json -> json".to_string(),
2058 created_at: "2026-01-01T00:00:00Z".to_string(),
2059 },
2060 );
2061 Ok::<_, CatalogError>(())
2062 })
2063 .unwrap();
2064
2065 let catalog = Catalog::open(dir.path());
2066 catalog.rm("a@1").unwrap();
2067
2068 assert!(
2069 matches!(catalog.show("a@1"), Err(CatalogError::NotFound { .. })),
2070 "rm must actually remove the index entry, not silently no-op"
2071 );
2072 assert!(
2073 dir.path().join("blobs").join(hex).exists(),
2074 "rm is index-only; the blob must remain"
2075 );
2076 }
2077
2078 #[test]
2079 fn list_returns_multiple_entries_sorted_by_name_at_version() {
2080 let dir = tempfile::tempdir().unwrap();
2081 seed(dir.path(), "b@1", "2026-01-01T00:00:00Z");
2082 seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2083 seed(dir.path(), "c@1", "2026-01-01T00:00:00Z");
2084
2085 let catalog = Catalog::open(dir.path());
2086 let names: Vec<String> = catalog
2087 .list()
2088 .unwrap()
2089 .into_iter()
2090 .map(|(name_version, _)| name_version)
2091 .collect();
2092
2093 assert_eq!(
2094 names,
2095 vec!["a@1".to_string(), "b@1".to_string(), "c@1".to_string()]
2096 );
2097 }
2098
2099 #[test]
2100 fn resolve_a_dot_wasm_suffix_is_direct_even_if_the_file_does_not_exist() {
2101 let dir = tempfile::tempdir().unwrap();
2102 let catalog = Catalog::open(dir.path());
2103 let resolved = catalog
2104 .resolve("/nonexistent/block.wasm", ResolutionContext::Interactive)
2105 .unwrap();
2106 assert!(matches!(resolved, Resolved::Direct(_)));
2107 }
2108
2109 #[test]
2110 fn resolve_a_dot_cfbundle_suffix_is_direct_even_if_the_file_does_not_exist() {
2111 let dir = tempfile::tempdir().unwrap();
2112 let catalog = Catalog::open(dir.path());
2113 let resolved = catalog
2114 .resolve(
2115 "/nonexistent/bundle.cfbundle",
2116 ResolutionContext::Interactive,
2117 )
2118 .unwrap();
2119 assert!(matches!(resolved, Resolved::Direct(_)));
2120 }
2121
2122 #[test]
2123 fn resolve_an_existing_filesystem_path_is_direct_no_catalog_lookup() {
2124 let dir = tempfile::tempdir().unwrap();
2125 let real_file = tempfile::NamedTempFile::new().unwrap();
2126 let catalog = Catalog::open(dir.path());
2127 let resolved = catalog
2128 .resolve(
2129 real_file.path().to_str().unwrap(),
2130 ResolutionContext::Interactive,
2131 )
2132 .unwrap();
2133 assert!(matches!(resolved, Resolved::Direct(_)));
2134 }
2135
2136 #[test]
2137 fn resolve_exact_name_at_version_hits_case_sensitively() {
2138 let dir = tempfile::tempdir().unwrap();
2139 seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
2140 let catalog = Catalog::open(dir.path());
2141
2142 assert!(catalog
2143 .resolve("summarize@1", ResolutionContext::Interactive)
2144 .is_ok());
2145
2146 let err = catalog
2147 .resolve("Summarize@1", ResolutionContext::Interactive)
2148 .unwrap_err();
2149 let CatalogError::NotFound { did_you_mean, .. } = &err else {
2150 panic!("expected NotFound (case-sensitive miss), got {err:?}")
2151 };
2152 assert!(
2153 did_you_mean.contains(&"summarize@1".to_string()),
2154 "case-sensitivity rejects the hit, but edit distance 1 should still suggest it: {did_you_mean:?}"
2155 );
2156 }
2157
2158 #[test]
2159 fn resolve_unqualified_name_picks_the_latest_by_created_at() {
2160 let dir = tempfile::tempdir().unwrap();
2161 seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2162 seed(dir.path(), "a@2", "2026-06-01T00:00:00Z");
2163 let catalog = Catalog::open(dir.path());
2164
2165 let resolved = catalog
2166 .resolve("a", ResolutionContext::Interactive)
2167 .unwrap();
2168 let Resolved::Cataloged { name_version, .. } = resolved else {
2169 panic!("expected a cataloged resolution")
2170 };
2171 assert_eq!(name_version, "a@2");
2172 }
2173
2174 #[test]
2175 fn resolve_unqualified_name_is_legal_from_an_interactive_context() {
2176 let dir = tempfile::tempdir().unwrap();
2177 seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2178 let catalog = Catalog::open(dir.path());
2179 assert!(catalog.resolve("a", ResolutionContext::Interactive).is_ok());
2180 }
2181
2182 #[test]
2183 fn resolve_unqualified_name_is_rejected_in_a_durable_context() {
2184 let dir = tempfile::tempdir().unwrap();
2185 seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2186 let catalog = Catalog::open(dir.path());
2187 let err = catalog
2188 .resolve("a", ResolutionContext::Durable)
2189 .unwrap_err();
2190 assert!(
2191 matches!(err, CatalogError::UnqualifiedName { .. }),
2192 "{err:?}"
2193 );
2194 }
2195
2196 #[test]
2197 fn resolve_not_found_suggests_a_close_typo() {
2198 let dir = tempfile::tempdir().unwrap();
2199 seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
2200 let catalog = Catalog::open(dir.path());
2201 let err = catalog
2202 .resolve("summarise@1", ResolutionContext::Interactive)
2203 .unwrap_err();
2204 let CatalogError::NotFound { did_you_mean, .. } = &err else {
2205 panic!("expected NotFound, got {err:?}")
2206 };
2207 assert_eq!(did_you_mean, &vec!["summarize@1".to_string()]);
2208 }
2209
2210 #[test]
2211 fn read_blob_returns_what_add_wrote() {
2212 let dir = tempfile::tempdir().unwrap();
2213 let catalog = Catalog::open(dir.path());
2214 let engine = wasmtime::Engine::default();
2215 let wasm = wat::parse_str("(module)").unwrap();
2216 let path = dir.path().join("m.wasm");
2217 std::fs::write(&path, &wasm).unwrap();
2218
2219 let outcome = catalog.add("m@1", &path, &engine).unwrap();
2220 let entry = catalog.show("m@1").unwrap();
2221
2222 let bytes = catalog.read_blob(&entry).unwrap();
2223 assert_eq!(bytes, wasm);
2224 assert_eq!(outcome.name_version, "m@1");
2225 }
2226
2227 #[test]
2228 fn read_blob_on_a_hand_edited_missing_hash_errors_clearly() {
2229 let dir = tempfile::tempdir().unwrap();
2230 let catalog = Catalog::open(dir.path());
2231 let fake = Entry {
2232 hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
2233 .to_string(),
2234 ..entry_fixture("2026-01-01T00:00:00Z")
2235 };
2236 let err = catalog.read_blob(&fake).unwrap_err();
2237 match err {
2238 CatalogError::Io(ref io_err) => {
2239 assert_eq!(
2240 io_err.kind(),
2241 std::io::ErrorKind::NotFound,
2242 "a well-formed hash with no matching blob file must surface as a plain \
2243 not-found I/O error: {err:?}"
2244 );
2245 }
2246 other => {
2247 panic!("a well-formed but absent hash must be a plain Io(NotFound), not {other:?}")
2248 }
2249 }
2250 }
2251
2252 #[test]
2253 fn read_blob_rejects_a_path_traversal_hash_instead_of_touching_the_filesystem() {
2254 let dir = tempfile::tempdir().unwrap();
2262 std::fs::write(dir.path().join("outside.txt"), b"do not leak this").unwrap();
2265
2266 let catalog = Catalog::open(dir.path());
2267 let traversal = Entry {
2268 hash: "sha256:../outside.txt".to_string(),
2269 ..entry_fixture("2026-01-01T00:00:00Z")
2270 };
2271 let err = catalog.read_blob(&traversal).unwrap_err();
2272 assert!(
2273 matches!(err, CatalogError::MalformedHash { .. }),
2274 "a path-traversal hash must be rejected as MalformedHash before any path is \
2275 constructed, got {err:?}"
2276 );
2277
2278 let absolute = Entry {
2279 hash: "sha256:/etc/passwd".to_string(),
2280 ..entry_fixture("2026-01-01T00:00:00Z")
2281 };
2282 let err = catalog.read_blob(&absolute).unwrap_err();
2283 assert!(
2284 matches!(err, CatalogError::MalformedHash { .. }),
2285 "an absolute-path-like hash must be rejected as MalformedHash before any path is \
2286 constructed, got {err:?}"
2287 );
2288 }
2289
2290 #[test]
2291 fn a_simple_lowercase_name_is_valid() {
2292 assert!(validate_block_name("my-block").is_ok());
2293 }
2294
2295 #[test]
2296 fn a_name_with_a_dot_is_rejected() {
2297 let err = validate_block_name("my.block").unwrap_err();
2298 assert!(err.to_string().contains('.'), "{err}");
2299 }
2300
2301 #[test]
2302 fn a_name_starting_with_a_digit_is_rejected() {
2303 assert!(validate_block_name("1block").is_err());
2304 }
2305
2306 #[test]
2307 fn a_windows_reserved_device_name_is_rejected_case_insensitively() {
2308 for bad in ["con", "CON", "Con", "aux", "nul", "com1", "lpt9"] {
2309 assert!(
2310 validate_block_name(bad).is_err(),
2311 "{bad} should be rejected"
2312 );
2313 }
2314 }
2315
2316 #[test]
2317 fn a_name_that_only_resembles_a_reserved_name_is_accepted() {
2318 assert!(validate_block_name("console").is_ok());
2319 assert!(validate_block_name("commander").is_ok());
2320 }
2321}