1use std::collections::BTreeMap;
24use std::fs::{self, File};
25use std::io::Write;
26use std::path::{Path, PathBuf};
27
28use serde::{Deserialize, Serialize};
29
30const INDEX_VERSION: u32 = 1;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ArtifactKind {
39 Block,
41 Bundle,
43 Script,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Entry {
52 pub hash: String,
54 pub kind: ArtifactKind,
56 pub signature: String,
60 pub created_at: String,
65}
66
67#[derive(Debug, Serialize, Deserialize)]
69struct IndexFile {
70 version: u32,
71 entries: BTreeMap<String, Entry>,
72 #[serde(default)]
82 retired: BTreeMap<String, String>,
83}
84
85impl IndexFile {
86 fn empty() -> Self {
87 Self {
88 version: INDEX_VERSION,
89 entries: BTreeMap::new(),
90 retired: BTreeMap::new(),
91 }
92 }
93}
94
95fn is_legal_identifier_char(c: char) -> bool {
100 c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')
101}
102
103fn validate_name_version(s: &str) -> Result<(), CatalogError> {
110 let invalid = |reason: String| CatalogError::InvalidNameVersion {
111 name_version: s.to_string(),
112 reason,
113 };
114
115 let separators = s.matches('@').count();
116 if separators != 1 {
117 return Err(invalid(match separators {
118 0 => "expected <name>@<version>, e.g. echo-summarize@1".to_string(),
119 n => format!("found {n} '@' separators"),
120 }));
121 }
122
123 let (name, version) = s.split_once('@').expect("exactly one '@' is present");
124 if name.is_empty() {
125 return Err(invalid("the name is empty".to_string()));
126 }
127 if version.is_empty() {
128 return Err(invalid("the version is empty".to_string()));
129 }
130
131 for (half, label) in [(name, "name"), (version, "version")] {
132 if let Some(bad) = half.chars().find(|c| !is_legal_identifier_char(*c)) {
133 return Err(invalid(format!(
134 "the {label} contains {bad:?}; only letters, digits, '.', '-' and '_' are allowed"
135 )));
136 }
137 }
138
139 Ok(())
140}
141
142const WINDOWS_RESERVED_NAMES: &[&str] = &[
146 "con", "prn", "aux", "nul", "com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8",
147 "com9", "lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9",
148];
149
150pub fn validate_block_name(name: &str) -> Result<(), CatalogError> {
164 let invalid = |reason: String| CatalogError::InvalidBlockName {
165 name: name.to_string(),
166 reason,
167 };
168
169 if name.is_empty() {
170 return Err(invalid("the name is empty".to_string()));
171 }
172 if !name.chars().next().unwrap().is_ascii_alphabetic() {
173 return Err(invalid("must start with a letter".to_string()));
174 }
175 if let Some(bad) = name
176 .chars()
177 .find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '-' | '_')))
178 {
179 return Err(invalid(format!(
180 "contains {bad:?}; only letters, digits, '-' and '_' are allowed"
181 )));
182 }
183 if WINDOWS_RESERVED_NAMES.contains(&name.to_ascii_lowercase().as_str()) {
184 return Err(invalid(format!(
185 "\"{name}\" is a reserved name on Windows and can't be used as a directory name there"
186 )));
187 }
188
189 Ok(())
190}
191
192#[derive(Debug, thiserror::Error)]
194pub enum CatalogError {
195 #[error("{name_version} is already catalogued; versions are immutable once published")]
197 AlreadyExists {
198 name_version: String,
200 },
201 #[error("{name_version:?} is not a name@version ({reason})")]
203 InvalidNameVersion {
204 name_version: String,
206 reason: String,
208 },
209 #[error("{name:?} is not a valid block name ({reason})")]
216 InvalidBlockName {
217 name: String,
219 reason: String,
221 },
222 #[error(
227 "{name_version} was previously catalogued with different content; versions are \
228 immutable once published ({previous_hash} -> {new_hash})"
229 )]
230 RetiredWithDifferentContent {
231 name_version: String,
233 previous_hash: String,
235 new_hash: String,
237 },
238 #[error(
240 "no such catalog entry: {name_version}{}",
241 format_did_you_mean(did_you_mean)
242 )]
243 NotFound {
244 name_version: String,
246 did_you_mean: Vec<String>,
249 },
250 #[error("{name} has no version — an exact name@version is required here")]
254 UnqualifiedName {
255 name: String,
257 },
258 #[error("catalog index at {path} is corrupt: {reason}")]
260 CorruptIndex {
261 path: PathBuf,
263 reason: String,
265 },
266 #[error("{path}: not a recognised artifact (header: {header:02x?})")]
268 UnrecognizedArtifact {
269 path: PathBuf,
271 header: Vec<u8>,
273 },
274 #[error("{path}: {reason}")]
286 UninspectableArtifact {
287 path: PathBuf,
295 reason: String,
297 },
298 #[error(
309 "catalog entry has a malformed hash {hash:?}: expected sha256:<64 lowercase hex digits>"
310 )]
311 MalformedHash {
312 hash: String,
314 },
315 #[error(transparent)]
318 Io(#[from] std::io::Error),
319}
320
321fn format_did_you_mean(names: &[String]) -> String {
324 if names.is_empty() {
325 String::new()
326 } else {
327 format!(" (did you mean: {}?)", names.join(", "))
328 }
329}
330
331fn levenshtein(a: &str, b: &str) -> usize {
333 let a: Vec<char> = a.chars().collect();
334 let b: Vec<char> = b.chars().collect();
335 let mut prev: Vec<usize> = (0..=b.len()).collect();
336 let mut curr = vec![0usize; b.len() + 1];
337
338 for i in 1..=a.len() {
339 curr[0] = i;
340 for j in 1..=b.len() {
341 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
342 curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost);
343 }
344 std::mem::swap(&mut prev, &mut curr);
345 }
346 prev[b.len()]
347}
348
349fn pick_did_you_mean(target_name: &str, entries: &BTreeMap<String, Entry>) -> Vec<String> {
355 let target_name = target_name.split('@').next().unwrap_or(target_name);
356 const MAX_DISTANCE: usize = 2;
357 const LIMIT: usize = 5;
358
359 let mut by_name: BTreeMap<&str, (&str, &str)> = BTreeMap::new();
361 for (name_version, entry) in entries {
362 let name = name_version.split('@').next().unwrap_or(name_version);
363 if levenshtein(target_name, name) > MAX_DISTANCE {
364 continue;
365 }
366 by_name
367 .entry(name)
368 .and_modify(|(nv, created)| {
369 if entry.created_at.as_str() > *created {
370 *nv = name_version;
371 *created = entry.created_at.as_str();
372 }
373 })
374 .or_insert((name_version, entry.created_at.as_str()));
375 }
376
377 let mut candidates: Vec<(usize, &str, &str)> = by_name
378 .into_iter()
379 .map(|(name, (nv, created))| (levenshtein(target_name, name), nv, created))
380 .collect();
381 candidates.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.2.cmp(b.2)));
382
383 candidates
384 .into_iter()
385 .take(LIMIT)
386 .map(|(_, nv, _)| nv.to_string())
387 .collect()
388}
389
390const WASM_MAGIC: &[u8] = b"\0asm";
391pub(crate) const BUNDLE_MAGIC: &[u8; 4] = b"CFBD";
396pub(crate) const BUNDLE_HEADER_LEN: usize = BUNDLE_MAGIC.len() + 8;
400
401pub(crate) fn sniff_artifact_kind(bytes: &[u8]) -> Option<ArtifactKind> {
406 if bytes.starts_with(WASM_MAGIC) {
407 Some(ArtifactKind::Block)
408 } else if bytes.starts_with(BUNDLE_MAGIC) {
409 Some(ArtifactKind::Bundle)
410 } else {
411 None
412 }
413}
414
415fn index_path(root: &Path) -> PathBuf {
416 root.join("index.json")
417}
418
419fn lock_path(root: &Path) -> PathBuf {
420 root.join("index.json.lock")
421}
422
423fn read_index(root: &Path) -> Result<IndexFile, CatalogError> {
430 let path = index_path(root);
431 if !path.exists() {
432 return Ok(IndexFile::empty());
433 }
434
435 let bytes = fs::read(&path)?;
436 let index: IndexFile =
437 serde_json::from_slice(&bytes).map_err(|e| CatalogError::CorruptIndex {
438 path: path.clone(),
439 reason: e.to_string(),
440 })?;
441
442 if index.version != INDEX_VERSION {
443 return Err(CatalogError::CorruptIndex {
444 path,
445 reason: format!(
446 "index format version {} is not supported by this build (expected {INDEX_VERSION})",
447 index.version
448 ),
449 });
450 }
451
452 Ok(index)
453}
454
455fn with_locked_index<T>(
466 root: &Path,
467 f: impl FnOnce(&mut IndexFile) -> Result<T, CatalogError>,
468) -> Result<T, CatalogError> {
469 fs::create_dir_all(root)?;
470 let lock_file = File::options()
471 .create(true)
472 .truncate(false)
473 .write(true)
474 .open(lock_path(root))?;
475 lock_file.lock()?;
476
477 let mut index = read_index(root)?;
478 let result = f(&mut index)?;
479
480 let tmp_path = root.join("index.json.tmp");
481 let bytes = serde_json::to_vec_pretty(&index).expect("IndexFile always serializes");
482 {
483 let mut tmp = File::create(&tmp_path)?;
484 tmp.write_all(&bytes)?;
485 tmp.sync_all()?;
486 }
487 fs::rename(&tmp_path, index_path(root))?;
488
489 Ok(result)
490}
491
492fn blobs_dir(root: &Path) -> PathBuf {
493 root.join("blobs")
494}
495
496fn is_well_formed_sha256_hex(hex: &str) -> bool {
503 hex.len() == 64
504 && hex
505 .bytes()
506 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
507}
508
509pub(crate) fn read_bundle_signature(bytes: &[u8], label: &str) -> Result<String, CatalogError> {
521 if bytes.len() < BUNDLE_HEADER_LEN {
522 return Err(CatalogError::UninspectableArtifact {
523 path: PathBuf::from(label),
524 reason: "shorter than the bundle header".to_string(),
525 });
526 }
527
528 let manifest_len =
529 u64::from_le_bytes(bytes[4..BUNDLE_HEADER_LEN].try_into().expect("8 bytes")) as usize;
530 let manifest_bytes = BUNDLE_HEADER_LEN
531 .checked_add(manifest_len)
532 .and_then(|end| bytes.get(BUNDLE_HEADER_LEN..end))
533 .ok_or_else(|| CatalogError::UninspectableArtifact {
534 path: PathBuf::from(label),
535 reason: format!("manifest_len {manifest_len} exceeds the file's actual length"),
536 })?;
537
538 let manifest: serde_json::Value = serde_json::from_slice(manifest_bytes).map_err(|e| {
539 CatalogError::UninspectableArtifact {
540 path: PathBuf::from(label),
541 reason: format!("manifest is not valid JSON: {e}"),
542 }
543 })?;
544
545 if let Some(nodes) = manifest.get("nodes").and_then(|v| v.as_array()) {
554 let body_len = (bytes.len() - BUNDLE_HEADER_LEN - manifest_len) as u64;
555 for node in nodes {
556 let bounds = node
557 .get("offset")
558 .and_then(|v| v.as_u64())
559 .zip(node.get("len").and_then(|v| v.as_u64()));
560 let in_bounds = match bounds {
561 Some((offset, len)) => offset.checked_add(len).is_some_and(|end| end <= body_len),
562 None => false,
563 };
564 if !in_bounds {
565 return Err(CatalogError::UninspectableArtifact {
566 path: PathBuf::from(label),
567 reason: format!(
568 "a node's offset/len ({:?}) doesn't fit within the bundle's \
569 {body_len}-byte stage-bytes region",
570 (
571 node.get("offset").and_then(|v| v.as_u64()),
572 node.get("len").and_then(|v| v.as_u64())
573 )
574 ),
575 });
576 }
577 }
578 }
579
580 manifest
581 .get("signature")
582 .and_then(|v| v.as_str())
583 .map(str::to_string)
584 .ok_or_else(|| CatalogError::UninspectableArtifact {
585 path: PathBuf::from(label),
586 reason: "manifest has no string field \"signature\"".to_string(),
587 })
588}
589
590pub(crate) fn read_script_signature(bytes: &[u8], label: &str) -> Result<String, CatalogError> {
602 let text = std::str::from_utf8(bytes).map_err(|_| CatalogError::UninspectableArtifact {
603 path: PathBuf::from(label),
604 reason: "not valid UTF-8 text".to_string(),
605 })?;
606
607 const HEADER_SCAN_LINES: usize = 10;
613 let matches: Vec<&str> = text
614 .lines()
615 .take(HEADER_SCAN_LINES)
616 .filter_map(|line| line.trim().strip_prefix("//! signature:"))
617 .collect();
618
619 let header = match matches.as_slice() {
620 [] => {
621 return Err(CatalogError::UninspectableArtifact {
622 path: PathBuf::from(label),
623 reason: format!(
624 "no `//! signature: <input> -> <output>` header comment found in the \
625 first {HEADER_SCAN_LINES} lines"
626 ),
627 })
628 }
629 [only] => only.trim(),
630 _ => {
631 return Err(CatalogError::UninspectableArtifact {
632 path: PathBuf::from(label),
633 reason: format!(
634 "found {} `//! signature: ...` header comments; a script must declare \
635 exactly one",
636 matches.len()
637 ),
638 })
639 }
640 };
641
642 header.parse::<cuttlefish_abi::Signature>().map_err(|e| {
647 CatalogError::UninspectableArtifact {
648 path: PathBuf::from(label),
649 reason: format!("signature header `{header}` does not parse: {e}"),
650 }
651 })?;
652
653 rhai::Engine::new()
661 .compile(text)
662 .map_err(|e| CatalogError::UninspectableArtifact {
663 path: PathBuf::from(label),
664 reason: format!("script does not parse: {e}"),
665 })?;
666
667 Ok(header.to_string())
668}
669
670fn write_blob(root: &Path, bytes: &[u8]) -> Result<String, CatalogError> {
676 use sha2::{Digest, Sha256};
677 use std::sync::atomic::{AtomicU64, Ordering};
678
679 let hex = crate::hex::encode(Sha256::digest(bytes));
680 let dir = blobs_dir(root);
681 fs::create_dir_all(&dir)?;
682
683 let blob_path = dir.join(&hex);
684 if !blob_path.exists() {
685 static COUNTER: AtomicU64 = AtomicU64::new(0);
692 let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
693 let tmp_path = dir.join(format!("{hex}.tmp.{}.{unique}", std::process::id()));
694 {
695 let mut tmp = File::create(&tmp_path)?;
696 tmp.write_all(bytes)?;
697 tmp.sync_all()?;
698 }
699 fs::rename(&tmp_path, &blob_path)?;
700 }
701
702 Ok(format!("sha256:{hex}"))
703}
704
705pub struct Catalog {
709 root: PathBuf,
710}
711
712#[derive(Debug, Clone)]
714pub struct AddOutcome {
715 pub name_version: String,
717 pub kind: ArtifactKind,
719 pub signature: String,
721 pub is_permissive_default: bool,
725}
726
727#[derive(Debug, Clone, Copy, PartialEq, Eq)]
735pub enum ResolutionContext {
736 Interactive,
741 Durable,
744}
745
746#[derive(Debug, Clone)]
748pub enum Resolved {
749 Direct(PathBuf),
752 Cataloged {
754 name_version: String,
757 entry: Entry,
759 },
760}
761
762impl Catalog {
763 pub fn open(root: impl Into<PathBuf>) -> Self {
765 Self { root: root.into() }
766 }
767
768 pub fn add(
775 &self,
776 name_version: &str,
777 artifact_path: &Path,
778 engine: &wasmtime::Engine,
779 ) -> Result<AddOutcome, CatalogError> {
780 validate_name_version(name_version)?;
781
782 let bytes = fs::read(artifact_path)?;
783 let kind = match sniff_artifact_kind(&bytes) {
784 Some(k) => k,
785 None if artifact_path.extension().is_some_and(|e| e == "rhai") => ArtifactKind::Script,
786 None => {
787 return Err(CatalogError::UnrecognizedArtifact {
788 path: artifact_path.to_path_buf(),
789 header: bytes.iter().take(8).copied().collect(),
790 })
791 }
792 };
793
794 let (signature, is_permissive_default) = match kind {
795 ArtifactKind::Block => {
796 let sig = crate::runner::read_signature(engine, &bytes).map_err(|e| {
797 CatalogError::UninspectableArtifact {
798 path: artifact_path.to_path_buf(),
799 reason: format!("{e:#}"),
800 }
801 })?;
802 let permissive = cuttlefish_abi::Signature {
803 input: cuttlefish_abi::Ty::Json,
804 output: cuttlefish_abi::Ty::Json,
805 };
806 let is_permissive = sig == permissive;
807 (sig.to_string(), is_permissive)
808 }
809 ArtifactKind::Bundle => {
810 let sig = read_bundle_signature(&bytes, &artifact_path.to_string_lossy())?;
811 (sig, false)
812 }
813 ArtifactKind::Script => {
814 let sig = read_script_signature(&bytes, &artifact_path.to_string_lossy())?;
815 (sig, false)
816 }
817 };
818
819 let hash = write_blob(&self.root, &bytes)?;
820 let created_at = now_rfc3339();
821 let name_version = name_version.to_string();
822
823 with_locked_index(&self.root, |index| {
824 if index.entries.contains_key(&name_version) {
825 return Err(CatalogError::AlreadyExists {
826 name_version: name_version.clone(),
827 });
828 }
829 if let Some(previous_hash) = index.retired.get(&name_version) {
834 if previous_hash != &hash {
835 return Err(CatalogError::RetiredWithDifferentContent {
836 name_version: name_version.clone(),
837 previous_hash: previous_hash.clone(),
838 new_hash: hash.clone(),
839 });
840 }
841 index.retired.remove(&name_version);
842 }
843 index.entries.insert(
844 name_version.clone(),
845 Entry {
846 hash,
847 kind,
848 signature: signature.clone(),
849 created_at,
850 },
851 );
852 Ok(())
853 })?;
854
855 Ok(AddOutcome {
856 name_version,
857 kind,
858 signature,
859 is_permissive_default,
860 })
861 }
862
863 pub fn list(&self) -> Result<Vec<(String, Entry)>, CatalogError> {
866 let index = read_index(&self.root)?;
867 Ok(index.entries.into_iter().collect())
868 }
869
870 pub fn show(&self, name_version: &str) -> Result<Entry, CatalogError> {
874 let index = read_index(&self.root)?;
875 index.entries.get(name_version).cloned().ok_or_else(|| {
876 let name = name_version.split('@').next().unwrap_or(name_version);
877 CatalogError::NotFound {
878 name_version: name_version.to_string(),
879 did_you_mean: pick_did_you_mean(name, &index.entries),
880 }
881 })
882 }
883
884 pub fn read_blob(&self, entry: &Entry) -> Result<Vec<u8>, CatalogError> {
889 let hex = entry.hash.strip_prefix("sha256:").unwrap_or(&entry.hash);
890 if !is_well_formed_sha256_hex(hex) {
891 return Err(CatalogError::MalformedHash {
892 hash: entry.hash.clone(),
893 });
894 }
895 Ok(fs::read(blobs_dir(&self.root).join(hex))?)
896 }
897
898 pub fn rm(&self, name_version: &str) -> Result<(), CatalogError> {
902 with_locked_index(&self.root, |index| {
903 if let Some(entry) = index.entries.remove(name_version) {
904 index
907 .retired
908 .insert(name_version.to_string(), entry.hash.clone());
909 Ok(())
910 } else {
911 let name = name_version.split('@').next().unwrap_or(name_version);
912 Err(CatalogError::NotFound {
913 name_version: name_version.to_string(),
914 did_you_mean: pick_did_you_mean(name, &index.entries),
915 })
916 }
917 })
918 }
919
920 pub fn resolve(&self, s: &str, context: ResolutionContext) -> Result<Resolved, CatalogError> {
934 if s.ends_with(".wasm") || s.ends_with(".cfbundle") || Path::new(s).exists() {
935 return Ok(Resolved::Direct(PathBuf::from(s)));
936 }
937
938 let index = read_index(&self.root)?;
939
940 if let Some((name, version)) = s.rsplit_once('@') {
941 let name_version = format!("{name}@{version}");
942 let entry = index.entries.get(&name_version).cloned().ok_or_else(|| {
943 CatalogError::NotFound {
944 name_version: name_version.clone(),
945 did_you_mean: pick_did_you_mean(name, &index.entries),
946 }
947 })?;
948 return Ok(Resolved::Cataloged {
949 name_version,
950 entry,
951 });
952 }
953
954 if context == ResolutionContext::Durable {
955 return Err(CatalogError::UnqualifiedName {
956 name: s.to_string(),
957 });
958 }
959
960 let mut versions: Vec<(&String, &Entry)> = index
961 .entries
962 .iter()
963 .filter(|(nv, _)| nv.rsplit_once('@').map(|(n, _)| n) == Some(s))
964 .collect();
965 versions.sort_by(|a, b| a.1.created_at.cmp(&b.1.created_at));
966
967 let (name_version, entry) =
968 versions
969 .last()
970 .copied()
971 .ok_or_else(|| CatalogError::NotFound {
972 name_version: s.to_string(),
973 did_you_mean: pick_did_you_mean(s, &index.entries),
974 })?;
975
976 Ok(Resolved::Cataloged {
977 name_version: name_version.clone(),
978 entry: entry.clone(),
979 })
980 }
981}
982
983pub(crate) fn cuttlefish_home() -> Option<PathBuf> {
992 if let Ok(home) = std::env::var("CUTTLEFISH_HOME") {
993 return Some(PathBuf::from(home));
994 }
995 dirs::home_dir().map(|home| home.join(".cuttlefish"))
996}
997
998pub fn default_root() -> Option<PathBuf> {
1002 cuttlefish_home().map(|h| h.join("catalog"))
1003}
1004
1005pub(crate) fn now_rfc3339() -> String {
1011 let now = time::OffsetDateTime::now_utc()
1012 .replace_nanosecond(0)
1013 .expect("0 is always a valid nanosecond value");
1014 now.format(&time::format_description::well_known::Rfc3339)
1015 .expect("Rfc3339 formatting cannot fail for a valid OffsetDateTime")
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020 use super::*;
1021 use std::sync::atomic::{AtomicBool, Ordering};
1022 use std::sync::{Arc, Barrier};
1023
1024 const WRITERS: usize = 16;
1027
1028 #[test]
1029 fn default_root_honors_cuttlefish_home() {
1030 std::env::set_var("CUTTLEFISH_HOME", "/tmp/cf-test-home");
1036 let root = default_root();
1037 std::env::remove_var("CUTTLEFISH_HOME");
1038 assert_eq!(root, Some(PathBuf::from("/tmp/cf-test-home/catalog")));
1039 }
1040
1041 #[test]
1042 fn index_file_serializes_to_the_shape_the_spec_documents() {
1043 let mut entries = BTreeMap::new();
1044 entries.insert(
1045 "chunk-text@1".to_string(),
1046 Entry {
1047 hash: "sha256:9f86d081".to_string(),
1048 kind: ArtifactKind::Block,
1049 signature: "{path: text} -> [text]".to_string(),
1050 created_at: "2026-08-02T18:03:00Z".to_string(),
1051 },
1052 );
1053 let index = IndexFile {
1054 version: INDEX_VERSION,
1055 entries,
1056 retired: BTreeMap::new(),
1057 };
1058
1059 let json = serde_json::to_string(&index).expect("IndexFile always serializes");
1060 let parsed: serde_json::Value =
1061 serde_json::from_str(&json).expect("what we just wrote must parse");
1062
1063 assert_eq!(parsed["version"], 1);
1064 assert_eq!(parsed["entries"]["chunk-text@1"]["kind"], "block");
1065 assert_eq!(
1066 parsed["entries"]["chunk-text@1"]["signature"],
1067 "{path: text} -> [text]"
1068 );
1069
1070 let round_tripped: IndexFile =
1071 serde_json::from_str(&json).expect("must deserialize what we just serialized");
1072 assert_eq!(round_tripped.version, INDEX_VERSION);
1073 assert!(round_tripped.entries.contains_key("chunk-text@1"));
1074 }
1075
1076 #[test]
1077 fn not_found_with_suggestions_reads_as_one_sentence() {
1078 let err = CatalogError::NotFound {
1079 name_version: "summarise@1".to_string(),
1080 did_you_mean: vec!["summarize@1".to_string()],
1081 };
1082 assert_eq!(
1083 err.to_string(),
1084 "no such catalog entry: summarise@1 (did you mean: summarize@1?)"
1085 );
1086 }
1087
1088 #[test]
1089 fn not_found_with_no_suggestions_has_no_dangling_parenthetical() {
1090 let err = CatalogError::NotFound {
1091 name_version: "xyz@1".to_string(),
1092 did_you_mean: vec![],
1093 };
1094 assert_eq!(err.to_string(), "no such catalog entry: xyz@1");
1095 }
1096
1097 fn entry_fixture(created_at: &str) -> Entry {
1098 Entry {
1099 hash: "sha256:deadbeef".to_string(),
1100 kind: ArtifactKind::Block,
1101 signature: "json -> json".to_string(),
1102 created_at: created_at.to_string(),
1103 }
1104 }
1105
1106 fn seed(root: &Path, name_version: &str, created_at: &str) {
1107 with_locked_index(root, |index| {
1108 index
1109 .entries
1110 .insert(name_version.to_string(), entry_fixture(created_at));
1111 Ok::<_, CatalogError>(())
1112 })
1113 .unwrap();
1114 }
1115
1116 #[test]
1117 fn levenshtein_matches_known_distances() {
1118 assert_eq!(levenshtein("kitten", "sitting"), 3);
1119 assert_eq!(levenshtein("summarize", "summarise"), 1);
1120 assert_eq!(levenshtein("same", "same"), 0);
1121 }
1122
1123 #[test]
1124 fn did_you_mean_catches_a_one_character_typo_a_prefix_match_would_miss() {
1125 let mut entries = BTreeMap::new();
1129 entries.insert(
1130 "summarize@1".to_string(),
1131 entry_fixture("2026-01-01T00:00:00Z"),
1132 );
1133 assert_eq!(
1134 pick_did_you_mean("summarise", &entries),
1135 vec!["summarize@1".to_string()]
1136 );
1137 }
1138
1139 #[test]
1140 fn did_you_mean_is_empty_when_nothing_registered_is_close() {
1141 let mut entries = BTreeMap::new();
1142 entries.insert(
1143 "summarize@1".to_string(),
1144 entry_fixture("2026-01-01T00:00:00Z"),
1145 );
1146 assert!(pick_did_you_mean("completely-unrelated-name", &entries).is_empty());
1147 }
1148
1149 #[test]
1150 fn did_you_mean_is_capped_at_five_closest_ordered_by_distance() {
1151 let mut entries = BTreeMap::new();
1152 for (i, name) in ["bat", "cot", "car", "cap", "can", "cad"]
1155 .iter()
1156 .enumerate()
1157 {
1158 entries.insert(
1159 format!("{name}@1"),
1160 entry_fixture(&format!("2026-01-0{}T00:00:00Z", i + 1)),
1161 );
1162 }
1163 let suggestions = pick_did_you_mean("cat", &entries);
1164 assert_eq!(suggestions.len(), 5, "capped at 5: {suggestions:?}");
1165 }
1166
1167 #[test]
1168 fn did_you_mean_suggests_the_newest_version_when_multiple_versions_of_a_close_name_exist() {
1169 let mut entries = BTreeMap::new();
1170 entries.insert(
1171 "summarize@1".to_string(),
1172 entry_fixture("2026-01-01T00:00:00Z"),
1173 );
1174 entries.insert(
1175 "summarize@2".to_string(),
1176 entry_fixture("2026-06-01T00:00:00Z"),
1177 );
1178 assert_eq!(
1179 pick_did_you_mean("summarise", &entries),
1180 vec!["summarize@2".to_string()],
1181 "must suggest the newest version of a matching name, not every version"
1182 );
1183 }
1184
1185 #[test]
1186 fn wasm_magic_bytes_sniff_as_a_block() {
1187 assert_eq!(
1188 sniff_artifact_kind(b"\0asm\x01\x00\x00\x00"),
1189 Some(ArtifactKind::Block)
1190 );
1191 }
1192
1193 #[test]
1194 fn bundle_magic_bytes_sniff_as_a_bundle() {
1195 assert_eq!(
1196 sniff_artifact_kind(b"CFBD\x00\x00\x00\x00\x00\x00\x00\x00"),
1197 Some(ArtifactKind::Bundle)
1198 );
1199 }
1200
1201 #[test]
1202 fn unrecognised_bytes_sniff_to_none_not_a_guess() {
1203 assert_eq!(sniff_artifact_kind(b"whatever-this-is"), None);
1204 }
1205
1206 #[test]
1207 fn writing_then_reading_the_index_round_trips_through_disk() {
1208 let dir = tempfile::tempdir().unwrap();
1209 with_locked_index(dir.path(), |index| {
1210 index
1211 .entries
1212 .insert("a@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1213 Ok::<_, CatalogError>(())
1214 })
1215 .unwrap();
1216
1217 let index = read_index(dir.path()).unwrap();
1218 assert!(index.entries.contains_key("a@1"));
1219 }
1220
1221 #[test]
1222 fn reading_an_index_that_does_not_exist_yet_is_an_empty_catalog_not_an_error() {
1223 let dir = tempfile::tempdir().unwrap();
1224 let index = read_index(dir.path()).expect("no index.json yet is not corruption");
1225 assert!(index.entries.is_empty());
1226 }
1227
1228 #[test]
1229 fn a_truncated_index_is_a_corrupt_index_error_not_an_empty_catalog() {
1230 let dir = tempfile::tempdir().unwrap();
1231 std::fs::create_dir_all(dir.path()).unwrap();
1232 std::fs::write(dir.path().join("index.json"), b"{\"version\": 1, \"ent").unwrap();
1233
1234 let err = read_index(dir.path()).unwrap_err();
1235 assert!(
1236 matches!(err, CatalogError::CorruptIndex { .. }),
1237 "a truncated index must be a loud CorruptIndex, not treated as empty: {err:?}"
1238 );
1239 }
1240
1241 #[test]
1242 fn an_unsupported_index_version_is_a_corrupt_index_error() {
1243 let dir = tempfile::tempdir().unwrap();
1244 std::fs::create_dir_all(dir.path()).unwrap();
1245 std::fs::write(
1246 dir.path().join("index.json"),
1247 br#"{"version": 999, "entries": {}}"#,
1248 )
1249 .unwrap();
1250
1251 let err = read_index(dir.path()).unwrap_err();
1252 assert!(matches!(err, CatalogError::CorruptIndex { .. }), "{err:?}");
1253 }
1254
1255 #[test]
1256 fn concurrent_writes_from_two_threads_both_land_and_the_index_stays_parseable() {
1257 let dir = tempfile::tempdir().unwrap();
1258 let root_a = dir.path().to_path_buf();
1259 let root_b = dir.path().to_path_buf();
1260
1261 let t1 = std::thread::spawn(move || {
1262 with_locked_index(&root_a, |index| {
1263 index
1264 .entries
1265 .insert("a@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1266 Ok::<_, CatalogError>(())
1267 })
1268 .unwrap();
1269 });
1270 let t2 = std::thread::spawn(move || {
1271 with_locked_index(&root_b, |index| {
1272 index
1273 .entries
1274 .insert("b@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1275 Ok::<_, CatalogError>(())
1276 })
1277 .unwrap();
1278 });
1279 t1.join().unwrap();
1280 t2.join().unwrap();
1281
1282 let index = read_index(dir.path()).expect("the index must still parse after contention");
1283 assert!(index.entries.contains_key("a@1"));
1284 assert!(index.entries.contains_key("b@1"));
1285 }
1286
1287 #[test]
1294 fn racing_inserts_of_the_same_key_leave_exactly_one_winner() {
1295 let dir = tempfile::tempdir().unwrap();
1296 let root = dir.path().to_path_buf();
1297 let barrier = Arc::new(Barrier::new(WRITERS));
1298
1299 let handles: Vec<_> = (0..WRITERS)
1300 .map(|_| {
1301 let root = root.clone();
1302 let barrier = barrier.clone();
1303 std::thread::spawn(move || {
1304 barrier.wait();
1305 with_locked_index(&root, |index| {
1306 if index.entries.contains_key("race@1") {
1307 return Err(CatalogError::AlreadyExists {
1308 name_version: "race@1".to_string(),
1309 });
1310 }
1311 index
1312 .entries
1313 .insert("race@1".to_string(), entry_fixture("2026-01-01T00:00:00Z"));
1314 Ok(())
1315 })
1316 })
1317 })
1318 .collect();
1319
1320 let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1321
1322 let winners = results.iter().filter(|r| r.is_ok()).count();
1323 assert_eq!(
1324 winners, 1,
1325 "exactly one racing writer may claim a key; got {winners}"
1326 );
1327 assert!(
1328 results
1329 .iter()
1330 .all(|r| r.is_ok() || matches!(r, Err(CatalogError::AlreadyExists { .. }))),
1331 "every loser must lose with AlreadyExists, not an io or corruption error: {results:?}"
1332 );
1333
1334 let index = read_index(&root).expect("the index must still parse after contention");
1335 assert_eq!(index.entries.len(), 1);
1336 }
1337
1338 #[test]
1343 fn many_racing_writers_of_distinct_keys_all_land_with_no_lost_updates() {
1344 let dir = tempfile::tempdir().unwrap();
1345 let root = dir.path().to_path_buf();
1346 let barrier = Arc::new(Barrier::new(WRITERS));
1347
1348 let handles: Vec<_> = (0..WRITERS)
1349 .map(|w| {
1350 let root = root.clone();
1351 let barrier = barrier.clone();
1352 std::thread::spawn(move || {
1353 barrier.wait();
1354 with_locked_index(&root, |index| {
1355 index.entries.insert(
1356 format!("writer-{w}@1"),
1357 entry_fixture("2026-01-01T00:00:00Z"),
1358 );
1359 Ok::<_, CatalogError>(())
1360 })
1361 .unwrap();
1362 })
1363 })
1364 .collect();
1365 for h in handles {
1366 h.join().unwrap();
1367 }
1368
1369 let index = read_index(&root).expect("the index must still parse after contention");
1370 assert_eq!(
1371 index.entries.len(),
1372 WRITERS,
1373 "every writer's entry must survive; a lost update means the \
1374 read-modify-write escaped the lock: {:?}",
1375 index.entries.keys().collect::<Vec<_>>()
1376 );
1377 }
1378
1379 #[test]
1385 fn lock_free_readers_never_observe_a_partial_index_while_writers_hammer() {
1386 let dir = tempfile::tempdir().unwrap();
1387 let root = dir.path().to_path_buf();
1388 seed(&root, "seed@1", "2026-01-01T00:00:00Z");
1391
1392 let stop = Arc::new(AtomicBool::new(false));
1393
1394 let writers: Vec<_> = (0..4)
1395 .map(|w| {
1396 let root = root.clone();
1397 std::thread::spawn(move || {
1398 for i in 0..60 {
1399 with_locked_index(&root, |index| {
1400 index.entries.insert(
1401 format!("w{w}-{i}@1"),
1402 entry_fixture("2026-01-01T00:00:00Z"),
1403 );
1404 Ok::<_, CatalogError>(())
1405 })
1406 .unwrap();
1407 }
1408 })
1409 })
1410 .collect();
1411
1412 let readers: Vec<_> = (0..4)
1413 .map(|_| {
1414 let root = root.clone();
1415 let stop = stop.clone();
1416 std::thread::spawn(move || {
1417 let mut reads = 0u32;
1418 while !stop.load(Ordering::Relaxed) {
1419 let index = read_index(&root)
1420 .expect("a lock-free reader must never see a partial or corrupt index");
1421 assert!(
1424 index.entries.contains_key("seed@1"),
1425 "an entry that is never removed vanished from a concurrent read"
1426 );
1427 reads += 1;
1428 }
1429 reads
1430 })
1431 })
1432 .collect();
1433
1434 for w in writers {
1435 w.join().unwrap();
1436 }
1437 stop.store(true, Ordering::Relaxed);
1438
1439 let total: u32 = readers.into_iter().map(|r| r.join().unwrap()).sum();
1440 assert!(
1441 total > 0,
1442 "the readers must have actually observed the index"
1443 );
1444 }
1445
1446 #[test]
1453 fn adds_and_removals_racing_on_one_index_leave_exactly_the_expected_entries() {
1454 let dir = tempfile::tempdir().unwrap();
1455 let root = dir.path().to_path_buf();
1456 seed(&root, "keep@1", "2026-01-01T00:00:00Z");
1457 seed(&root, "keep@2", "2026-01-01T00:00:00Z");
1458
1459 let barrier = Arc::new(Barrier::new(WRITERS));
1460 let handles: Vec<_> = (0..WRITERS)
1461 .map(|w| {
1462 let root = root.clone();
1463 let barrier = barrier.clone();
1464 std::thread::spawn(move || {
1465 let key = format!("churn-{w}@1");
1466 barrier.wait();
1467 for _ in 0..10 {
1468 with_locked_index(&root, |index| {
1469 index
1470 .entries
1471 .insert(key.clone(), entry_fixture("2026-01-01T00:00:00Z"));
1472 Ok::<_, CatalogError>(())
1473 })
1474 .unwrap();
1475 with_locked_index(&root, |index| {
1476 index.entries.remove(&key).expect(
1477 "a key only this thread ever touches must still be present",
1478 );
1479 Ok::<_, CatalogError>(())
1480 })
1481 .unwrap();
1482 }
1483 })
1484 })
1485 .collect();
1486 for h in handles {
1487 h.join().unwrap();
1488 }
1489
1490 let index = read_index(&root).expect("the index must still parse after mixed contention");
1491 let names: Vec<_> = index.entries.keys().cloned().collect();
1492 assert_eq!(
1493 names,
1494 vec!["keep@1".to_string(), "keep@2".to_string()],
1495 "churn keys must all be gone and the untouched entries must survive"
1496 );
1497 }
1498
1499 #[test]
1500 fn identical_bytes_under_two_writes_produce_exactly_one_blob_file() {
1501 let dir = tempfile::tempdir().unwrap();
1502 let hash1 = write_blob(dir.path(), b"hello world").unwrap();
1503 let hash2 = write_blob(dir.path(), b"hello world").unwrap();
1504
1505 assert_eq!(hash1, hash2);
1506 assert!(hash1.starts_with("sha256:"));
1507
1508 let blob_count = std::fs::read_dir(dir.path().join("blobs")).unwrap().count();
1509 assert_eq!(
1510 blob_count, 1,
1511 "identical bytes must dedupe to a single blob file"
1512 );
1513 }
1514
1515 #[test]
1516 fn the_blob_filename_on_disk_is_bare_hex_no_prefix() {
1517 let dir = tempfile::tempdir().unwrap();
1518 let hash = write_blob(dir.path(), b"hello world").unwrap();
1519 let hex = hash
1520 .strip_prefix("sha256:")
1521 .expect("index field is prefixed");
1522
1523 assert!(dir.path().join("blobs").join(hex).exists());
1524 }
1525
1526 #[test]
1527 fn many_concurrent_writers_of_identical_bytes_never_corrupt_the_blob() {
1528 let dir = tempfile::tempdir().unwrap();
1529 let root = dir.path().to_path_buf();
1530 let content = b"identical content raced by many concurrent writers";
1531
1532 let handles: Vec<_> = (0..16)
1533 .map(|_| {
1534 let root = root.clone();
1535 std::thread::spawn(move || write_blob(&root, content).unwrap())
1536 })
1537 .collect();
1538
1539 let hashes: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
1540 assert!(
1541 hashes.iter().all(|h| h == &hashes[0]),
1542 "every writer must compute and report the same hash: {hashes:?}"
1543 );
1544
1545 let hex = hashes[0].strip_prefix("sha256:").unwrap();
1546 let blob_bytes = std::fs::read(root.join("blobs").join(hex)).unwrap();
1547 assert_eq!(
1548 blob_bytes, content,
1549 "the published blob must be exactly the input bytes, not truncated or corrupted by a racing writer"
1550 );
1551 }
1552
1553 fn make_bundle(manifest_json: &[u8]) -> Vec<u8> {
1554 let mut bytes = b"CFBD".to_vec();
1555 bytes.extend_from_slice(&(manifest_json.len() as u64).to_le_bytes());
1556 bytes.extend_from_slice(manifest_json);
1557 bytes
1558 }
1559
1560 #[test]
1561 fn reads_the_signature_field_out_of_a_valid_bundle_manifest() {
1562 let bundle = make_bundle(
1563 br#"{"nodes":[],"edges":[],"signature":"{path: text} -> {summary: text}"}"#,
1564 );
1565 let sig = read_bundle_signature(&bundle, "test.cfbundle").unwrap();
1566 assert_eq!(sig, "{path: text} -> {summary: text}");
1567 }
1568
1569 #[test]
1570 fn a_manifest_len_exceeding_the_actual_bytes_is_uninspectable() {
1571 let mut bundle = make_bundle(br#"{"nodes":[],"edges":[],"signature":"x -> x"}"#);
1572 bundle.truncate(bundle.len() - 5); let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1574 match err {
1575 CatalogError::UninspectableArtifact { reason, .. } => assert!(
1576 reason.contains("exceeds the file's actual length"),
1577 "{reason}"
1578 ),
1579 other => panic!("expected UninspectableArtifact, got {other:?}"),
1580 }
1581 }
1582
1583 #[test]
1584 fn invalid_manifest_json_is_uninspectable() {
1585 let bundle = make_bundle(b"not valid json at all");
1586 let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1587 match err {
1588 CatalogError::UninspectableArtifact { reason, .. } => {
1589 assert!(reason.contains("not valid JSON"), "{reason}")
1590 }
1591 other => panic!("expected UninspectableArtifact, got {other:?}"),
1592 }
1593 }
1594
1595 #[test]
1596 fn a_manifest_missing_the_signature_field_is_uninspectable() {
1597 let bundle = make_bundle(br#"{"nodes":[],"edges":[]}"#);
1598 let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1599 match err {
1600 CatalogError::UninspectableArtifact { reason, .. } => {
1601 assert!(reason.contains("no string field"), "{reason}")
1602 }
1603 other => panic!("expected UninspectableArtifact, got {other:?}"),
1604 }
1605 }
1606
1607 #[test]
1608 fn a_node_whose_offset_and_len_overflow_the_stage_bytes_is_uninspectable() {
1609 let bundle = make_bundle(
1615 br#"{"nodes":[{"name":"bad","kind":"block","resolved":null,
1616 "signature":"json -> json","offset":99999,"len":99999}],
1617 "signature":"json -> json"}"#,
1618 );
1619 let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1620 match err {
1621 CatalogError::UninspectableArtifact { reason, .. } => {
1622 assert!(reason.contains("doesn't fit"), "{reason}")
1623 }
1624 other => panic!("expected UninspectableArtifact, got {other:?}"),
1625 }
1626 }
1627
1628 #[test]
1629 fn a_node_whose_offset_and_len_exactly_fit_the_stage_bytes_is_fine() {
1630 let mut bundle = make_bundle(
1631 br#"{"nodes":[{"name":"ok","kind":"block","resolved":null,
1632 "signature":"json -> json","offset":0,"len":3}],
1633 "signature":"json -> json"}"#,
1634 );
1635 bundle.extend_from_slice(b"abc");
1636 let sig = read_bundle_signature(&bundle, "test.cfbundle").unwrap();
1637 assert_eq!(sig, "json -> json");
1638 }
1639
1640 #[test]
1641 fn an_overflowing_node_offset_plus_len_is_uninspectable_not_a_panic() {
1642 let bundle = make_bundle(
1646 format!(
1647 r#"{{"nodes":[{{"name":"bad","kind":"block","resolved":null,
1648 "signature":"json -> json","offset":{},"len":10}}],
1649 "signature":"json -> json"}}"#,
1650 u64::MAX
1651 )
1652 .as_bytes(),
1653 );
1654 let err = read_bundle_signature(&bundle, "test.cfbundle").unwrap_err();
1655 assert!(matches!(err, CatalogError::UninspectableArtifact { .. }));
1656 }
1657
1658 #[test]
1659 fn an_overflowing_manifest_len_is_uninspectable_not_a_panic() {
1660 let mut bytes = b"CFBD".to_vec();
1665 bytes.extend_from_slice(&u64::MAX.to_le_bytes());
1666 let err = read_bundle_signature(&bytes, "test.cfbundle").unwrap_err();
1667 match err {
1668 CatalogError::UninspectableArtifact { reason, .. } => {
1669 assert!(
1670 reason.contains("exceeds the file's actual length"),
1671 "{reason}"
1672 )
1673 }
1674 other => panic!("expected UninspectableArtifact, got {other:?}"),
1675 }
1676 }
1677
1678 #[test]
1679 fn a_file_shorter_than_the_header_is_uninspectable() {
1680 let err = read_bundle_signature(b"CFBD", "test.cfbundle").unwrap_err();
1681 match err {
1682 CatalogError::UninspectableArtifact { reason, .. } => {
1683 assert!(
1684 reason.contains("shorter than the bundle header"),
1685 "{reason}"
1686 )
1687 }
1688 other => panic!("expected UninspectableArtifact, got {other:?}"),
1689 }
1690 }
1691
1692 #[test]
1693 fn adding_a_wasm_block_with_no_cf_signature_export_caches_the_permissive_default_and_flags_it()
1694 {
1695 let catalog_dir = tempfile::tempdir().unwrap();
1696 let wasm_dir = tempfile::tempdir().unwrap();
1697 let wasm_path = wasm_dir.path().join("no_sig.wasm");
1698 std::fs::write(
1699 &wasm_path,
1700 wat::parse_str(r#"(module (memory (export "memory") 1))"#).unwrap(),
1701 )
1702 .unwrap();
1703
1704 let catalog = Catalog::open(catalog_dir.path());
1705 let outcome = catalog
1706 .add("no-sig@1", &wasm_path, &wasmtime::Engine::default())
1707 .expect("a block missing cf_signature is not an add-time error");
1708
1709 assert_eq!(outcome.signature, "json -> json");
1710 assert!(
1711 outcome.is_permissive_default,
1712 "a block with no cf_signature export must be flagged, not silently accepted"
1713 );
1714 }
1715
1716 #[test]
1717 fn adding_wasm_magic_bytes_with_an_invalid_module_body_is_uninspectable() {
1718 let catalog_dir = tempfile::tempdir().unwrap();
1719 let wasm_dir = tempfile::tempdir().unwrap();
1720 let wasm_path = wasm_dir.path().join("broken.wasm");
1721 std::fs::write(
1724 &wasm_path,
1725 b"\0asm\x01\x00\x00\x00garbage-not-a-real-module",
1726 )
1727 .unwrap();
1728
1729 let catalog = Catalog::open(catalog_dir.path());
1730 let err = catalog
1731 .add("broken@1", &wasm_path, &wasmtime::Engine::default())
1732 .unwrap_err();
1733 assert!(
1734 matches!(err, CatalogError::UninspectableArtifact { .. }),
1735 "{err:?}"
1736 );
1737 }
1738
1739 #[test]
1740 fn a_cf_signature_export_that_exists_but_returns_unparseable_bytes_is_uninspectable_not_permissive(
1741 ) {
1742 let catalog_dir = tempfile::tempdir().unwrap();
1750 let wasm_dir = tempfile::tempdir().unwrap();
1751 let wasm_path = wasm_dir.path().join("broken_sig.wasm");
1752 std::fs::write(
1753 &wasm_path,
1754 wat::parse_str(
1755 r#"(module
1756 (memory (export "memory") 1)
1757 (func (export "cf_signature") (result i32) i32.const 0)
1758 )"#,
1759 )
1760 .unwrap(),
1761 )
1762 .unwrap();
1763
1764 let catalog = Catalog::open(catalog_dir.path());
1765 let err = catalog
1766 .add("broken-sig@1", &wasm_path, &wasmtime::Engine::default())
1767 .unwrap_err();
1768 assert!(
1769 matches!(err, CatalogError::UninspectableArtifact { .. }),
1770 "present-but-unparseable cf_signature must be a hard failure, not the permissive default: {err:?}"
1771 );
1772 }
1773
1774 #[test]
1775 fn adding_a_bundle_reads_its_signature_from_the_manifest_never_instantiating_wasm() {
1776 let catalog_dir = tempfile::tempdir().unwrap();
1777 let bundle_dir = tempfile::tempdir().unwrap();
1778 let bundle_path = bundle_dir.path().join("digest.cfbundle");
1779 std::fs::write(
1780 &bundle_path,
1781 make_bundle(
1782 br#"{"nodes":[],"edges":[],"signature":"{path: text} -> {summary: text}"}"#,
1783 ),
1784 )
1785 .unwrap();
1786
1787 let catalog = Catalog::open(catalog_dir.path());
1788 let outcome = catalog
1789 .add("digest@1", &bundle_path, &wasmtime::Engine::default())
1790 .unwrap();
1791
1792 assert_eq!(outcome.kind, ArtifactKind::Bundle);
1793 assert_eq!(outcome.signature, "{path: text} -> {summary: text}");
1794 assert!(!outcome.is_permissive_default);
1795 }
1796
1797 #[test]
1798 fn adding_a_file_with_neither_magic_is_unrecognized_not_a_silent_guess() {
1799 let catalog_dir = tempfile::tempdir().unwrap();
1800 let junk_dir = tempfile::tempdir().unwrap();
1801 let junk_path = junk_dir.path().join("junk.bin");
1802 std::fs::write(&junk_path, b"not a wasm or bundle").unwrap();
1803
1804 let catalog = Catalog::open(catalog_dir.path());
1805 let err = catalog
1806 .add("junk@1", &junk_path, &wasmtime::Engine::default())
1807 .unwrap_err();
1808 assert!(
1809 matches!(err, CatalogError::UnrecognizedArtifact { .. }),
1810 "{err:?}"
1811 );
1812 }
1813
1814 #[test]
1815 fn re_adding_the_same_name_version_is_rejected() {
1816 let catalog_dir = tempfile::tempdir().unwrap();
1817 let wasm_dir = tempfile::tempdir().unwrap();
1818 let wasm_path = wasm_dir.path().join("a.wasm");
1819 std::fs::write(
1820 &wasm_path,
1821 wat::parse_str(r#"(module (memory (export "memory") 1))"#).unwrap(),
1822 )
1823 .unwrap();
1824
1825 let catalog = Catalog::open(catalog_dir.path());
1826 let engine = wasmtime::Engine::default();
1827 catalog.add("dup@1", &wasm_path, &engine).unwrap();
1828
1829 let err = catalog.add("dup@1", &wasm_path, &engine).unwrap_err();
1830 assert!(matches!(err, CatalogError::AlreadyExists { .. }), "{err:?}");
1831 }
1832
1833 #[test]
1834 fn list_show_rm_roundtrip() {
1835 let dir = tempfile::tempdir().unwrap();
1836 seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
1837 let catalog = Catalog::open(dir.path());
1838
1839 assert_eq!(catalog.list().unwrap().len(), 1);
1840 let shown = catalog
1841 .show("a@1")
1842 .expect("just-seeded entry must be visible");
1843 assert_eq!(shown.signature, "json -> json");
1844
1845 catalog.rm("a@1").unwrap();
1846 assert!(catalog.list().unwrap().is_empty());
1847 }
1848
1849 #[test]
1850 fn showing_a_missing_entry_reports_not_found_with_a_suggestion() {
1851 let dir = tempfile::tempdir().unwrap();
1852 seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
1853 let catalog = Catalog::open(dir.path());
1854
1855 let err = catalog.show("summarise@1").unwrap_err();
1856 let CatalogError::NotFound { did_you_mean, .. } = &err else {
1857 panic!("expected NotFound, got {err:?}")
1858 };
1859 assert_eq!(did_you_mean, &vec!["summarize@1".to_string()]);
1860 }
1861
1862 fn distinct_wasm(dir: &Path, name: &str, body_marker: u32) -> PathBuf {
1866 let path = dir.join(format!("{name}.wasm"));
1867 std::fs::write(
1868 &path,
1869 wat::parse_str(format!(
1870 r#"(module (memory (export "memory") 1) (func (export "marker") (result i32) i32.const {body_marker}))"#
1871 ))
1872 .unwrap(),
1873 )
1874 .unwrap();
1875 path
1876 }
1877
1878 #[test]
1879 fn an_identifier_with_no_at_version_is_rejected_rather_than_catalogued_under_a_typo() {
1880 let catalog_dir = tempfile::tempdir().unwrap();
1881 let wasm_dir = tempfile::tempdir().unwrap();
1882 let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1883
1884 let err = Catalog::open(catalog_dir.path())
1885 .add("echo-summarize", &wasm, &wasmtime::Engine::default())
1886 .expect_err("dropping @version is a typo, not a name meaning itself");
1887
1888 assert!(
1889 matches!(err, CatalogError::InvalidNameVersion { .. }),
1890 "{err:?}"
1891 );
1892 assert!(
1893 Catalog::open(catalog_dir.path()).list().unwrap().is_empty(),
1894 "a rejected identifier must not leave an entry behind"
1895 );
1896 }
1897
1898 #[test]
1899 fn an_identifier_with_an_empty_name_or_version_is_rejected() {
1900 let catalog_dir = tempfile::tempdir().unwrap();
1901 let wasm_dir = tempfile::tempdir().unwrap();
1902 let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1903 let catalog = Catalog::open(catalog_dir.path());
1904 let engine = wasmtime::Engine::default();
1905
1906 for bad in ["@1", "name@", "", " "] {
1907 let err = catalog
1908 .add(bad, &wasm, &engine)
1909 .expect_err("an empty name or version is not a name@version");
1910 assert!(
1911 matches!(err, CatalogError::InvalidNameVersion { .. }),
1912 "{bad:?} gave {err:?}"
1913 );
1914 }
1915 }
1916
1917 #[test]
1918 fn an_identifier_with_more_than_one_at_separator_is_rejected() {
1919 let catalog_dir = tempfile::tempdir().unwrap();
1920 let wasm_dir = tempfile::tempdir().unwrap();
1921 let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1922
1923 let err = Catalog::open(catalog_dir.path())
1924 .add("a@b@c", &wasm, &wasmtime::Engine::default())
1925 .expect_err("two '@' separators is not a name@version");
1926 assert!(
1927 matches!(err, CatalogError::InvalidNameVersion { .. }),
1928 "{err:?}"
1929 );
1930 }
1931
1932 #[test]
1933 fn an_identifier_containing_path_or_whitespace_characters_is_rejected() {
1934 let catalog_dir = tempfile::tempdir().unwrap();
1935 let wasm_dir = tempfile::tempdir().unwrap();
1936 let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1937 let catalog = Catalog::open(catalog_dir.path());
1938 let engine = wasmtime::Engine::default();
1939
1940 for bad in ["../../etc/passwd@1", "with space@1", "name@../../tmp/pwn"] {
1941 let err = catalog
1942 .add(bad, &wasm, &engine)
1943 .expect_err("{bad} must be rejected");
1944 assert!(
1945 matches!(err, CatalogError::InvalidNameVersion { .. }),
1946 "{bad:?} gave {err:?}"
1947 );
1948 }
1949 }
1950
1951 #[test]
1952 fn an_ordinary_name_at_version_still_catalogs() {
1953 let catalog_dir = tempfile::tempdir().unwrap();
1954 let wasm_dir = tempfile::tempdir().unwrap();
1955 let wasm = distinct_wasm(wasm_dir.path(), "block", 1);
1956
1957 Catalog::open(catalog_dir.path())
1958 .add(
1959 "echo-summarize@1.2.3-rc.1",
1960 &wasm,
1961 &wasmtime::Engine::default(),
1962 )
1963 .expect("letters, digits, '.', '-' and '_' are all legal");
1964 }
1965
1966 #[test]
1970 fn a_pre_existing_junk_identifier_can_still_be_shown_and_removed() {
1971 let dir = tempfile::tempdir().unwrap();
1972 seed(dir.path(), "no-at-sign", "2026-01-01T00:00:00Z");
1973 let catalog = Catalog::open(dir.path());
1974
1975 catalog
1976 .show("no-at-sign")
1977 .expect("an already-stored key must remain inspectable");
1978 catalog
1979 .rm("no-at-sign")
1980 .expect("an already-stored key must remain removable");
1981 }
1982
1983 #[test]
1984 fn re_adding_a_removed_version_with_the_same_bytes_is_allowed() {
1985 let catalog_dir = tempfile::tempdir().unwrap();
1986 let wasm_dir = tempfile::tempdir().unwrap();
1987 let wasm = distinct_wasm(wasm_dir.path(), "same", 7);
1988 let catalog = Catalog::open(catalog_dir.path());
1989 let engine = wasmtime::Engine::default();
1990
1991 catalog.add("thing@1", &wasm, &engine).unwrap();
1992 catalog.rm("thing@1").unwrap();
1993 catalog
1994 .add("thing@1", &wasm, &engine)
1995 .expect("re-adding identical bytes is an undo of the rm, not a rewrite of history");
1996
1997 assert_eq!(catalog.list().unwrap().len(), 1);
1998 }
1999
2000 #[test]
2004 fn re_adding_a_removed_version_with_different_bytes_is_rejected() {
2005 let catalog_dir = tempfile::tempdir().unwrap();
2006 let wasm_dir = tempfile::tempdir().unwrap();
2007 let original = distinct_wasm(wasm_dir.path(), "original", 1);
2008 let replacement = distinct_wasm(wasm_dir.path(), "replacement", 2);
2009 let catalog = Catalog::open(catalog_dir.path());
2010 let engine = wasmtime::Engine::default();
2011
2012 catalog.add("thing@1", &original, &engine).unwrap();
2013 catalog.rm("thing@1").unwrap();
2014
2015 let err = catalog
2016 .add("thing@1", &replacement, &engine)
2017 .expect_err("rm must not be a way to republish a version with new content");
2018 let CatalogError::RetiredWithDifferentContent {
2019 name_version,
2020 previous_hash,
2021 new_hash,
2022 } = &err
2023 else {
2024 panic!("expected RetiredWithDifferentContent, got {err:?}")
2025 };
2026 assert_eq!(name_version, "thing@1");
2027 assert_ne!(previous_hash, new_hash);
2028 assert!(
2029 catalog.list().unwrap().is_empty(),
2030 "the reject must not add"
2031 );
2032 }
2033
2034 #[test]
2038 fn an_index_written_without_the_retired_field_still_loads() {
2039 let dir = tempfile::tempdir().unwrap();
2040 std::fs::create_dir_all(dir.path()).unwrap();
2041 std::fs::write(
2042 dir.path().join("index.json"),
2043 br#"{"version":1,"entries":{"old@1":{"hash":"sha256:ab","kind":"block","signature":"json -> json","created_at":"2026-01-01T00:00:00Z"}}}"#,
2044 )
2045 .unwrap();
2046
2047 let index = read_index(dir.path()).expect("an index predating `retired` is not corrupt");
2048 assert!(index.entries.contains_key("old@1"));
2049 assert!(index.retired.is_empty());
2050 }
2051
2052 #[test]
2053 fn removing_a_missing_entry_is_not_found_not_a_silent_no_op() {
2054 let dir = tempfile::tempdir().unwrap();
2055 let catalog = Catalog::open(dir.path());
2056 let err = catalog.rm("nothing@1").unwrap_err();
2057 assert!(matches!(err, CatalogError::NotFound { .. }), "{err:?}");
2058 }
2059
2060 #[test]
2061 fn removing_an_entry_leaves_its_blob_on_disk_v1_has_no_garbage_collection() {
2062 let dir = tempfile::tempdir().unwrap();
2063 let hash = write_blob(dir.path(), b"some block bytes").unwrap();
2064 let hex = hash.strip_prefix("sha256:").unwrap();
2065 with_locked_index(dir.path(), |index| {
2066 index.entries.insert(
2067 "a@1".to_string(),
2068 Entry {
2069 hash: hash.clone(),
2070 kind: ArtifactKind::Block,
2071 signature: "json -> json".to_string(),
2072 created_at: "2026-01-01T00:00:00Z".to_string(),
2073 },
2074 );
2075 Ok::<_, CatalogError>(())
2076 })
2077 .unwrap();
2078
2079 let catalog = Catalog::open(dir.path());
2080 catalog.rm("a@1").unwrap();
2081
2082 assert!(
2083 matches!(catalog.show("a@1"), Err(CatalogError::NotFound { .. })),
2084 "rm must actually remove the index entry, not silently no-op"
2085 );
2086 assert!(
2087 dir.path().join("blobs").join(hex).exists(),
2088 "rm is index-only; the blob must remain"
2089 );
2090 }
2091
2092 #[test]
2093 fn list_returns_multiple_entries_sorted_by_name_at_version() {
2094 let dir = tempfile::tempdir().unwrap();
2095 seed(dir.path(), "b@1", "2026-01-01T00:00:00Z");
2096 seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2097 seed(dir.path(), "c@1", "2026-01-01T00:00:00Z");
2098
2099 let catalog = Catalog::open(dir.path());
2100 let names: Vec<String> = catalog
2101 .list()
2102 .unwrap()
2103 .into_iter()
2104 .map(|(name_version, _)| name_version)
2105 .collect();
2106
2107 assert_eq!(
2108 names,
2109 vec!["a@1".to_string(), "b@1".to_string(), "c@1".to_string()]
2110 );
2111 }
2112
2113 #[test]
2114 fn resolve_a_dot_wasm_suffix_is_direct_even_if_the_file_does_not_exist() {
2115 let dir = tempfile::tempdir().unwrap();
2116 let catalog = Catalog::open(dir.path());
2117 let resolved = catalog
2118 .resolve("/nonexistent/block.wasm", ResolutionContext::Interactive)
2119 .unwrap();
2120 assert!(matches!(resolved, Resolved::Direct(_)));
2121 }
2122
2123 #[test]
2124 fn resolve_a_dot_cfbundle_suffix_is_direct_even_if_the_file_does_not_exist() {
2125 let dir = tempfile::tempdir().unwrap();
2126 let catalog = Catalog::open(dir.path());
2127 let resolved = catalog
2128 .resolve(
2129 "/nonexistent/bundle.cfbundle",
2130 ResolutionContext::Interactive,
2131 )
2132 .unwrap();
2133 assert!(matches!(resolved, Resolved::Direct(_)));
2134 }
2135
2136 #[test]
2137 fn resolve_an_existing_filesystem_path_is_direct_no_catalog_lookup() {
2138 let dir = tempfile::tempdir().unwrap();
2139 let real_file = tempfile::NamedTempFile::new().unwrap();
2140 let catalog = Catalog::open(dir.path());
2141 let resolved = catalog
2142 .resolve(
2143 real_file.path().to_str().unwrap(),
2144 ResolutionContext::Interactive,
2145 )
2146 .unwrap();
2147 assert!(matches!(resolved, Resolved::Direct(_)));
2148 }
2149
2150 #[test]
2151 fn resolve_exact_name_at_version_hits_case_sensitively() {
2152 let dir = tempfile::tempdir().unwrap();
2153 seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
2154 let catalog = Catalog::open(dir.path());
2155
2156 assert!(catalog
2157 .resolve("summarize@1", ResolutionContext::Interactive)
2158 .is_ok());
2159
2160 let err = catalog
2161 .resolve("Summarize@1", ResolutionContext::Interactive)
2162 .unwrap_err();
2163 let CatalogError::NotFound { did_you_mean, .. } = &err else {
2164 panic!("expected NotFound (case-sensitive miss), got {err:?}")
2165 };
2166 assert!(
2167 did_you_mean.contains(&"summarize@1".to_string()),
2168 "case-sensitivity rejects the hit, but edit distance 1 should still suggest it: {did_you_mean:?}"
2169 );
2170 }
2171
2172 #[test]
2173 fn resolve_unqualified_name_picks_the_latest_by_created_at() {
2174 let dir = tempfile::tempdir().unwrap();
2175 seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2176 seed(dir.path(), "a@2", "2026-06-01T00:00:00Z");
2177 let catalog = Catalog::open(dir.path());
2178
2179 let resolved = catalog
2180 .resolve("a", ResolutionContext::Interactive)
2181 .unwrap();
2182 let Resolved::Cataloged { name_version, .. } = resolved else {
2183 panic!("expected a cataloged resolution")
2184 };
2185 assert_eq!(name_version, "a@2");
2186 }
2187
2188 #[test]
2189 fn resolve_unqualified_name_is_legal_from_an_interactive_context() {
2190 let dir = tempfile::tempdir().unwrap();
2191 seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2192 let catalog = Catalog::open(dir.path());
2193 assert!(catalog.resolve("a", ResolutionContext::Interactive).is_ok());
2194 }
2195
2196 #[test]
2197 fn resolve_unqualified_name_is_rejected_in_a_durable_context() {
2198 let dir = tempfile::tempdir().unwrap();
2199 seed(dir.path(), "a@1", "2026-01-01T00:00:00Z");
2200 let catalog = Catalog::open(dir.path());
2201 let err = catalog
2202 .resolve("a", ResolutionContext::Durable)
2203 .unwrap_err();
2204 assert!(
2205 matches!(err, CatalogError::UnqualifiedName { .. }),
2206 "{err:?}"
2207 );
2208 }
2209
2210 #[test]
2211 fn resolve_not_found_suggests_a_close_typo() {
2212 let dir = tempfile::tempdir().unwrap();
2213 seed(dir.path(), "summarize@1", "2026-01-01T00:00:00Z");
2214 let catalog = Catalog::open(dir.path());
2215 let err = catalog
2216 .resolve("summarise@1", ResolutionContext::Interactive)
2217 .unwrap_err();
2218 let CatalogError::NotFound { did_you_mean, .. } = &err else {
2219 panic!("expected NotFound, got {err:?}")
2220 };
2221 assert_eq!(did_you_mean, &vec!["summarize@1".to_string()]);
2222 }
2223
2224 #[test]
2225 fn read_blob_returns_what_add_wrote() {
2226 let dir = tempfile::tempdir().unwrap();
2227 let catalog = Catalog::open(dir.path());
2228 let engine = wasmtime::Engine::default();
2229 let wasm = wat::parse_str("(module)").unwrap();
2230 let path = dir.path().join("m.wasm");
2231 std::fs::write(&path, &wasm).unwrap();
2232
2233 let outcome = catalog.add("m@1", &path, &engine).unwrap();
2234 let entry = catalog.show("m@1").unwrap();
2235
2236 let bytes = catalog.read_blob(&entry).unwrap();
2237 assert_eq!(bytes, wasm);
2238 assert_eq!(outcome.name_version, "m@1");
2239 }
2240
2241 #[test]
2242 fn read_blob_on_a_hand_edited_missing_hash_errors_clearly() {
2243 let dir = tempfile::tempdir().unwrap();
2244 let catalog = Catalog::open(dir.path());
2245 let fake = Entry {
2246 hash: "sha256:0000000000000000000000000000000000000000000000000000000000000000"
2247 .to_string(),
2248 ..entry_fixture("2026-01-01T00:00:00Z")
2249 };
2250 let err = catalog.read_blob(&fake).unwrap_err();
2251 match err {
2252 CatalogError::Io(ref io_err) => {
2253 assert_eq!(
2254 io_err.kind(),
2255 std::io::ErrorKind::NotFound,
2256 "a well-formed hash with no matching blob file must surface as a plain \
2257 not-found I/O error: {err:?}"
2258 );
2259 }
2260 other => {
2261 panic!("a well-formed but absent hash must be a plain Io(NotFound), not {other:?}")
2262 }
2263 }
2264 }
2265
2266 #[test]
2267 fn read_blob_rejects_a_path_traversal_hash_instead_of_touching_the_filesystem() {
2268 let dir = tempfile::tempdir().unwrap();
2276 std::fs::write(dir.path().join("outside.txt"), b"do not leak this").unwrap();
2279
2280 let catalog = Catalog::open(dir.path());
2281 let traversal = Entry {
2282 hash: "sha256:../outside.txt".to_string(),
2283 ..entry_fixture("2026-01-01T00:00:00Z")
2284 };
2285 let err = catalog.read_blob(&traversal).unwrap_err();
2286 assert!(
2287 matches!(err, CatalogError::MalformedHash { .. }),
2288 "a path-traversal hash must be rejected as MalformedHash before any path is \
2289 constructed, got {err:?}"
2290 );
2291
2292 let absolute = Entry {
2293 hash: "sha256:/etc/passwd".to_string(),
2294 ..entry_fixture("2026-01-01T00:00:00Z")
2295 };
2296 let err = catalog.read_blob(&absolute).unwrap_err();
2297 assert!(
2298 matches!(err, CatalogError::MalformedHash { .. }),
2299 "an absolute-path-like hash must be rejected as MalformedHash before any path is \
2300 constructed, got {err:?}"
2301 );
2302 }
2303
2304 #[test]
2305 fn a_simple_lowercase_name_is_valid() {
2306 assert!(validate_block_name("my-block").is_ok());
2307 }
2308
2309 #[test]
2310 fn a_name_with_a_dot_is_rejected() {
2311 let err = validate_block_name("my.block").unwrap_err();
2312 assert!(err.to_string().contains('.'), "{err}");
2313 }
2314
2315 #[test]
2316 fn a_name_starting_with_a_digit_is_rejected() {
2317 assert!(validate_block_name("1block").is_err());
2318 }
2319
2320 #[test]
2321 fn a_windows_reserved_device_name_is_rejected_case_insensitively() {
2322 for bad in ["con", "CON", "Con", "aux", "nul", "com1", "lpt9"] {
2323 assert!(
2324 validate_block_name(bad).is_err(),
2325 "{bad} should be rejected"
2326 );
2327 }
2328 }
2329
2330 #[test]
2331 fn a_name_that_only_resembles_a_reserved_name_is_accepted() {
2332 assert!(validate_block_name("console").is_ok());
2333 assert!(validate_block_name("commander").is_ok());
2334 }
2335}