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)]
36#[serde(rename_all = "snake_case")]
37pub enum ArtifactKind {
38 Block,
40 Bundle,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct Entry {
47 pub hash: String,
49 pub kind: ArtifactKind,
51 pub signature: String,
55 pub created_at: String,
60}
61
62#[derive(Debug, Serialize, Deserialize)]
64struct IndexFile {
65 version: u32,
66 entries: BTreeMap<String, Entry>,
67 #[serde(default)]
77 retired: BTreeMap<String, String>,
78}
79
80impl IndexFile {
81 fn empty() -> Self {
82 Self {
83 version: INDEX_VERSION,
84 entries: BTreeMap::new(),
85 retired: BTreeMap::new(),
86 }
87 }
88}
89
90fn is_legal_identifier_char(c: char) -> bool {
95 c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')
96}
97
98fn validate_name_version(s: &str) -> Result<(), CatalogError> {
105 let invalid = |reason: String| CatalogError::InvalidNameVersion {
106 name_version: s.to_string(),
107 reason,
108 };
109
110 let separators = s.matches('@').count();
111 if separators != 1 {
112 return Err(invalid(match separators {
113 0 => "expected <name>@<version>, e.g. echo-summarize@1".to_string(),
114 n => format!("found {n} '@' separators"),
115 }));
116 }
117
118 let (name, version) = s.split_once('@').expect("exactly one '@' is present");
119 if name.is_empty() {
120 return Err(invalid("the name is empty".to_string()));
121 }
122 if version.is_empty() {
123 return Err(invalid("the version is empty".to_string()));
124 }
125
126 for (half, label) in [(name, "name"), (version, "version")] {
127 if let Some(bad) = half.chars().find(|c| !is_legal_identifier_char(*c)) {
128 return Err(invalid(format!(
129 "the {label} contains {bad:?}; only letters, digits, '.', '-' and '_' are allowed"
130 )));
131 }
132 }
133
134 Ok(())
135}
136
137#[derive(Debug, thiserror::Error)]
139pub enum CatalogError {
140 #[error("{name_version} is already catalogued; versions are immutable once published")]
142 AlreadyExists {
143 name_version: String,
145 },
146 #[error("{name_version:?} is not a name@version ({reason})")]
148 InvalidNameVersion {
149 name_version: String,
151 reason: String,
153 },
154 #[error(
159 "{name_version} was previously catalogued with different content; versions are \
160 immutable once published ({previous_hash} -> {new_hash})"
161 )]
162 RetiredWithDifferentContent {
163 name_version: String,
165 previous_hash: String,
167 new_hash: String,
169 },
170 #[error(
172 "no such catalog entry: {name_version}{}",
173 format_did_you_mean(did_you_mean)
174 )]
175 NotFound {
176 name_version: String,
178 did_you_mean: Vec<String>,
181 },
182 #[error("{name} has no version — an exact name@version is required here")]
186 UnqualifiedName {
187 name: String,
189 },
190 #[error("catalog index at {path} is corrupt: {reason}")]
192 CorruptIndex {
193 path: PathBuf,
195 reason: String,
197 },
198 #[error("{path}: not a recognised artifact (header: {header:02x?})")]
200 UnrecognizedArtifact {
201 path: PathBuf,
203 header: Vec<u8>,
205 },
206 #[error("{path}: {reason}")]
214 UninspectableArtifact {
215 path: PathBuf,
223 reason: String,
225 },
226 #[error(
237 "catalog entry has a malformed hash {hash:?}: expected sha256:<64 lowercase hex digits>"
238 )]
239 MalformedHash {
240 hash: String,
242 },
243 #[error(transparent)]
246 Io(#[from] std::io::Error),
247}
248
249fn format_did_you_mean(names: &[String]) -> String {
252 if names.is_empty() {
253 String::new()
254 } else {
255 format!(" (did you mean: {}?)", names.join(", "))
256 }
257}
258
259fn levenshtein(a: &str, b: &str) -> usize {
261 let a: Vec<char> = a.chars().collect();
262 let b: Vec<char> = b.chars().collect();
263 let mut prev: Vec<usize> = (0..=b.len()).collect();
264 let mut curr = vec![0usize; b.len() + 1];
265
266 for i in 1..=a.len() {
267 curr[0] = i;
268 for j in 1..=b.len() {
269 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
270 curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
271 }
272 std::mem::swap(&mut prev, &mut curr);
273 }
274 prev[b.len()]
275}
276
277fn pick_did_you_mean(target_name: &str, entries: &BTreeMap<String, Entry>) -> Vec<String> {
283 let target_name = target_name.split('@').next().unwrap_or(target_name);
284 const MAX_DISTANCE: usize = 2;
285 const LIMIT: usize = 5;
286
287 let mut by_name: BTreeMap<&str, (&str, &str)> = BTreeMap::new();
289 for (name_version, entry) in entries {
290 let name = name_version.split('@').next().unwrap_or(name_version);
291 if levenshtein(target_name, name) > MAX_DISTANCE {
292 continue;
293 }
294 by_name
295 .entry(name)
296 .and_modify(|(nv, created)| {
297 if entry.created_at.as_str() > *created {
298 *nv = name_version;
299 *created = entry.created_at.as_str();
300 }
301 })
302 .or_insert((name_version, entry.created_at.as_str()));
303 }
304
305 let mut candidates: Vec<(usize, &str, &str)> = by_name
306 .into_iter()
307 .map(|(name, (nv, created))| (levenshtein(target_name, name), nv, created))
308 .collect();
309 candidates.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.2.cmp(b.2)));
310
311 candidates
312 .into_iter()
313 .take(LIMIT)
314 .map(|(_, nv, _)| nv.to_string())
315 .collect()
316}
317
318const WASM_MAGIC: &[u8] = b"\0asm";
319pub(crate) const BUNDLE_MAGIC: &[u8; 4] = b"CFBD";
324pub(crate) const BUNDLE_HEADER_LEN: usize = BUNDLE_MAGIC.len() + 8;
328
329pub(crate) fn sniff_artifact_kind(bytes: &[u8]) -> Option<ArtifactKind> {
334 if bytes.starts_with(WASM_MAGIC) {
335 Some(ArtifactKind::Block)
336 } else if bytes.starts_with(BUNDLE_MAGIC) {
337 Some(ArtifactKind::Bundle)
338 } else {
339 None
340 }
341}
342
343fn index_path(root: &Path) -> PathBuf {
344 root.join("index.json")
345}
346
347fn lock_path(root: &Path) -> PathBuf {
348 root.join("index.json.lock")
349}
350
351fn read_index(root: &Path) -> Result<IndexFile, CatalogError> {
358 let path = index_path(root);
359 if !path.exists() {
360 return Ok(IndexFile::empty());
361 }
362
363 let bytes = fs::read(&path)?;
364 let index: IndexFile =
365 serde_json::from_slice(&bytes).map_err(|e| CatalogError::CorruptIndex {
366 path: path.clone(),
367 reason: e.to_string(),
368 })?;
369
370 if index.version != INDEX_VERSION {
371 return Err(CatalogError::CorruptIndex {
372 path,
373 reason: format!(
374 "index format version {} is not supported by this build (expected {INDEX_VERSION})",
375 index.version
376 ),
377 });
378 }
379
380 Ok(index)
381}
382
383fn with_locked_index<T>(
394 root: &Path,
395 f: impl FnOnce(&mut IndexFile) -> Result<T, CatalogError>,
396) -> Result<T, CatalogError> {
397 fs::create_dir_all(root)?;
398 let lock_file = File::options()
399 .create(true)
400 .truncate(false)
401 .write(true)
402 .open(lock_path(root))?;
403 lock_file.lock()?;
404
405 let mut index = read_index(root)?;
406 let result = f(&mut index)?;
407
408 let tmp_path = root.join("index.json.tmp");
409 let bytes = serde_json::to_vec_pretty(&index).expect("IndexFile always serializes");
410 {
411 let mut tmp = File::create(&tmp_path)?;
412 tmp.write_all(&bytes)?;
413 tmp.sync_all()?;
414 }
415 fs::rename(&tmp_path, index_path(root))?;
416
417 Ok(result)
418}
419
420fn blobs_dir(root: &Path) -> PathBuf {
421 root.join("blobs")
422}
423
424fn is_well_formed_sha256_hex(hex: &str) -> bool {
431 hex.len() == 64
432 && hex
433 .bytes()
434 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
435}
436
437pub(crate) fn read_bundle_signature(bytes: &[u8], label: &str) -> Result<String, CatalogError> {
449 if bytes.len() < BUNDLE_HEADER_LEN {
450 return Err(CatalogError::UninspectableArtifact {
451 path: PathBuf::from(label),
452 reason: "shorter than the bundle header".to_string(),
453 });
454 }
455
456 let manifest_len =
457 u64::from_le_bytes(bytes[4..BUNDLE_HEADER_LEN].try_into().expect("8 bytes")) as usize;
458 let manifest_bytes = BUNDLE_HEADER_LEN
459 .checked_add(manifest_len)
460 .and_then(|end| bytes.get(BUNDLE_HEADER_LEN..end))
461 .ok_or_else(|| CatalogError::UninspectableArtifact {
462 path: PathBuf::from(label),
463 reason: format!("manifest_len {manifest_len} exceeds the file's actual length"),
464 })?;
465
466 let manifest: serde_json::Value = serde_json::from_slice(manifest_bytes).map_err(|e| {
467 CatalogError::UninspectableArtifact {
468 path: PathBuf::from(label),
469 reason: format!("manifest is not valid JSON: {e}"),
470 }
471 })?;
472
473 if let Some(nodes) = manifest.get("nodes").and_then(|v| v.as_array()) {
482 let body_len = (bytes.len() - BUNDLE_HEADER_LEN - manifest_len) as u64;
483 for node in nodes {
484 let bounds = node
485 .get("offset")
486 .and_then(|v| v.as_u64())
487 .zip(node.get("len").and_then(|v| v.as_u64()));
488 let in_bounds = match bounds {
489 Some((offset, len)) => offset.checked_add(len).is_some_and(|end| end <= body_len),
490 None => false,
491 };
492 if !in_bounds {
493 return Err(CatalogError::UninspectableArtifact {
494 path: PathBuf::from(label),
495 reason: format!(
496 "a node's offset/len ({:?}) doesn't fit within the bundle's \
497 {body_len}-byte stage-bytes region",
498 (
499 node.get("offset").and_then(|v| v.as_u64()),
500 node.get("len").and_then(|v| v.as_u64())
501 )
502 ),
503 });
504 }
505 }
506 }
507
508 manifest
509 .get("signature")
510 .and_then(|v| v.as_str())
511 .map(str::to_string)
512 .ok_or_else(|| CatalogError::UninspectableArtifact {
513 path: PathBuf::from(label),
514 reason: "manifest has no string field \"signature\"".to_string(),
515 })
516}
517
518fn write_blob(root: &Path, bytes: &[u8]) -> Result<String, CatalogError> {
524 use sha2::{Digest, Sha256};
525 use std::sync::atomic::{AtomicU64, Ordering};
526
527 let hex = format!("{:x}", Sha256::digest(bytes));
528 let dir = blobs_dir(root);
529 fs::create_dir_all(&dir)?;
530
531 let blob_path = dir.join(&hex);
532 if !blob_path.exists() {
533 static COUNTER: AtomicU64 = AtomicU64::new(0);
540 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
541 let tmp_path = dir.join(format!("{hex}.tmp.{}.{unique}", std::process::id()));
542 {
543 let mut tmp = File::create(&tmp_path)?;
544 tmp.write_all(bytes)?;
545 tmp.sync_all()?;
546 }
547 fs::rename(&tmp_path, &blob_path)?;
548 }
549
550 Ok(format!("sha256:{hex}"))
551}
552
553pub struct Catalog {
557 root: PathBuf,
558}
559
560#[derive(Debug, Clone)]
562pub struct AddOutcome {
563 pub name_version: String,
565 pub kind: ArtifactKind,
567 pub signature: String,
569 pub is_permissive_default: bool,
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583pub enum ResolutionContext {
584 Interactive,
589 Durable,
592}
593
594#[derive(Debug, Clone)]
596pub enum Resolved {
597 Direct(PathBuf),
600 Cataloged {
602 name_version: String,
605 entry: Entry,
607 },
608}
609
610impl Catalog {
611 pub fn open(root: impl Into<PathBuf>) -> Self {
613 Self { root: root.into() }
614 }
615
616 pub fn add(
623 &self,
624 name_version: &str,
625 artifact_path: &Path,
626 engine: &wasmtime::Engine,
627 ) -> Result<AddOutcome, CatalogError> {
628 validate_name_version(name_version)?;
629
630 let bytes = fs::read(artifact_path)?;
631 let kind =
632 sniff_artifact_kind(&bytes).ok_or_else(|| CatalogError::UnrecognizedArtifact {
633 path: artifact_path.to_path_buf(),
634 header: bytes.iter().take(8).copied().collect(),
635 })?;
636
637 let (signature, is_permissive_default) = match kind {
638 ArtifactKind::Block => {
639 let sig = crate::runner::read_signature(engine, &bytes).map_err(|e| {
640 CatalogError::UninspectableArtifact {
641 path: artifact_path.to_path_buf(),
642 reason: format!("{e:#}"),
643 }
644 })?;
645 let permissive = cuttlefish_abi::Signature {
646 input: cuttlefish_abi::Ty::Json,
647 output: cuttlefish_abi::Ty::Json,
648 };
649 let is_permissive = sig == permissive;
650 (sig.to_string(), is_permissive)
651 }
652 ArtifactKind::Bundle => {
653 let sig = read_bundle_signature(&bytes, &artifact_path.to_string_lossy())?;
654 (sig, false)
655 }
656 };
657
658 let hash = write_blob(&self.root, &bytes)?;
659 let created_at = now_rfc3339();
660 let name_version = name_version.to_string();
661
662 with_locked_index(&self.root, |index| {
663 if index.entries.contains_key(&name_version) {
664 return Err(CatalogError::AlreadyExists {
665 name_version: name_version.clone(),
666 });
667 }
668 if let Some(previous_hash) = index.retired.get(&name_version) {
673 if previous_hash != &hash {
674 return Err(CatalogError::RetiredWithDifferentContent {
675 name_version: name_version.clone(),
676 previous_hash: previous_hash.clone(),
677 new_hash: hash.clone(),
678 });
679 }
680 index.retired.remove(&name_version);
681 }
682 index.entries.insert(
683 name_version.clone(),
684 Entry {
685 hash,
686 kind,
687 signature: signature.clone(),
688 created_at,
689 },
690 );
691 Ok(())
692 })?;
693
694 Ok(AddOutcome {
695 name_version,
696 kind,
697 signature,
698 is_permissive_default,
699 })
700 }
701
702 pub fn list(&self) -> Result<Vec<(String, Entry)>, CatalogError> {
705 let index = read_index(&self.root)?;
706 Ok(index.entries.into_iter().collect())
707 }
708
709 pub fn show(&self, name_version: &str) -> Result<Entry, CatalogError> {
713 let index = read_index(&self.root)?;
714 index.entries.get(name_version).cloned().ok_or_else(|| {
715 let name = name_version.split('@').next().unwrap_or(name_version);
716 CatalogError::NotFound {
717 name_version: name_version.to_string(),
718 did_you_mean: pick_did_you_mean(name, &index.entries),
719 }
720 })
721 }
722
723 pub fn read_blob(&self, entry: &Entry) -> Result<Vec<u8>, CatalogError> {
728 let hex = entry.hash.strip_prefix("sha256:").unwrap_or(&entry.hash);
729 if !is_well_formed_sha256_hex(hex) {
730 return Err(CatalogError::MalformedHash {
731 hash: entry.hash.clone(),
732 });
733 }
734 Ok(fs::read(blobs_dir(&self.root).join(hex))?)
735 }
736
737 pub fn rm(&self, name_version: &str) -> Result<(), CatalogError> {
741 with_locked_index(&self.root, |index| {
742 if let Some(entry) = index.entries.remove(name_version) {
743 index
746 .retired
747 .insert(name_version.to_string(), entry.hash.clone());
748 Ok(())
749 } else {
750 let name = name_version.split('@').next().unwrap_or(name_version);
751 Err(CatalogError::NotFound {
752 name_version: name_version.to_string(),
753 did_you_mean: pick_did_you_mean(name, &index.entries),
754 })
755 }
756 })
757 }
758
759 pub fn resolve(&self, s: &str, context: ResolutionContext) -> Result<Resolved, CatalogError> {
773 if s.ends_with(".wasm") || s.ends_with(".cfbundle") || Path::new(s).exists() {
774 return Ok(Resolved::Direct(PathBuf::from(s)));
775 }
776
777 let index = read_index(&self.root)?;
778
779 if let Some((name, version)) = s.rsplit_once('@') {
780 let name_version = format!("{name}@{version}");
781 let entry = index.entries.get(&name_version).cloned().ok_or_else(|| {
782 CatalogError::NotFound {
783 name_version: name_version.clone(),
784 did_you_mean: pick_did_you_mean(name, &index.entries),
785 }
786 })?;
787 return Ok(Resolved::Cataloged {
788 name_version,
789 entry,
790 });
791 }
792
793 if context == ResolutionContext::Durable {
794 return Err(CatalogError::UnqualifiedName {
795 name: s.to_string(),
796 });
797 }
798
799 let mut versions: Vec<(&String, &Entry)> = index
800 .entries
801 .iter()
802 .filter(|(nv, _)| nv.rsplit_once('@').map(|(n, _)| n) == Some(s))
803 .collect();
804 versions.sort_by(|a, b| a.1.created_at.cmp(&b.1.created_at));
805
806 let (name_version, entry) =
807 versions
808 .last()
809 .copied()
810 .ok_or_else(|| CatalogError::NotFound {
811 name_version: s.to_string(),
812 did_you_mean: pick_did_you_mean(s, &index.entries),
813 })?;
814
815 Ok(Resolved::Cataloged {
816 name_version: name_version.clone(),
817 entry: entry.clone(),
818 })
819 }
820}
821
822pub(crate) fn cuttlefish_home() -> Option<PathBuf> {
829 if let Ok(home) = std::env::var("CUTTLEFISH_HOME") {
830 return Some(PathBuf::from(home));
831 }
832 dirs::home_dir().map(|home| home.join(".cuttlefish"))
833}
834
835pub fn default_root() -> Option<PathBuf> {
839 cuttlefish_home().map(|h| h.join("catalog"))
840}
841
842pub(crate) fn now_rfc3339() -> String {
848 let now = time::OffsetDateTime::now_utc()
849 .replace_nanosecond(0)
850 .expect("0 is always a valid nanosecond value");
851 now.format(&time::format_description::well_known::Rfc3339)
852 .expect("Rfc3339 formatting cannot fail for a valid OffsetDateTime")
853}
854
855#[cfg(test)]
856mod tests {
857 use super::*;
858 use std::sync::atomic::{AtomicBool, Ordering};
859 use std::sync::{Arc, Barrier};
860
861 const WRITERS: usize = 16;
864
865 #[test]
866 fn default_root_honors_cuttlefish_home() {
867 std::env::set_var("CUTTLEFISH_HOME", "/tmp/cf-test-home");
873 let root = default_root();
874 std::env::remove_var("CUTTLEFISH_HOME");
875 assert_eq!(root, Some(PathBuf::from("/tmp/cf-test-home/catalog")));
876 }
877
878 #[test]
879 fn index_file_serializes_to_the_shape_the_spec_documents() {
880 let mut entries = BTreeMap::new();
881 entries.insert(
882 "chunk-text@1".to_string(),
883 Entry {
884 hash: "sha256:9f86d081".to_string(),
885 kind: ArtifactKind::Block,
886 signature: "{path: text} -> [text]".to_string(),
887 created_at: "2026-08-02T18:03:00Z".to_string(),
888 },
889 );
890 let index = IndexFile {
891 version: INDEX_VERSION,
892 entries,
893 retired: BTreeMap::new(),
894 };
895
896 let json = serde_json::to_string(&index).expect("IndexFile always serializes");
897 let parsed: serde_json::Value =
898 serde_json::from_str(&json).expect("what we just wrote must parse");
899
900 assert_eq!(parsed["version"], 1);
901 assert_eq!(parsed["entries"]["chunk-text@1"]["kind"], "block");
902 assert_eq!(
903 parsed["entries"]["chunk-text@1"]["signature"],
904 "{path: text} -> [text]"
905 );
906
907 let round_tripped: IndexFile =
908 serde_json::from_str(&json).expect("must deserialize what we just serialized");
909 assert_eq!(round_tripped.version, INDEX_VERSION);
910 assert!(round_tripped.entries.contains_key("chunk-text@1"));
911 }
912
913 #[test]
914 fn not_found_with_suggestions_reads_as_one_sentence() {
915 let err = CatalogError::NotFound {
916 name_version: "summarise@1".to_string(),
917 did_you_mean: vec!["summarize@1".to_string()],
918 };
919 assert_eq!(
920 err.to_string(),
921 "no such catalog entry: summarise@1 (did you mean: summarize@1?)"
922 );
923 }
924
925 #[test]
926 fn not_found_with_no_suggestions_has_no_dangling_parenthetical() {
927 let err = CatalogError::NotFound {
928 name_version: "xyz@1".to_string(),
929 did_you_mean: vec![],
930 };
931 assert_eq!(err.to_string(), "no such catalog entry: xyz@1");
932 }
933
934 fn entry_fixture(created_at: &str) -> Entry {
935 Entry {
936 hash: "sha256:deadbeef".to_string(),
937 kind: ArtifactKind::Block,
938 signature: "json -> json".to_string(),
939 created_at: created_at.to_string(),
940 }
941 }
942
943 fn seed(root: &Path, name_version: &str, created_at: &str) {
944 with_locked_index(root, |index| {
945 index
946 .entries
947 .insert(name_version.to_string(), entry_fixture(created_at));
948 Ok::<_, CatalogError>(())
949 })
950 .unwrap();
951 }
952
953 #[test]
954 fn levenshtein_matches_known_distances() {
955 assert_eq!(levenshtein("kitten", "sitting"), 3);
956 assert_eq!(levenshtein("summarize", "summarise"), 1);
957 assert_eq!(levenshtein("same", "same"), 0);
958 }
959
960 #[test]
961 fn did_you_mean_catches_a_one_character_typo_a_prefix_match_would_miss() {
962 let mut entries = BTreeMap::new();
966 entries.insert(
967 "summarize@1".to_string(),
968 entry_fixture("2026-01-01T00:00:00Z"),
969 );
970 assert_eq!(
971 pick_did_you_mean("summarise", &entries),
972 vec!["summarize@1".to_string()]
973 );
974 }
975
976 #[test]
977 fn did_you_mean_is_empty_when_nothing_registered_is_close() {
978 let mut entries = BTreeMap::new();
979 entries.insert(
980 "summarize@1".to_string(),
981 entry_fixture("2026-01-01T00:00:00Z"),
982 );
983 assert!(pick_did_you_mean("completely-unrelated-name", &entries).is_empty());
984 }
985
986 #[test]
987 fn did_you_mean_is_capped_at_five_closest_ordered_by_distance() {
988 let mut entries = BTreeMap::new();
989 for (i, name) in ["bat", "cot", "car", "cap", "can", "cad"]
992 .iter()
993 .enumerate()
994 {
995 entries.insert(
996 format!("{name}@1"),
997 entry_fixture(&format!("2026-01-0{}T00:00:00Z", i + 1)),
998 );
999 }
1000 let suggestions = pick_did_you_mean("cat", &entries);
1001 assert_eq!(suggestions.len(), 5, "capped at 5: {suggestions:?}");
1002 }
1003
1004 #[test]
1005 fn did_you_mean_suggests_the_newest_version_when_multiple_versions_of_a_close_name_exist() {
1006 let mut entries = BTreeMap::new();
1007 entries.insert(
1008 "summarize@1".to_string(),
1009 entry_fixture("2026-01-01T00:00:00Z"),
1010 );
1011 entries.insert(
1012 "summarize@2".to_string(),
1013 entry_fixture("2026-06-01T00:00:00Z"),
1014 );
1015 assert_eq!(
1016 pick_did_you_mean("summarise", &entries),
1017 vec!["summarize@2".to_string()],
1018 "must suggest the newest version of a matching name, not every version"
1019 );
1020 }
1021
1022 #[test]
1023 fn wasm_magic_bytes_sniff_as_a_block() {
1024 assert_eq!(
1025 sniff_artifact_kind(b"\0asm\x01\x00\x00\x00"),
1026 Some(ArtifactKind::Block)
1027 );
1028 }
1029
1030 #[test]
1031 fn bundle_magic_bytes_sniff_as_a_bundle() {
1032 assert_eq!(
1033 sniff_artifact_kind(b"CFBD\x00\x00\x00\x00\x00\x00\x00\x00"),
1034 Some(ArtifactKind::Bundle)
1035 );
1036 }
1037
1038 #[test]
1039 fn unrecognised_bytes_sniff_to_none_not_a_guess() {
1040 assert_eq!(sniff_artifact_kind(b"whatever-this-is"), None);
1041 }
1042
1043 #[test]
1044 fn writing_then_reading_the_index_round_trips_through_disk() {
1045 let dir = tempfile::tempdir().unwrap();
1046 with_locked_index(dir.path(), |index| {
1047 index
1048 .entries
1049 .insert("a@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1050 Ok::<_, CatalogError>(())
1051 })
1052 .unwrap();
1053
1054 let index = read_index(dir.path()).unwrap();
1055 assert!(index.entries.contains_key("a@1"));
1056 }
1057
1058 #[test]
1059 fn reading_an_index_that_does_not_exist_yet_is_an_empty_catalog_not_an_error() {
1060 let dir = tempfile::tempdir().unwrap();
1061 let index = read_index(dir.path()).expect("no index.json yet is not corruption");
1062 assert!(index.entries.is_empty());
1063 }
1064
1065 #[test]
1066 fn a_truncated_index_is_a_corrupt_index_error_not_an_empty_catalog() {
1067 let dir = tempfile::tempdir().unwrap();
1068 std::fs::create_dir_all(dir.path()).unwrap();
1069 std::fs::write(dir.path().join("index.json"), b"{\"version\": 1, \"ent").unwrap();
1070
1071 let err = read_index(dir.path()).unwrap_err();
1072 assert!(
1073 matches!(err, CatalogError::CorruptIndex { .. }),
1074 "a truncated index must be a loud CorruptIndex, not treated as empty: {err:?}"
1075 );
1076 }
1077
1078 #[test]
1079 fn an_unsupported_index_version_is_a_corrupt_index_error() {
1080 let dir = tempfile::tempdir().unwrap();
1081 std::fs::create_dir_all(dir.path()).unwrap();
1082 std::fs::write(
1083 dir.path().join("index.json"),
1084 br#"{"version": 999, "entries": {}}"#,
1085 )
1086 .unwrap();
1087
1088 let err = read_index(dir.path()).unwrap_err();
1089 assert!(matches!(err, CatalogError::CorruptIndex { .. }), "{err:?}");
1090 }
1091
1092 #[test]
1093 fn concurrent_writes_from_two_threads_both_land_and_the_index_stays_parseable() {
1094 let dir = tempfile::tempdir().unwrap();
1095 let root_a = dir.path().to_path_buf();
1096 let root_b = dir.path().to_path_buf();
1097
1098 let t1 = std::thread::spawn(move || {
1099 with_locked_index(&root_a, |index| {
1100 index
1101 .entries
1102 .insert("a@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1103 Ok::<_, CatalogError>(())
1104 })
1105 .unwrap();
1106 });
1107 let t2 = std::thread::spawn(move || {
1108 with_locked_index(&root_b, |index| {
1109 index
1110 .entries
1111 .insert("b@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1112 Ok::<_, CatalogError>(())
1113 })
1114 .unwrap();
1115 });
1116 t1.join().unwrap();
1117 t2.join().unwrap();
1118
1119 let index = read_index(dir.path()).expect("the index must still parse after contention");
1120 assert!(index.entries.contains_key("a@1"));
1121 assert!(index.entries.contains_key("b@1"));
1122 }
1123
1124 #[test]
1131 fn racing_inserts_of_the_same_key_leave_exactly_one_winner() {
1132 let dir = tempfile::tempdir().unwrap();
1133 let root = dir.path().to_path_buf();
1134 let barrier = Arc::new(Barrier::new(WRITERS));
1135
1136 let handles: Vec<_> = (0..WRITERS)
1137 .map(|_| {
1138 let root = root.clone();
1139 let barrier = barrier.clone();
1140 std::thread::spawn(move || {
1141 barrier.wait();
1142 with_locked_index(&root, |index| {
1143 if index.entries.contains_key("race@1") {
1144 return Err(CatalogError::AlreadyExists {
1145 name_version: "race@1".to_string(),
1146 });
1147 }
1148 index
1149 .entries
1150 .insert("race@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1151 Ok(())
1152 })
1153 })
1154 })
1155 .collect();
1156
1157 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1158
1159 let winners = results.iter().filter(|r| r.is_ok()).count();
1160 assert_eq!(
1161 winners, 1,
1162 "exactly one racing writer may claim a key; got {winners}"
1163 );
1164 assert!(
1165 results
1166 .iter()
1167 .all(|r| r.is_ok() || matches!(r, Err(CatalogError::AlreadyExists { .. }))),
1168 "every loser must lose with AlreadyExists, not an io or corruption error: {results:?}"
1169 );
1170
1171 let index = read_index(&root).expect("the index must still parse after contention");
1172 assert_eq!(index.entries.len(), 1);
1173 }
1174
1175 #[test]
1180 fn many_racing_writers_of_distinct_keys_all_land_with_no_lost_updates() {
1181 let dir = tempfile::tempdir().unwrap();
1182 let root = dir.path().to_path_buf();
1183 let barrier = Arc::new(Barrier::new(WRITERS));
1184
1185 let handles: Vec<_> = (0..WRITERS)
1186 .map(|w| {
1187 let root = root.clone();
1188 let barrier = barrier.clone();
1189 std::thread::spawn(move || {
1190 barrier.wait();
1191 with_locked_index(&root, |index| {
1192 index.entries.insert(
1193 format!("writer-{w}@1"),
1194 entry_fixture("2026-01-01T00:00:00Z"),
1195 );
1196 Ok::<_, CatalogError>(())
1197 })
1198 .unwrap();
1199 })
1200 })
1201 .collect();
1202 for h in handles {
1203 h.join().unwrap();
1204 }
1205
1206 let index = read_index(&root).expect("the index must still parse after contention");
1207 assert_eq!(
1208 index.entries.len(),
1209 WRITERS,
1210 "every writer's entry must survive; a lost update means the \
1211 read-modify-write escaped the lock: {:?}",
1212 index.entries.keys().collect::<Vec<_>>()
1213 );
1214 }
1215
1216 #[test]
1222 fn lock_free_readers_never_observe_a_partial_index_while_writers_hammer() {
1223 let dir = tempfile::tempdir().unwrap();
1224 let root = dir.path().to_path_buf();
1225 seed(&root, "seed@1", "2026-01-01T00:00:00Z");
1228
1229 let stop = Arc::new(AtomicBool::new(false));
1230
1231 let writers: Vec<_> = (0..4)
1232 .map(|w| {
1233 let root = root.clone();
1234 std::thread::spawn(move || {
1235 for i in 0..60 {
1236 with_locked_index(&root, |index| {
1237 index.entries.insert(
1238 format!("w{w}-{i}@1"),
1239 entry_fixture("2026-01-01T00:00:00Z"),
1240 );
1241 Ok::<_, CatalogError>(())
1242 })
1243 .unwrap();
1244 }
1245 })
1246 })
1247 .collect();
1248
1249 let readers: Vec<_> = (0..4)
1250 .map(|_| {
1251 let root = root.clone();
1252 let stop = stop.clone();
1253 std::thread::spawn(move || {
1254 let mut reads = 0u32;
1255 while !stop.load(Ordering::Relaxed) {
1256 let index = read_index(&root)
1257 .expect("a lock-free reader must never see a partial or corrupt index");
1258 assert!(
1261 index.entries.contains_key("seed@1"),
1262 "an entry that is never removed vanished from a concurrent read"
1263 );
1264 reads += 1;
1265 }
1266 reads
1267 })
1268 })
1269 .collect();
1270
1271 for w in writers {
1272 w.join().unwrap();
1273 }
1274 stop.store(true, Ordering::Relaxed);
1275
1276 let total: u32 = readers.into_iter().map(|r| r.join().unwrap()).sum();
1277 assert!(
1278 total > 0,
1279 "the readers must have actually observed the index"
1280 );
1281 }
1282
1283 #[test]
1290 fn adds_and_removals_racing_on_one_index_leave_exactly_the_expected_entries() {
1291 let dir = tempfile::tempdir().unwrap();
1292 let root = dir.path().to_path_buf();
1293 seed(&root, "keep@1", "2026-01-01T00:00:00Z");
1294 seed(&root, "keep@2", "2026-01-01T00:00:00Z");
1295
1296 let barrier = Arc::new(Barrier::new(WRITERS));
1297 let handles: Vec<_> = (0..WRITERS)
1298 .map(|w| {
1299 let root = root.clone();
1300 let barrier = barrier.clone();
1301 std::thread::spawn(move || {
1302 let key = format!("churn-{w}@1");
1303 barrier.wait();
1304 for _ in 0..10 {
1305 with_locked_index(&root, |index| {
1306 index
1307 .entries
1308 .insert(key.clone(), entry_fixture("2026-01-01T00:00:00Z"));
1309 Ok::<_, CatalogError>(())
1310 })
1311 .unwrap();
1312 with_locked_index(&root, |index| {
1313 index.entries.remove(&key).expect(
1314 "a key only this thread ever touches must still be present",
1315 );
1316 Ok::<_, CatalogError>(())
1317 })
1318 .unwrap();
1319 }
1320 })
1321 })
1322 .collect();
1323 for h in handles {
1324 h.join().unwrap();
1325 }
1326
1327 let index = read_index(&root).expect("the index must still parse after mixed contention");
1328 let names: Vec<_> = index.entries.keys().cloned().collect();
1329 assert_eq!(
1330 names,
1331 vec!["keep@1".to_string(), "keep@2".to_string()],
1332 "churn keys must all be gone and the untouched entries must survive"
1333 );
1334 }
1335
1336 #[test]
1337 fn identical_bytes_under_two_writes_produce_exactly_one_blob_file() {
1338 let dir = tempfile::tempdir().unwrap();
1339 let hash1 = write_blob(dir.path(), b"hello world").unwrap();
1340 let hash2 = write_blob(dir.path(), b"hello world").unwrap();
1341
1342 assert_eq!(hash1, hash2);
1343 assert!(hash1.starts_with("sha256:"));
1344
1345 let blob_count = std::fs::read_dir(dir.path().join("blobs")).unwrap().count();
1346 assert_eq!(
1347 blob_count, 1,
1348 "identical bytes must dedupe to a single blob file"
1349 );
1350 }
1351
1352 #[test]
1353 fn the_blob_filename_on_disk_is_bare_hex_no_prefix() {
1354 let dir = tempfile::tempdir().unwrap();
1355 let hash = write_blob(dir.path(), b"hello world").unwrap();
1356 let hex = hash
1357 .strip_prefix("sha256:")
1358 .expect("index field is prefixed");
1359
1360 assert!(dir.path().join("blobs").join(hex).exists());
1361 }
1362
1363 #[test]
1364 fn many_concurrent_writers_of_identical_bytes_never_corrupt_the_blob() {
1365 let dir = tempfile::tempdir().unwrap();
1366 let root = dir.path().to_path_buf();
1367 let content = b"identical content raced by many concurrent writers";
1368
1369 let handles: Vec<_> = (0..16)
1370 .map(|_| {
1371 let root = root.clone();
1372 std::thread::spawn(move || write_blob(&root, content).unwrap())
1373 })
1374 .collect();
1375
1376 let hashes: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1377 assert!(
1378 hashes.iter().all(|h| h == &hashes[0]),
1379 "every writer must compute and report the same hash: {hashes:?}"
1380 );
1381
1382 let hex = hashes[0].strip_prefix("sha256:").unwrap();
1383 let blob_bytes = std::fs::read(root.join("blobs").join(hex)).unwrap();
1384 assert_eq!(
1385 blob_bytes, content,
1386 "the published blob must be exactly the input bytes, not truncated or corrupted by a racing writer"
1387 );
1388 }
1389
1390 fn make_bundle(manifest_json: &[u8]) -> Vec<u8> {
1391 let mut bytes = b"CFBD".to_vec();
1392 bytes.extend_from_slice(&(manifest_json.len() as u64).to_le_bytes());
1393 bytes.extend_from_slice(manifest_json);
1394 bytes
1395 }
1396
1397 #[test]
1398 fn reads_the_signature_field_out_of_a_valid_bundle_manifest() {
1399 let bundle = make_bundle(
1400 br#"{"nodes":[],"edges":[],"signature":"{path: text} -> {summary: text}"}"#,
1401 );
1402 let sig = read_bundle_signature(&bundle, "test.cfbundle").unwrap();
1403 assert_eq!(sig, "{path: text} -> {summary: text}");
1404 }
1405
1406 #[test]
1407 fn a_manifest_len_exceeding_the_actual_bytes_is_uninspectable() {
1408 let mut bundle = make_bundle(br#"{"nodes":[],"edges":[],"signature":"x -> x"}"#);
1409 bundle.truncate(bundle.len() - 5); let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1411 match err {
1412 CatalogError::UninspectableArtifact { reason, .. } => assert!(
1413 reason.contains("exceeds the file's actual length"),
1414 "{reason}"
1415 ),
1416 other => panic!("expected UninspectableArtifact, got {other:?}"),
1417 }
1418 }
1419
1420 #[test]
1421 fn invalid_manifest_json_is_uninspectable() {
1422 let bundle = make_bundle(b"not valid json at all");
1423 let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1424 match err {
1425 CatalogError::UninspectableArtifact { reason, .. } => {
1426 assert!(reason.contains("not valid JSON"), "{reason}")
1427 }
1428 other => panic!("expected UninspectableArtifact, got {other:?}"),
1429 }
1430 }
1431
1432 #[test]
1433 fn a_manifest_missing_the_signature_field_is_uninspectable() {
1434 let bundle = make_bundle(br#"{"nodes":[],"edges":[]}"#);
1435 let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1436 match err {
1437 CatalogError::UninspectableArtifact { reason, .. } => {
1438 assert!(reason.contains("no string field"), "{reason}")
1439 }
1440 other => panic!("expected UninspectableArtifact, got {other:?}"),
1441 }
1442 }
1443
1444 #[test]
1445 fn a_node_whose_offset_and_len_overflow_the_stage_bytes_is_uninspectable() {
1446 let bundle = make_bundle(
1452 br#"{"nodes":[{"name":"bad","kind":"block","resolved":null,
1453 "signature":"json -> json","offset":99999,"len":99999}],
1454 "signature":"json -> json"}"#,
1455 );
1456 let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1457 match err {
1458 CatalogError::UninspectableArtifact { reason, .. } => {
1459 assert!(reason.contains("doesn't fit"), "{reason}")
1460 }
1461 other => panic!("expected UninspectableArtifact, got {other:?}"),
1462 }
1463 }
1464
1465 #[test]
1466 fn a_node_whose_offset_and_len_exactly_fit_the_stage_bytes_is_fine() {
1467 let mut bundle = make_bundle(
1468 br#"{"nodes":[{"name":"ok","kind":"block","resolved":null,
1469 "signature":"json -> json","offset":0,"len":3}],
1470 "signature":"json -> json"}"#,
1471 );
1472 bundle.extend_from_slice(b"abc");
1473 let sig = read_bundle_signature(&bundle, "test.cfbundle").unwrap();
1474 assert_eq!(sig, "json -> json");
1475 }
1476
1477 #[test]
1478 fn an_overflowing_node_offset_plus_len_is_uninspectable_not_a_panic() {
1479 let bundle = make_bundle(
1483 format!(
1484 r#"{{"nodes":[{{"name":"bad","kind":"block","resolved":null,
1485 "signature":"json -> json","offset":{},"len":10}}],
1486 "signature":"json -> json"}}"#,
1487 u64::MAX
1488 )
1489 .as_bytes(),
1490 );
1491 let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1492 assert!(matches!(err, CatalogError::UninspectableArtifact { .. }));
1493 }
1494
1495 #[test]
1496 fn an_overflowing_manifest_len_is_uninspectable_not_a_panic() {
1497 let mut bytes = b"CFBD".to_vec();
1502 bytes.extend_from_slice(&u64::MAX.to_le_bytes());
1503 let err = read_bundle_signature(&bytes, "test.cfbundle").unwrap_err();
1504 match err {
1505 CatalogError::UninspectableArtifact { reason, .. } => {
1506 assert!(
1507 reason.contains("exceeds the file's actual length"),
1508 "{reason}"
1509 )
1510 }
1511 other => panic!("expected UninspectableArtifact, got {other:?}"),
1512 }
1513 }
1514
1515 #[test]
1516 fn a_file_shorter_than_the_header_is_uninspectable() {
1517 let err = read_bundle_signature(b"CFBD", "test.cfbundle").unwrap_err();
1518 match err {
1519 CatalogError::UninspectableArtifact { reason, .. } => {
1520 assert!(
1521 reason.contains("shorter than the bundle header"),
1522 "{reason}"
1523 )
1524 }
1525 other => panic!("expected UninspectableArtifact, got {other:?}"),
1526 }
1527 }
1528
1529 #[test]
1530 fn adding_a_wasm_block_with_no_cf_signature_export_caches_the_permissive_default_and_flags_it()
1531 {
1532 let catalog_dir = tempfile::tempdir().unwrap();
1533 let wasm_dir = tempfile::tempdir().unwrap();
1534 let wasm_path = wasm_dir.path().join("no_sig.wasm");
1535 std::fs::write(
1536 &wasm_path,
1537 wat::parse_str(r#"(module (memory (export "memory") 1))"#).unwrap(),
1538 )
1539 .unwrap();
1540
1541 let catalog = Catalog::open(catalog_dir.path());
1542 let outcome = catalog
1543 .add("no-sig@1", &wasm_path, &wasmtime::Engine::default())
1544 .expect("a block missing cf_signature is not an add-time error");
1545
1546 assert_eq!(outcome.signature, "json -> json");
1547 assert!(
1548 outcome.is_permissive_default,
1549 "a block with no cf_signature export must be flagged, not silently accepted"
1550 );
1551 }
1552
1553 #[test]
1554 fn adding_wasm_magic_bytes_with_an_invalid_module_body_is_uninspectable() {
1555 let catalog_dir = tempfile::tempdir().unwrap();
1556 let wasm_dir = tempfile::tempdir().unwrap();
1557 let wasm_path = wasm_dir.path().join("broken.wasm");
1558 std::fs::write(
1561 &wasm_path,
1562 b"\0asm\x01\x00\x00\x00garbage-not-a-real-module",
1563 )
1564 .unwrap();
1565
1566 let catalog = Catalog::open(catalog_dir.path());
1567 let err = catalog
1568 .add("broken@1", &wasm_path, &wasmtime::Engine::default())
1569 .unwrap_err();
1570 assert!(
1571 matches!(err, CatalogError::UninspectableArtifact { .. }),
1572 "{err:?}"
1573 );
1574 }
1575
1576 #[test]
1577 fn a_cf_signature_export_that_exists_but_returns_unparseable_bytes_is_uninspectable_not_permissive(
1578 ) {
1579 let catalog_dir = tempfile::tempdir().unwrap();
1587 let wasm_dir = tempfile::tempdir().unwrap();
1588 let wasm_path = wasm_dir.path().join("broken_sig.wasm");
1589 std::fs::write(
1590 &wasm_path,
1591 wat::parse_str(
1592 r#"(module
1593 (memory (export "memory") 1)
1594 (func (export "cf_signature") (result i32) i32.const 0)
1595 )"#,
1596 )
1597 .unwrap(),
1598 )
1599 .unwrap();
1600
1601 let catalog = Catalog::open(catalog_dir.path());
1602 let err = catalog
1603 .add("broken-sig@1", &wasm_path, &wasmtime::Engine::default())
1604 .unwrap_err();
1605 assert!(
1606 matches!(err, CatalogError::UninspectableArtifact { .. }),
1607 "present-but-unparseable cf_signature must be a hard failure, not the permissive default: {err:?}"
1608 );
1609 }
1610
1611 #[test]
1612 fn adding_a_bundle_reads_its_signature_from_the_manifest_never_instantiating_wasm() {
1613 let catalog_dir = tempfile::tempdir().unwrap();
1614 let bundle_dir = tempfile::tempdir().unwrap();
1615 let bundle_path = bundle_dir.path().join("digest.cfbundle");
1616 std::fs::write(
1617 &bundle_path,
1618 make_bundle(
1619 br#"{"nodes":[],"edges":[],"signature":"{path: text} -> {summary: text}"}"#,
1620 ),
1621 )
1622 .unwrap();
1623
1624 let catalog = Catalog::open(catalog_dir.path());
1625 let outcome = catalog
1626 .add("digest@1", &bundle_path, &wasmtime::Engine::default())
1627 .unwrap();
1628
1629 assert_eq!(outcome.kind, ArtifactKind::Bundle);
1630 assert_eq!(outcome.signature, "{path: text} -> {summary: text}");
1631 assert!(!outcome.is_permissive_default);
1632 }
1633
1634 #[test]
1635 fn adding_a_file_with_neither_magic_is_unrecognized_not_a_silent_guess() {
1636 let catalog_dir = tempfile::tempdir().unwrap();
1637 let junk_dir = tempfile::tempdir().unwrap();
1638 let junk_path = junk_dir.path().join("junk.bin");
1639 std::fs::write(&junk_path, b"not a wasm or bundle").unwrap();
1640
1641 let catalog = Catalog::open(catalog_dir.path());
1642 let err = catalog
1643 .add("junk@1", &junk_path, &wasmtime::Engine::default())
1644 .unwrap_err();
1645 assert!(
1646 matches!(err, CatalogError::UnrecognizedArtifact { .. }),
1647 "{err:?}"
1648 );
1649 }
1650
1651 #[test]
1652 fn re_adding_the_same_name_version_is_rejected() {
1653 let catalog_dir = tempfile::tempdir().unwrap();
1654 let wasm_dir = tempfile::tempdir().unwrap();
1655 let wasm_path = wasm_dir.path().join("a.wasm");
1656 std::fs::write(
1657 &wasm_path,
1658 wat::parse_str(r#"(module (memory (export "memory") 1))"#).unwrap(),
1659 )
1660 .unwrap();
1661
1662 let catalog = Catalog::open(catalog_dir.path());
1663 let engine = wasmtime::Engine::default();
1664 catalog.add("dup@1", &wasm_path, &engine).unwrap();
1665
1666 let err = catalog.add("dup@1", &wasm_path, &engine).unwrap_err();
1667 assert!(matches!(err, CatalogError::AlreadyExists { .. }), "{err:?}");
1668 }
1669
1670 #[test]
1671 fn list_show_rm_roundtrip() {
1672 let dir = tempfile::tempdir().unwrap();
1673 seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
1674 let catalog = Catalog::open(dir.path());
1675
1676 assert_eq!(catalog.list().unwrap().len(), 1);
1677 let shown = catalog
1678 .show("a@1")
1679 .expect("just-seeded entry must be visible");
1680 assert_eq!(shown.signature, "json -> json");
1681
1682 catalog.rm("a@1").unwrap();
1683 assert!(catalog.list().unwrap().is_empty());
1684 }
1685
1686 #[test]
1687 fn showing_a_missing_entry_reports_not_found_with_a_suggestion() {
1688 let dir = tempfile::tempdir().unwrap();
1689 seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
1690 let catalog = Catalog::open(dir.path());
1691
1692 let err = catalog.show("summarise@1").unwrap_err();
1693 let CatalogError::NotFound { did_you_mean, .. } = &err else {
1694 panic!("expected NotFound, got {err:?}")
1695 };
1696 assert_eq!(did_you_mean, &vec!["summarize@1".to_string()]);
1697 }
1698
1699 fn distinct_wasm(dir: &Path, name: &str, body_marker: u32) -> PathBuf {
1703 let path = dir.join(format!("{name}.wasm"));
1704 std::fs::write(
1705 &path,
1706 wat::parse_str(format!(
1707 r#"(module (memory (export "memory") 1) (func (export "marker") (result i32) i32.const {body_marker}))"#
1708 ))
1709 .unwrap(),
1710 )
1711 .unwrap();
1712 path
1713 }
1714
1715 #[test]
1716 fn an_identifier_with_no_at_version_is_rejected_rather_than_catalogued_under_a_typo() {
1717 let catalog_dir = tempfile::tempdir().unwrap();
1718 let wasm_dir = tempfile::tempdir().unwrap();
1719 let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1720
1721 let err = Catalog::open(catalog_dir.path())
1722 .add("echo-summarize", &wasm, &wasmtime::Engine::default())
1723 .expect_err("dropping @version is a typo, not a name meaning itself");
1724
1725 assert!(
1726 matches!(err, CatalogError::InvalidNameVersion { .. }),
1727 "{err:?}"
1728 );
1729 assert!(
1730 Catalog::open(catalog_dir.path()).list().unwrap().is_empty(),
1731 "a rejected identifier must not leave an entry behind"
1732 );
1733 }
1734
1735 #[test]
1736 fn an_identifier_with_an_empty_name_or_version_is_rejected() {
1737 let catalog_dir = tempfile::tempdir().unwrap();
1738 let wasm_dir = tempfile::tempdir().unwrap();
1739 let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1740 let catalog = Catalog::open(catalog_dir.path());
1741 let engine = wasmtime::Engine::default();
1742
1743 for bad in ["@1", "name@", "", " "] {
1744 let err = catalog
1745 .add(bad, &wasm, &engine)
1746 .expect_err("an empty name or version is not a name@version");
1747 assert!(
1748 matches!(err, CatalogError::InvalidNameVersion { .. }),
1749 "{bad:?} gave {err:?}"
1750 );
1751 }
1752 }
1753
1754 #[test]
1755 fn an_identifier_with_more_than_one_at_separator_is_rejected() {
1756 let catalog_dir = tempfile::tempdir().unwrap();
1757 let wasm_dir = tempfile::tempdir().unwrap();
1758 let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1759
1760 let err = Catalog::open(catalog_dir.path())
1761 .add("a@b@c", &wasm, &wasmtime::Engine::default())
1762 .expect_err("two '@' separators is not a name@version");
1763 assert!(
1764 matches!(err, CatalogError::InvalidNameVersion { .. }),
1765 "{err:?}"
1766 );
1767 }
1768
1769 #[test]
1770 fn an_identifier_containing_path_or_whitespace_characters_is_rejected() {
1771 let catalog_dir = tempfile::tempdir().unwrap();
1772 let wasm_dir = tempfile::tempdir().unwrap();
1773 let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1774 let catalog = Catalog::open(catalog_dir.path());
1775 let engine = wasmtime::Engine::default();
1776
1777 for bad in ["../../etc/passwd@1", "with space@1", "name@../../tmp/pwn"] {
1778 let err = catalog
1779 .add(bad, &wasm, &engine)
1780 .expect_err("{bad} must be rejected");
1781 assert!(
1782 matches!(err, CatalogError::InvalidNameVersion { .. }),
1783 "{bad:?} gave {err:?}"
1784 );
1785 }
1786 }
1787
1788 #[test]
1789 fn an_ordinary_name_at_version_still_catalogs() {
1790 let catalog_dir = tempfile::tempdir().unwrap();
1791 let wasm_dir = tempfile::tempdir().unwrap();
1792 let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1793
1794 Catalog::open(catalog_dir.path())
1795 .add(
1796 "echo-summarize@1.2.3-rc.1",
1797 &wasm,
1798 &wasmtime::Engine::default(),
1799 )
1800 .expect("letters, digits, '.', '-' and '_' are all legal");
1801 }
1802
1803 #[test]
1807 fn a_pre_existing_junk_identifier_can_still_be_shown_and_removed() {
1808 let dir = tempfile::tempdir().unwrap();
1809 seed(dir.path(), "no-at-sign", "2026-01-01T00:00:00Z");
1810 let catalog = Catalog::open(dir.path());
1811
1812 catalog
1813 .show("no-at-sign")
1814 .expect("an already-stored key must remain inspectable");
1815 catalog
1816 .rm("no-at-sign")
1817 .expect("an already-stored key must remain removable");
1818 }
1819
1820 #[test]
1821 fn re_adding_a_removed_version_with_the_same_bytes_is_allowed() {
1822 let catalog_dir = tempfile::tempdir().unwrap();
1823 let wasm_dir = tempfile::tempdir().unwrap();
1824 let wasm = distinct_wasm(wasm_dir.path(), "same", 7);
1825 let catalog = Catalog::open(catalog_dir.path());
1826 let engine = wasmtime::Engine::default();
1827
1828 catalog.add("thing@1", &wasm, &engine).unwrap();
1829 catalog.rm("thing@1").unwrap();
1830 catalog
1831 .add("thing@1", &wasm, &engine)
1832 .expect("re-adding identical bytes is an undo of the rm, not a rewrite of history");
1833
1834 assert_eq!(catalog.list().unwrap().len(), 1);
1835 }
1836
1837 #[test]
1841 fn re_adding_a_removed_version_with_different_bytes_is_rejected() {
1842 let catalog_dir = tempfile::tempdir().unwrap();
1843 let wasm_dir = tempfile::tempdir().unwrap();
1844 let original = distinct_wasm(wasm_dir.path(), "original", 1);
1845 let replacement = distinct_wasm(wasm_dir.path(), "replacement", 2);
1846 let catalog = Catalog::open(catalog_dir.path());
1847 let engine = wasmtime::Engine::default();
1848
1849 catalog.add("thing@1", &original, &engine).unwrap();
1850 catalog.rm("thing@1").unwrap();
1851
1852 let err = catalog
1853 .add("thing@1", &replacement, &engine)
1854 .expect_err("rm must not be a way to republish a version with new content");
1855 let CatalogError::RetiredWithDifferentContent {
1856 name_version,
1857 previous_hash,
1858 new_hash,
1859 } = &err
1860 else {
1861 panic!("expected RetiredWithDifferentContent, got {err:?}")
1862 };
1863 assert_eq!(name_version, "thing@1");
1864 assert_ne!(previous_hash, new_hash);
1865 assert!(
1866 catalog.list().unwrap().is_empty(),
1867 "the reject must not add"
1868 );
1869 }
1870
1871 #[test]
1875 fn an_index_written_without_the_retired_field_still_loads() {
1876 let dir = tempfile::tempdir().unwrap();
1877 std::fs::create_dir_all(dir.path()).unwrap();
1878 std::fs::write(
1879 dir.path().join("index.json"),
1880 br#"{"version":1,"entries":{"old@1":{"hash":"sha256:ab","kind":"block","signature":"json -> json","created_at":"2026-01-01T00:00:00Z"}}}"#,
1881 )
1882 .unwrap();
1883
1884 let index = read_index(dir.path()).expect("an index predating `retired` is not corrupt");
1885 assert!(index.entries.contains_key("old@1"));
1886 assert!(index.retired.is_empty());
1887 }
1888
1889 #[test]
1890 fn removing_a_missing_entry_is_not_found_not_a_silent_no_op() {
1891 let dir = tempfile::tempdir().unwrap();
1892 let catalog = Catalog::open(dir.path());
1893 let err = catalog.rm("nothing@1").unwrap_err();
1894 assert!(matches!(err, CatalogError::NotFound { .. }), "{err:?}");
1895 }
1896
1897 #[test]
1898 fn removing_an_entry_leaves_its_blob_on_disk_v1_has_no_garbage_collection() {
1899 let dir = tempfile::tempdir().unwrap();
1900 let hash = write_blob(dir.path(), b"some block bytes").unwrap();
1901 let hex = hash.strip_prefix("sha256:").unwrap();
1902 with_locked_index(dir.path(), |index| {
1903 index.entries.insert(
1904 "a@1".to_string(),
1905 Entry {
1906 hash: hash.clone(),
1907 kind: ArtifactKind::Block,
1908 signature: "json -> json".to_string(),
1909 created_at: "2026-01-01T00:00:00Z".to_string(),
1910 },
1911 );
1912 Ok::<_, CatalogError>(())
1913 })
1914 .unwrap();
1915
1916 let catalog = Catalog::open(dir.path());
1917 catalog.rm("a@1").unwrap();
1918
1919 assert!(
1920 matches!(catalog.show("a@1"), Err(CatalogError::NotFound { .. })),
1921 "rm must actually remove the index entry, not silently no-op"
1922 );
1923 assert!(
1924 dir.path().join("blobs").join(hex).exists(),
1925 "rm is index-only; the blob must remain"
1926 );
1927 }
1928
1929 #[test]
1930 fn list_returns_multiple_entries_sorted_by_name_at_version() {
1931 let dir = tempfile::tempdir().unwrap();
1932 seed(dir.path(), "b@1", "2026-01-01T00:00:00Z");
1933 seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
1934 seed(dir.path(), "c@1", "2026-01-01T00:00:00Z");
1935
1936 let catalog = Catalog::open(dir.path());
1937 let names: Vec<String> = catalog
1938 .list()
1939 .unwrap()
1940 .into_iter()
1941 .map(|(name_version, _)| name_version)
1942 .collect();
1943
1944 assert_eq!(
1945 names,
1946 vec!["a@1".to_string(), "b@1".to_string(), "c@1".to_string()]
1947 );
1948 }
1949
1950 #[test]
1951 fn resolve_a_dot_wasm_suffix_is_direct_even_if_the_file_does_not_exist() {
1952 let dir = tempfile::tempdir().unwrap();
1953 let catalog = Catalog::open(dir.path());
1954 let resolved = catalog
1955 .resolve("/nonexistent/block.wasm", ResolutionContext::Interactive)
1956 .unwrap();
1957 assert!(matches!(resolved, Resolved::Direct(_)));
1958 }
1959
1960 #[test]
1961 fn resolve_a_dot_cfbundle_suffix_is_direct_even_if_the_file_does_not_exist() {
1962 let dir = tempfile::tempdir().unwrap();
1963 let catalog = Catalog::open(dir.path());
1964 let resolved = catalog
1965 .resolve(
1966 "/nonexistent/bundle.cfbundle",
1967 ResolutionContext::Interactive,
1968 )
1969 .unwrap();
1970 assert!(matches!(resolved, Resolved::Direct(_)));
1971 }
1972
1973 #[test]
1974 fn resolve_an_existing_filesystem_path_is_direct_no_catalog_lookup() {
1975 let dir = tempfile::tempdir().unwrap();
1976 let real_file = tempfile::NamedTempFile::new().unwrap();
1977 let catalog = Catalog::open(dir.path());
1978 let resolved = catalog
1979 .resolve(
1980 real_file.path().to_str().unwrap(),
1981 ResolutionContext::Interactive,
1982 )
1983 .unwrap();
1984 assert!(matches!(resolved, Resolved::Direct(_)));
1985 }
1986
1987 #[test]
1988 fn resolve_exact_name_at_version_hits_case_sensitively() {
1989 let dir = tempfile::tempdir().unwrap();
1990 seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
1991 let catalog = Catalog::open(dir.path());
1992
1993 assert!(catalog
1994 .resolve("summarize@1", ResolutionContext::Interactive)
1995 .is_ok());
1996
1997 let err = catalog
1998 .resolve("Summarize@1", ResolutionContext::Interactive)
1999 .unwrap_err();
2000 let CatalogError::NotFound { did_you_mean, .. } = &err else {
2001 panic!("expected NotFound (case-sensitive miss), got {err:?}")
2002 };
2003 assert!(
2004 did_you_mean.contains(&"summarize@1".to_string()),
2005 "case-sensitivity rejects the hit, but edit distance 1 should still suggest it: {did_you_mean:?}"
2006 );
2007 }
2008
2009 #[test]
2010 fn resolve_unqualified_name_picks_the_latest_by_created_at() {
2011 let dir = tempfile::tempdir().unwrap();
2012 seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2013 seed(dir.path(), "a@2", "2026-06-01T00:00:00Z");
2014 let catalog = Catalog::open(dir.path());
2015
2016 let resolved = catalog
2017 .resolve("a", ResolutionContext::Interactive)
2018 .unwrap();
2019 let Resolved::Cataloged { name_version, .. } = resolved else {
2020 panic!("expected a cataloged resolution")
2021 };
2022 assert_eq!(name_version, "a@2");
2023 }
2024
2025 #[test]
2026 fn resolve_unqualified_name_is_legal_from_an_interactive_context() {
2027 let dir = tempfile::tempdir().unwrap();
2028 seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2029 let catalog = Catalog::open(dir.path());
2030 assert!(catalog.resolve("a", ResolutionContext::Interactive).is_ok());
2031 }
2032
2033 #[test]
2034 fn resolve_unqualified_name_is_rejected_in_a_durable_context() {
2035 let dir = tempfile::tempdir().unwrap();
2036 seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2037 let catalog = Catalog::open(dir.path());
2038 let err = catalog
2039 .resolve("a", ResolutionContext::Durable)
2040 .unwrap_err();
2041 assert!(
2042 matches!(err, CatalogError::UnqualifiedName { .. }),
2043 "{err:?}"
2044 );
2045 }
2046
2047 #[test]
2048 fn resolve_not_found_suggests_a_close_typo() {
2049 let dir = tempfile::tempdir().unwrap();
2050 seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
2051 let catalog = Catalog::open(dir.path());
2052 let err = catalog
2053 .resolve("summarise@1", ResolutionContext::Interactive)
2054 .unwrap_err();
2055 let CatalogError::NotFound { did_you_mean, .. } = &err else {
2056 panic!("expected NotFound, got {err:?}")
2057 };
2058 assert_eq!(did_you_mean, &vec!["summarize@1".to_string()]);
2059 }
2060
2061 #[test]
2062 fn read_blob_returns_what_add_wrote() {
2063 let dir = tempfile::tempdir().unwrap();
2064 let catalog = Catalog::open(dir.path());
2065 let engine = wasmtime::Engine::default();
2066 let wasm = wat::parse_str("(module)").unwrap();
2067 let path = dir.path().join("m.wasm");
2068 std::fs::write(&path, &wasm).unwrap();
2069
2070 let outcome = catalog.add("m@1", &path, &engine).unwrap();
2071 let entry = catalog.show("m@1").unwrap();
2072
2073 let bytes = catalog.read_blob(&entry).unwrap();
2074 assert_eq!(bytes, wasm);
2075 assert_eq!(outcome.name_version, "m@1");
2076 }
2077
2078 #[test]
2079 fn read_blob_on_a_hand_edited_missing_hash_errors_clearly() {
2080 let dir = tempfile::tempdir().unwrap();
2081 let catalog = Catalog::open(dir.path());
2082 let fake = Entry {
2083 hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
2084 .to_string(),
2085 ..entry_fixture("2026-01-01T00:00:00Z")
2086 };
2087 let err = catalog.read_blob(&fake).unwrap_err();
2088 match err {
2089 CatalogError::Io(ref io_err) => {
2090 assert_eq!(
2091 io_err.kind(),
2092 std::io::ErrorKind::NotFound,
2093 "a well-formed hash with no matching blob file must surface as a plain \
2094 not-found I/O error: {err:?}"
2095 );
2096 }
2097 other => {
2098 panic!("a well-formed but absent hash must be a plain Io(NotFound), not {other:?}")
2099 }
2100 }
2101 }
2102
2103 #[test]
2104 fn read_blob_rejects_a_path_traversal_hash_instead_of_touching_the_filesystem() {
2105 let dir = tempfile::tempdir().unwrap();
2113 std::fs::write(dir.path().join("outside.txt"), b"do not leak this").unwrap();
2116
2117 let catalog = Catalog::open(dir.path());
2118 let traversal = Entry {
2119 hash: "sha256:../outside.txt".to_string(),
2120 ..entry_fixture("2026-01-01T00:00:00Z")
2121 };
2122 let err = catalog.read_blob(&traversal).unwrap_err();
2123 assert!(
2124 matches!(err, CatalogError::MalformedHash { .. }),
2125 "a path-traversal hash must be rejected as MalformedHash before any path is \
2126 constructed, got {err:?}"
2127 );
2128
2129 let absolute = Entry {
2130 hash: "sha256:/etc/passwd".to_string(),
2131 ..entry_fixture("2026-01-01T00:00:00Z")
2132 };
2133 let err = catalog.read_blob(&absolute).unwrap_err();
2134 assert!(
2135 matches!(err, CatalogError::MalformedHash { .. }),
2136 "an absolute-path-like hash must be rejected as MalformedHash before any path is \
2137 constructed, got {err:?}"
2138 );
2139 }
2140}