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