1use serde_json::{Value, json};
9use sha2::{Digest, Sha256};
10use std::fmt::Write as _;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::time::{SystemTime, UNIX_EPOCH};
14use traverse_contracts::parse_contract;
15use traverse_registry::{
16 PublicRegistryCapabilityRecord, RegistryReference, ResolvedRegistryComponent,
17 SyncedPublicRegistryState,
18};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum RegistryCacheErrorCode {
23 RegistrySyncMissing,
25 RegistryVersionNotFound,
27 RegistryDependencyYanked,
29 RegistryPrepareFailed,
31 RegistryArtifactDigestMismatch,
33 RegistryCacheEntryMissing,
35}
36
37impl RegistryCacheErrorCode {
38 #[must_use]
40 pub fn as_str(self) -> &'static str {
41 match self {
42 Self::RegistrySyncMissing => "registry_sync_missing",
43 Self::RegistryVersionNotFound => "registry_version_not_found",
44 Self::RegistryDependencyYanked => "registry_dependency_yanked",
45 Self::RegistryPrepareFailed => "registry_prepare_failed",
46 Self::RegistryArtifactDigestMismatch => "registry_artifact_digest_mismatch",
47 Self::RegistryCacheEntryMissing => "registry_cache_entry_missing",
48 }
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct RegistryCacheError {
55 pub code: RegistryCacheErrorCode,
57 pub message: String,
59}
60
61impl RegistryCacheError {
62 fn new(code: RegistryCacheErrorCode, message: impl Into<String>) -> Self {
63 Self {
64 code,
65 message: message.into(),
66 }
67 }
68}
69
70pub trait RegistryArtifactFetcher {
72 fn fetch(&self, url: &str) -> Result<Vec<u8>, String>;
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct HostRegistryCache {
83 root: PathBuf,
84}
85
86impl HostRegistryCache {
87 #[must_use]
92 pub fn new(root: impl Into<PathBuf>) -> Self {
93 Self { root: root.into() }
94 }
95
96 #[must_use]
98 pub fn root(&self) -> &Path {
99 &self.root
100 }
101
102 pub fn evict(&self, artifact_digest: &str) -> Result<(), RegistryCacheError> {
109 let digest = normalize_digest(artifact_digest).ok_or_else(|| {
110 RegistryCacheError::new(
111 RegistryCacheErrorCode::RegistryPrepareFailed,
112 "artifact digest must be sha256: followed by 64 hex characters",
113 )
114 })?;
115 let artifact = self.artifact_path(&digest);
116 let meta = self.meta_path(&digest);
117 if artifact.exists() {
118 fs::remove_file(&artifact).map_err(|error| {
119 RegistryCacheError::new(
120 RegistryCacheErrorCode::RegistryPrepareFailed,
121 format!("failed to evict verified registry artifact: {error}"),
122 )
123 })?;
124 }
125 if meta.exists() {
126 fs::remove_file(&meta).map_err(|error| {
127 RegistryCacheError::new(
128 RegistryCacheErrorCode::RegistryPrepareFailed,
129 format!("failed to evict registry cache metadata: {error}"),
130 )
131 })?;
132 }
133 Ok(())
134 }
135
136 pub fn evict_all(&self) -> Result<(), RegistryCacheError> {
143 for sub in ["sha256", "meta", "refs"] {
144 let path = self.root.join(sub);
145 if path.exists() {
146 fs::remove_dir_all(&path).map_err(|error| {
147 RegistryCacheError::new(
148 RegistryCacheErrorCode::RegistryPrepareFailed,
149 format!("failed to clear host registry cache: {error}"),
150 )
151 })?;
152 }
153 }
154 Ok(())
155 }
156
157 fn artifact_path(&self, digest_hex: &str) -> PathBuf {
158 self.root.join("sha256").join(digest_hex)
159 }
160
161 fn meta_path(&self, digest_hex: &str) -> PathBuf {
162 self.root.join("meta").join(format!("{digest_hex}.json"))
163 }
164
165 fn ref_path(&self, reference: &RegistryReference) -> PathBuf {
166 let key = sha256_hex(
167 format!(
168 "{}:{}:{}",
169 reference.namespace, reference.id, reference.version_range
170 )
171 .as_bytes(),
172 );
173 self.root.join("refs").join(format!("{key}.json"))
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct RegistryPrepareEvidence {
180 pub namespace: String,
182 pub id: String,
184 pub selected_version: String,
186 pub version_range: String,
188 pub source_release: String,
190 pub index_digest: String,
192 pub artifact_digest: String,
194 pub verified_at: u64,
196 pub outcome: &'static str,
198}
199
200impl RegistryPrepareEvidence {
201 #[must_use]
203 pub fn as_value(&self) -> Value {
204 json!({
205 "namespace": self.namespace,
206 "id": self.id,
207 "selected_version": self.selected_version,
208 "version_range": self.version_range,
209 "source_release": self.source_release,
210 "index_digest": self.index_digest,
211 "artifact_digest": self.artifact_digest,
212 "verified_at": self.verified_at,
213 "outcome": self.outcome,
214 })
215 }
216}
217
218#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
219struct CacheEntryMeta {
220 namespace: String,
221 id: String,
222 selected_version: String,
223 version_range: String,
224 source_release: String,
225 index_digest: String,
226 artifact_digest: String,
227 contract_digest: String,
228 verified_at: u64,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct VerifiedRegistryDependency {
234 pub wasm_binary_path: PathBuf,
236 pub contract_path: PathBuf,
238 pub contract_bytes: Vec<u8>,
240 pub wasm_digest: String,
242 pub evidence: RegistryPrepareEvidence,
244}
245
246pub fn prepare(
256 cache: &HostRegistryCache,
257 snapshot: &SyncedPublicRegistryState,
258 reference: &RegistryReference,
259 fetcher: &dyn RegistryArtifactFetcher,
260) -> Result<RegistryPrepareEvidence, RegistryCacheError> {
261 if snapshot.capabilities.is_empty() {
262 return Err(RegistryCacheError::new(
263 RegistryCacheErrorCode::RegistrySyncMissing,
264 "synced registry index snapshot contains no capabilities",
265 ));
266 }
267 let record = select_highest_active(snapshot, reference)?;
268 let index_digest = index_snapshot_digest(snapshot);
269 let artifact_bytes = fetcher.fetch(&record.artifact_url).map_err(|message| {
270 RegistryCacheError::new(
271 RegistryCacheErrorCode::RegistryPrepareFailed,
272 format!("host registry artifact fetch failed: {message}"),
273 )
274 })?;
275 let artifact_hex = verify_digest(&record.digest, &artifact_bytes)?;
276 let contract_bytes = fetcher.fetch(&record.contract_url).map_err(|message| {
277 RegistryCacheError::new(
278 RegistryCacheErrorCode::RegistryPrepareFailed,
279 format!("host registry contract fetch failed: {message}"),
280 )
281 })?;
282 let contract_hex = verify_digest(&record.contract_digest, &contract_bytes)?;
283
284 write_verified_bytes(cache, &artifact_hex, &artifact_bytes)?;
285 write_verified_bytes(cache, &contract_hex, &contract_bytes)?;
286
287 let verified_at = unix_seconds();
288 let meta = CacheEntryMeta {
289 namespace: record.namespace.clone(),
290 id: record.id.clone(),
291 selected_version: record.version.clone(),
292 version_range: reference.version_range.clone(),
293 source_release: snapshot.release_tag.clone(),
294 index_digest: index_digest.clone(),
295 artifact_digest: record.digest.clone(),
296 contract_digest: record.contract_digest.clone(),
297 verified_at,
298 };
299 write_json_atomic(&cache.meta_path(&artifact_hex), &meta)?;
300 write_json_atomic(
301 &cache.ref_path(reference),
302 &json!({
303 "artifact_digest": record.digest,
304 "contract_digest": record.contract_digest,
305 }),
306 )?;
307
308 Ok(RegistryPrepareEvidence {
309 namespace: record.namespace,
310 id: record.id,
311 selected_version: record.version,
312 version_range: reference.version_range.clone(),
313 source_release: snapshot.release_tag.clone(),
314 index_digest,
315 artifact_digest: record.digest,
316 verified_at,
317 outcome: "prepared",
318 })
319}
320
321pub fn resolve_offline(
329 cache: &HostRegistryCache,
330 reference: &RegistryReference,
331) -> Result<VerifiedRegistryDependency, RegistryCacheError> {
332 let ref_path = cache.ref_path(reference);
333 let ref_bytes = fs::read(&ref_path).map_err(|_| {
334 RegistryCacheError::new(
335 RegistryCacheErrorCode::RegistryCacheEntryMissing,
336 "verified registry cache entry is missing for registry_ref",
337 )
338 })?;
339 let pointer: Value = serde_json::from_slice(&ref_bytes).map_err(|_| {
340 RegistryCacheError::new(
341 RegistryCacheErrorCode::RegistryCacheEntryMissing,
342 "verified registry cache entry is missing for registry_ref",
343 )
344 })?;
345 let artifact_digest = pointer
346 .get("artifact_digest")
347 .and_then(Value::as_str)
348 .ok_or_else(|| {
349 RegistryCacheError::new(
350 RegistryCacheErrorCode::RegistryCacheEntryMissing,
351 "verified registry cache entry is missing for registry_ref",
352 )
353 })?;
354 let contract_digest = pointer
355 .get("contract_digest")
356 .and_then(Value::as_str)
357 .ok_or_else(|| {
358 RegistryCacheError::new(
359 RegistryCacheErrorCode::RegistryCacheEntryMissing,
360 "verified registry cache entry is missing for registry_ref",
361 )
362 })?;
363 let artifact_hex = normalize_digest(artifact_digest).ok_or_else(|| {
364 RegistryCacheError::new(
365 RegistryCacheErrorCode::RegistryCacheEntryMissing,
366 "verified registry cache entry is missing for registry_ref",
367 )
368 })?;
369 let contract_hex = normalize_digest(contract_digest).ok_or_else(|| {
370 RegistryCacheError::new(
371 RegistryCacheErrorCode::RegistryCacheEntryMissing,
372 "verified registry cache entry is missing for registry_ref",
373 )
374 })?;
375 let (wasm_binary_path, _) = read_verified(cache, &artifact_hex, artifact_digest)?;
376 let (contract_path, contract_bytes) = read_verified(cache, &contract_hex, contract_digest)?;
377 let meta_bytes = fs::read(cache.meta_path(&artifact_hex)).map_err(|_| {
378 RegistryCacheError::new(
379 RegistryCacheErrorCode::RegistryCacheEntryMissing,
380 "verified registry cache entry is missing for registry_ref",
381 )
382 })?;
383 let meta: CacheEntryMeta = serde_json::from_slice(&meta_bytes).map_err(|_| {
384 RegistryCacheError::new(
385 RegistryCacheErrorCode::RegistryCacheEntryMissing,
386 "verified registry cache entry is missing for registry_ref",
387 )
388 })?;
389 Ok(VerifiedRegistryDependency {
390 wasm_binary_path,
391 contract_path,
392 contract_bytes,
393 wasm_digest: artifact_digest.to_string(),
394 evidence: RegistryPrepareEvidence {
395 namespace: meta.namespace,
396 id: meta.id,
397 selected_version: meta.selected_version,
398 version_range: meta.version_range,
399 source_release: meta.source_release,
400 index_digest: meta.index_digest,
401 artifact_digest: meta.artifact_digest,
402 verified_at: meta.verified_at,
403 outcome: "resolved",
404 },
405 })
406}
407
408pub fn resolve_component(
415 cache: &HostRegistryCache,
416 reference: &RegistryReference,
417) -> Result<ResolvedRegistryComponent, RegistryCacheError> {
418 let resolved = resolve_offline(cache, reference)?;
419 let contract_text = String::from_utf8(resolved.contract_bytes).map_err(|_| {
420 RegistryCacheError::new(
421 RegistryCacheErrorCode::RegistryPrepareFailed,
422 "verified registry contract is invalid: not utf-8",
423 )
424 })?;
425 let contract = parse_contract(&contract_text).map_err(|failure| {
426 RegistryCacheError::new(
427 RegistryCacheErrorCode::RegistryPrepareFailed,
428 format!(
429 "verified registry contract is invalid: {}",
430 failure
431 .errors
432 .first()
433 .map_or("parse failed", |error| error.message.as_str())
434 ),
435 )
436 })?;
437 Ok(ResolvedRegistryComponent {
438 contract_path: resolved.contract_path,
439 contract,
440 wasm_binary_path: resolved.wasm_binary_path,
441 wasm_digest: resolved.wasm_digest,
442 })
443}
444
445fn select_highest_active(
446 snapshot: &SyncedPublicRegistryState,
447 reference: &RegistryReference,
448) -> Result<PublicRegistryCapabilityRecord, RegistryCacheError> {
449 let requirement = semver::VersionReq::parse(&reference.version_range).map_err(|error| {
450 RegistryCacheError::new(
451 RegistryCacheErrorCode::RegistryVersionNotFound,
452 format!(
453 "invalid registry_ref version_range {}: {error}",
454 reference.version_range
455 ),
456 )
457 })?;
458 let matching = snapshot
459 .capabilities
460 .iter()
461 .filter_map(|record| {
462 if record.namespace != reference.namespace || record.id != reference.id {
463 return None;
464 }
465 semver::Version::parse(&record.version)
466 .ok()
467 .filter(|version| requirement.matches(version))
468 .map(|version| (version, record.clone()))
469 })
470 .collect::<Vec<_>>();
471 let mut active = matching
472 .iter()
473 .filter(|(_, record)| !record.deprecated)
474 .cloned()
475 .collect::<Vec<_>>();
476 active.sort_by(|left, right| right.0.cmp(&left.0));
477 if let Some((_, record)) = active.into_iter().next() {
478 return Ok(record);
479 }
480 if matching.is_empty() {
481 Err(RegistryCacheError::new(
482 RegistryCacheErrorCode::RegistryVersionNotFound,
483 format!(
484 "no synced public registry version for {}:{} satisfies {}",
485 reference.namespace, reference.id, reference.version_range
486 ),
487 ))
488 } else {
489 Err(RegistryCacheError::new(
490 RegistryCacheErrorCode::RegistryDependencyYanked,
491 format!(
492 "only yanked public registry versions for {}:{} satisfy {}",
493 reference.namespace, reference.id, reference.version_range
494 ),
495 ))
496 }
497}
498
499fn index_snapshot_digest(snapshot: &SyncedPublicRegistryState) -> String {
500 let encoded = serde_json::to_vec(snapshot).unwrap_or_default();
501 format!("sha256:{}", sha256_hex(&encoded))
502}
503
504fn verify_digest(declared: &str, bytes: &[u8]) -> Result<String, RegistryCacheError> {
505 let expected = normalize_digest(declared).ok_or_else(|| {
506 RegistryCacheError::new(
507 RegistryCacheErrorCode::RegistryArtifactDigestMismatch,
508 "registry digest must be sha256: followed by 64 hex characters",
509 )
510 })?;
511 if sha256_hex(bytes) == expected {
512 Ok(expected)
513 } else {
514 Err(RegistryCacheError::new(
515 RegistryCacheErrorCode::RegistryArtifactDigestMismatch,
516 "registry artifact bytes do not match the published digest",
517 ))
518 }
519}
520
521fn write_verified_bytes(
522 cache: &HostRegistryCache,
523 digest_hex: &str,
524 bytes: &[u8],
525) -> Result<PathBuf, RegistryCacheError> {
526 let path = cache.artifact_path(digest_hex);
527 if path.exists() {
528 let cached = fs::read(&path).map_err(|error| {
529 RegistryCacheError::new(
530 RegistryCacheErrorCode::RegistryPrepareFailed,
531 format!("failed to read existing verified cache entry: {error}"),
532 )
533 })?;
534 if sha256_hex(&cached) != digest_hex {
535 return Err(RegistryCacheError::new(
536 RegistryCacheErrorCode::RegistryArtifactDigestMismatch,
537 "existing registry cache entry digest mismatch",
538 ));
539 }
540 return Ok(path);
541 }
542 let parent = cache.root.join("sha256");
543 fs::create_dir_all(&parent).map_err(|error| {
544 RegistryCacheError::new(
545 RegistryCacheErrorCode::RegistryPrepareFailed,
546 format!("failed to create host registry cache directory: {error}"),
547 )
548 })?;
549 let temporary = path.with_extension("tmp");
550 fs::write(&temporary, bytes).map_err(|error| {
551 let _ = fs::remove_file(&temporary);
552 RegistryCacheError::new(
553 RegistryCacheErrorCode::RegistryPrepareFailed,
554 format!("failed to write registry cache entry: {error}"),
555 )
556 })?;
557 fs::rename(&temporary, &path).map_err(|error| {
558 let _ = fs::remove_file(&temporary);
559 RegistryCacheError::new(
560 RegistryCacheErrorCode::RegistryPrepareFailed,
561 format!("failed to commit registry cache entry: {error}"),
562 )
563 })?;
564 Ok(path)
565}
566
567fn read_verified(
568 cache: &HostRegistryCache,
569 digest_hex: &str,
570 declared: &str,
571) -> Result<(PathBuf, Vec<u8>), RegistryCacheError> {
572 let path = cache.artifact_path(digest_hex);
573 let bytes = fs::read(&path).map_err(|_| {
574 RegistryCacheError::new(
575 RegistryCacheErrorCode::RegistryCacheEntryMissing,
576 "verified registry cache entry is missing for registry_ref",
577 )
578 })?;
579 verify_digest(declared, &bytes)?;
580 Ok((path, bytes))
581}
582
583fn write_json_atomic(path: &Path, value: &impl serde::Serialize) -> Result<(), RegistryCacheError> {
584 let parent = path.parent().ok_or_else(|| {
585 RegistryCacheError::new(
586 RegistryCacheErrorCode::RegistryPrepareFailed,
587 "registry cache metadata path has no parent directory",
588 )
589 })?;
590 fs::create_dir_all(parent).map_err(|error| {
591 RegistryCacheError::new(
592 RegistryCacheErrorCode::RegistryPrepareFailed,
593 format!("failed to create host registry cache directory: {error}"),
594 )
595 })?;
596 let bytes = serde_json::to_vec(value).map_err(|error| {
597 RegistryCacheError::new(
598 RegistryCacheErrorCode::RegistryPrepareFailed,
599 format!("failed to encode registry cache metadata: {error}"),
600 )
601 })?;
602 let temporary = path.with_extension("tmp");
603 fs::write(&temporary, bytes).map_err(|error| {
604 let _ = fs::remove_file(&temporary);
605 RegistryCacheError::new(
606 RegistryCacheErrorCode::RegistryPrepareFailed,
607 format!("failed to write registry cache metadata: {error}"),
608 )
609 })?;
610 fs::rename(&temporary, path).map_err(|error| {
611 let _ = fs::remove_file(&temporary);
612 RegistryCacheError::new(
613 RegistryCacheErrorCode::RegistryPrepareFailed,
614 format!("failed to commit registry cache metadata: {error}"),
615 )
616 })?;
617 Ok(())
618}
619
620fn normalize_digest(digest: &str) -> Option<String> {
621 let digest = digest.strip_prefix("sha256:")?;
622 if digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
623 Some(digest.to_ascii_lowercase())
624 } else {
625 None
626 }
627}
628
629fn sha256_hex(bytes: &[u8]) -> String {
630 let digest = Sha256::digest(bytes);
631 let mut value = String::with_capacity(digest.len() * 2);
632 for byte in digest {
633 let _ = write!(value, "{byte:02x}");
634 }
635 value
636}
637
638fn unix_seconds() -> u64 {
639 SystemTime::now()
640 .duration_since(UNIX_EPOCH)
641 .map_or(0, |duration| duration.as_secs())
642}
643
644#[cfg(test)]
645mod tests {
646 #![allow(clippy::expect_used, clippy::unwrap_used)]
647
648 use super::*;
649 use std::collections::HashMap;
650 use std::sync::atomic::{AtomicU64, Ordering};
651
652 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
653
654 struct MapFetcher {
655 assets: HashMap<String, Vec<u8>>,
656 }
657
658 impl RegistryArtifactFetcher for MapFetcher {
659 fn fetch(&self, url: &str) -> Result<Vec<u8>, String> {
660 self.assets
661 .get(url)
662 .cloned()
663 .ok_or_else(|| "missing asset".to_string())
664 }
665 }
666
667 fn digest_for(bytes: &[u8]) -> String {
668 format!("sha256:{}", sha256_hex(bytes))
669 }
670
671 fn sample_snapshot(
672 deprecated: bool,
673 ) -> (SyncedPublicRegistryState, MapFetcher, RegistryReference) {
674 let artifact = b"wasm-bytes".to_vec();
675 let contract = br#"{"kind":"capability_contract"}"#.to_vec();
676 let artifact_digest = digest_for(&artifact);
677 let contract_digest = digest_for(&contract);
678 let record = PublicRegistryCapabilityRecord {
679 namespace: "demo".to_string(),
680 id: "greet".to_string(),
681 version: "1.2.0".to_string(),
682 digest: artifact_digest,
683 artifact_url: "https://example.test/greet.wasm".to_string(),
684 contract_digest,
685 contract_url: "https://example.test/greet.json".to_string(),
686 deprecated,
687 };
688 let older = PublicRegistryCapabilityRecord {
689 namespace: "demo".to_string(),
690 id: "greet".to_string(),
691 version: "1.0.0".to_string(),
692 digest: digest_for(b"older"),
693 artifact_url: "https://example.test/older.wasm".to_string(),
694 contract_digest: digest_for(b"older-contract"),
695 contract_url: "https://example.test/older.json".to_string(),
696 deprecated: false,
697 };
698 let snapshot = SyncedPublicRegistryState {
699 schema_version: "1".to_string(),
700 workspace_id: "ws".to_string(),
701 state_scope: "public".to_string(),
702 source_repo: "traverse-framework/registry".to_string(),
703 release_tag: "index-v9".to_string(),
704 index_version: 9,
705 generated_at: "2026-07-29T00:00:00Z".to_string(),
706 source_commit: None,
707 synced_at: "2026-07-29T00:00:00Z".to_string(),
708 record_count: 2,
709 validation_status: "valid".to_string(),
710 governing_spec: "055-registry-sync".to_string(),
711 capabilities: vec![older, record.clone()],
712 };
713 let mut assets = HashMap::new();
714 assets.insert(record.artifact_url.clone(), artifact);
715 assets.insert(record.contract_url.clone(), contract);
716 let fetcher = MapFetcher { assets };
717 let reference = RegistryReference {
718 namespace: "demo".to_string(),
719 id: "greet".to_string(),
720 version_range: "^1.0.0".to_string(),
721 };
722 (snapshot, fetcher, reference)
723 }
724
725 fn unique_cache() -> HostRegistryCache {
726 let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
727 let nanos = SystemTime::now()
728 .duration_since(UNIX_EPOCH)
729 .expect("clock")
730 .as_nanos();
731 let root = std::env::temp_dir().join(format!("traverse-embedder-cache-{nanos}-{counter}"));
732 fs::create_dir_all(&root).expect("temp");
733 HostRegistryCache::new(root)
734 }
735
736 #[test]
737 fn prepare_then_offline_resolve_round_trip() {
738 let cache = unique_cache();
739 let (snapshot, fetcher, reference) = sample_snapshot(false);
740 let evidence = prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
741 assert_eq!(evidence.selected_version, "1.2.0");
742 assert_eq!(evidence.source_release, "index-v9");
743 assert_eq!(evidence.outcome, "prepared");
744 let resolved = resolve_offline(&cache, &reference).expect("offline");
745 assert_eq!(resolved.evidence.selected_version, "1.2.0");
746 assert_eq!(
747 fs::read(&resolved.wasm_binary_path).expect("wasm"),
748 b"wasm-bytes"
749 );
750 }
751
752 #[test]
753 fn offline_resolve_without_prepare_is_missing() {
754 let cache = unique_cache();
755 let reference = RegistryReference {
756 namespace: "demo".to_string(),
757 id: "greet".to_string(),
758 version_range: "^1.0.0".to_string(),
759 };
760 let failure = resolve_offline(&cache, &reference).expect_err("missing");
761 assert_eq!(
762 failure.code,
763 RegistryCacheErrorCode::RegistryCacheEntryMissing
764 );
765 }
766
767 #[test]
768 fn yanked_only_range_fails_closed() {
769 let cache = unique_cache();
770 let (mut snapshot, fetcher, reference) = sample_snapshot(true);
771 snapshot
772 .capabilities
773 .retain(|record| record.version == "1.2.0");
774 let failure = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("yanked");
775 assert_eq!(
776 failure.code,
777 RegistryCacheErrorCode::RegistryDependencyYanked
778 );
779 assert!(
780 cache
781 .root
782 .join("sha256")
783 .read_dir()
784 .ok()
785 .is_none_or(|mut d| d.next().is_none())
786 );
787 }
788
789 #[test]
790 fn digest_mismatch_leaves_no_usable_entry() {
791 let cache = unique_cache();
792 let (snapshot, mut fetcher, reference) = sample_snapshot(false);
793 fetcher.assets.insert(
794 "https://example.test/greet.wasm".to_string(),
795 b"tampered".to_vec(),
796 );
797 let failure = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("mismatch");
798 assert_eq!(
799 failure.code,
800 RegistryCacheErrorCode::RegistryArtifactDigestMismatch
801 );
802 let missing = resolve_offline(&cache, &reference).expect_err("no entry");
803 assert_eq!(
804 missing.code,
805 RegistryCacheErrorCode::RegistryCacheEntryMissing
806 );
807 }
808
809 #[test]
810 fn empty_snapshot_is_sync_missing() {
811 let cache = unique_cache();
812 let (mut snapshot, fetcher, reference) = sample_snapshot(false);
813 snapshot.capabilities.clear();
814 let failure = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("empty");
815 assert_eq!(failure.code, RegistryCacheErrorCode::RegistrySyncMissing);
816 }
817
818 #[test]
819 fn evict_removes_prepared_entry() {
820 let cache = unique_cache();
821 let (snapshot, fetcher, reference) = sample_snapshot(false);
822 let evidence = prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
823 cache.evict(&evidence.artifact_digest).expect("evict");
824 let missing = resolve_offline(&cache, &reference).expect_err("gone");
825 assert!(matches!(
826 missing.code,
827 RegistryCacheErrorCode::RegistryCacheEntryMissing
828 | RegistryCacheErrorCode::RegistryArtifactDigestMismatch
829 ));
830 }
831
832 #[test]
833 fn error_codes_and_evidence_project_secret_free_values() {
834 for (code, wire) in [
835 (
836 RegistryCacheErrorCode::RegistrySyncMissing,
837 "registry_sync_missing",
838 ),
839 (
840 RegistryCacheErrorCode::RegistryVersionNotFound,
841 "registry_version_not_found",
842 ),
843 (
844 RegistryCacheErrorCode::RegistryDependencyYanked,
845 "registry_dependency_yanked",
846 ),
847 (
848 RegistryCacheErrorCode::RegistryPrepareFailed,
849 "registry_prepare_failed",
850 ),
851 (
852 RegistryCacheErrorCode::RegistryArtifactDigestMismatch,
853 "registry_artifact_digest_mismatch",
854 ),
855 (
856 RegistryCacheErrorCode::RegistryCacheEntryMissing,
857 "registry_cache_entry_missing",
858 ),
859 ] {
860 assert_eq!(code.as_str(), wire);
861 }
862 let cache = unique_cache();
863 assert!(cache.root().exists());
864 let (snapshot, fetcher, reference) = sample_snapshot(false);
865 let evidence = prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
866 let value = evidence.as_value();
867 assert_eq!(value["outcome"], "prepared");
868 assert_eq!(value["selected_version"], "1.2.0");
869 cache.evict_all().expect("evict_all");
870 assert!(
871 resolve_offline(&cache, &reference)
872 .expect_err("cleared")
873 .code
874 == RegistryCacheErrorCode::RegistryCacheEntryMissing
875 );
876 let invalid = cache.evict("not-a-digest").expect_err("invalid");
877 assert_eq!(invalid.code, RegistryCacheErrorCode::RegistryPrepareFailed);
878 }
879
880 #[test]
881 fn prepare_reports_fetch_and_version_selection_failures() {
882 let cache = unique_cache();
883 let (snapshot, fetcher, mut reference) = sample_snapshot(false);
884 reference.version_range = "not a range!!!".to_string();
885 let invalid_range = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("range");
886 assert_eq!(
887 invalid_range.code,
888 RegistryCacheErrorCode::RegistryVersionNotFound
889 );
890
891 let (snapshot, fetcher, mut reference) = sample_snapshot(false);
892 reference.version_range = "^9.0.0".to_string();
893 let missing = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("missing");
894 assert_eq!(
895 missing.code,
896 RegistryCacheErrorCode::RegistryVersionNotFound
897 );
898
899 let (snapshot, fetcher, reference) = sample_snapshot(false);
900 let empty_fetcher = MapFetcher {
901 assets: HashMap::new(),
902 };
903 let fetch_failed =
904 prepare(&cache, &snapshot, &reference, &empty_fetcher).expect_err("fetch");
905 assert_eq!(
906 fetch_failed.code,
907 RegistryCacheErrorCode::RegistryPrepareFailed
908 );
909 let _ = fetcher;
910 }
911
912 #[test]
913 fn prepare_rejects_invalid_declared_digests_and_reuses_verified_hits() {
914 let cache = unique_cache();
915 let (mut snapshot, fetcher, reference) = sample_snapshot(false);
916 snapshot.capabilities[1].digest = "sha256:short".to_string();
917 let invalid = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("digest");
918 assert_eq!(
919 invalid.code,
920 RegistryCacheErrorCode::RegistryArtifactDigestMismatch
921 );
922
923 let (snapshot, fetcher, reference) = sample_snapshot(false);
924 let first = prepare(&cache, &snapshot, &reference, &fetcher).expect("first");
925 let second = prepare(&cache, &snapshot, &reference, &fetcher).expect("reuse");
926 assert_eq!(first.artifact_digest, second.artifact_digest);
927
928 let artifact_hex = normalize_digest(&first.artifact_digest).expect("hex");
929 let path = cache.artifact_path(&artifact_hex);
930 fs::write(&path, b"tampered").expect("tamper");
931 let tampered = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("tampered");
932 assert_eq!(
933 tampered.code,
934 RegistryCacheErrorCode::RegistryArtifactDigestMismatch
935 );
936 }
937 #[test]
938 fn offline_resolve_rejects_corrupt_pointers_and_missing_meta() {
939 let cache = unique_cache();
940 let reference = RegistryReference {
941 namespace: "demo".to_string(),
942 id: "greet".to_string(),
943 version_range: "^1.0.0".to_string(),
944 };
945 let ref_path = cache.ref_path(&reference);
946 fs::create_dir_all(ref_path.parent().expect("parent")).expect("dirs");
947 fs::write(&ref_path, b"{not-json").expect("corrupt");
948 let corrupt = resolve_offline(&cache, &reference).expect_err("corrupt");
949 assert_eq!(
950 corrupt.code,
951 RegistryCacheErrorCode::RegistryCacheEntryMissing
952 );
953
954 fs::write(
955 &ref_path,
956 br#"{"contract_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#,
957 )
958 .expect("partial artifact");
959 let missing_artifact = resolve_offline(&cache, &reference).expect_err("artifact");
960 assert_eq!(
961 missing_artifact.code,
962 RegistryCacheErrorCode::RegistryCacheEntryMissing
963 );
964
965 fs::write(
966 &ref_path,
967 br#"{"artifact_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","contract_digest":"bad"}"#,
968 )
969 .expect("bad contract digest");
970 let bad_contract = resolve_offline(&cache, &reference).expect_err("contract dig");
971 assert_eq!(
972 bad_contract.code,
973 RegistryCacheErrorCode::RegistryCacheEntryMissing
974 );
975
976 fs::write(
977 &ref_path,
978 br#"{"artifact_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#,
979 )
980 .expect("partial");
981 let partial = resolve_offline(&cache, &reference).expect_err("partial");
982 assert_eq!(
983 partial.code,
984 RegistryCacheErrorCode::RegistryCacheEntryMissing
985 );
986
987 let (snapshot, fetcher, reference) = sample_snapshot(false);
988 let evidence = prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
989 let artifact_hex = normalize_digest(&evidence.artifact_digest).expect("hex");
990 fs::remove_file(cache.meta_path(&artifact_hex)).expect("remove meta");
991 let missing_meta = resolve_offline(&cache, &reference).expect_err("meta");
992 assert_eq!(
993 missing_meta.code,
994 RegistryCacheErrorCode::RegistryCacheEntryMissing
995 );
996 }
997
998 #[test]
999 fn contract_fetch_failure_and_invalid_contract_digest_fail_closed() {
1000 let cache = unique_cache();
1001 let (snapshot, mut fetcher, reference) = sample_snapshot(false);
1002 fetcher.assets.remove("https://example.test/greet.json");
1003 let failure = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("contract fetch");
1004 assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1005
1006 let (snapshot, mut fetcher, reference) = sample_snapshot(false);
1007 fetcher.assets.insert(
1008 "https://example.test/greet.json".to_string(),
1009 b"wrong-contract".to_vec(),
1010 );
1011 let mismatch = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("contract dig");
1012 assert_eq!(
1013 mismatch.code,
1014 RegistryCacheErrorCode::RegistryArtifactDigestMismatch
1015 );
1016 }
1017
1018 #[test]
1019 fn evict_all_and_missing_evict_paths_are_idempotent() {
1020 let cache = unique_cache();
1021 cache.evict_all().expect("empty evict_all");
1022 let digest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
1023 cache.evict(digest).expect("missing evict ok");
1024 let (snapshot, fetcher, reference) = sample_snapshot(false);
1025 let evidence = prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
1026 cache.evict_all().expect("clear");
1027 cache.evict(&evidence.artifact_digest).expect("again");
1028 }
1029
1030 #[test]
1031 fn write_verified_bytes_reports_directory_collision_failures() {
1032 let cache = unique_cache();
1033 let bytes = b"content";
1034 let digest = digest_for(bytes);
1035 let hex = normalize_digest(&digest).expect("hex");
1036 let path = cache.artifact_path(&hex);
1037 fs::create_dir_all(&path).expect("block as directory");
1038 let failure = write_verified_bytes(&cache, &hex, bytes).expect_err("dir");
1039 assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1040 }
1041
1042 #[test]
1043 fn evict_and_write_json_report_filesystem_failures() {
1044 let cache_art = unique_cache();
1046 let (snapshot, fetcher, reference) = sample_snapshot(false);
1047 let evidence = prepare(&cache_art, &snapshot, &reference, &fetcher).expect("prepare");
1048 let hex = normalize_digest(&evidence.artifact_digest).expect("hex");
1049 let artifact = cache_art.artifact_path(&hex);
1050 fs::remove_file(&artifact).expect("remove");
1051 fs::create_dir_all(&artifact).expect("dir");
1052 fs::write(artifact.join("nested"), b"x").expect("nested");
1053 let art_evict = cache_art
1054 .evict(&evidence.artifact_digest)
1055 .expect_err("artifact evict");
1056 assert_eq!(
1057 art_evict.code,
1058 RegistryCacheErrorCode::RegistryPrepareFailed
1059 );
1060
1061 let cache = unique_cache();
1062 let (snapshot, fetcher, reference) = sample_snapshot(false);
1063 let evidence = prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
1064 let hex = normalize_digest(&evidence.artifact_digest).expect("hex");
1065
1066 let meta = cache.meta_path(&hex);
1068 fs::remove_file(&meta).expect("remove meta file");
1069 fs::create_dir_all(&meta).expect("meta dir");
1070 fs::write(meta.join("nested"), b"x").expect("nested");
1071 let meta_evict = cache
1072 .evict(&evidence.artifact_digest)
1073 .expect_err("meta evict");
1074 assert_eq!(
1075 meta_evict.code,
1076 RegistryCacheErrorCode::RegistryPrepareFailed
1077 );
1078
1079 let cache2 = unique_cache();
1080 fs::write(cache2.root.join("sha256"), b"not-dir").expect("file");
1081 let clear_fail = cache2.evict_all().expect_err("evict_all");
1082 assert_eq!(
1083 clear_fail.code,
1084 RegistryCacheErrorCode::RegistryPrepareFailed
1085 );
1086
1087 let cache3 = unique_cache();
1088 let meta_parent = cache3.root.join("meta");
1089 fs::write(&meta_parent, b"not-dir").expect("block meta dir");
1090 let meta_fail = write_json_atomic(
1091 &cache3.meta_path("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"),
1092 &json!({"ok": true}),
1093 )
1094 .expect_err("meta");
1095 assert_eq!(
1096 meta_fail.code,
1097 RegistryCacheErrorCode::RegistryPrepareFailed
1098 );
1099
1100 let cache4 = unique_cache();
1102 fs::write(cache4.root.join("sha256"), b"not-dir").expect("block");
1103 let bytes = b"fresh";
1104 let digest = digest_for(bytes);
1105 let hex = normalize_digest(&digest).expect("hex");
1106 let create_fail = write_verified_bytes(&cache4, &hex, bytes).expect_err("create");
1107 assert_eq!(
1108 create_fail.code,
1109 RegistryCacheErrorCode::RegistryPrepareFailed
1110 );
1111 }
1112
1113 #[test]
1114 fn write_helpers_report_tmp_rename_and_existing_entry_failures() {
1115 let cache5 = unique_cache();
1117 let digest = digest_for(b"another");
1118 let hex = normalize_digest(&digest).expect("hex");
1119 let path = cache5.artifact_path(&hex);
1120 fs::create_dir_all(path.parent().expect("parent")).expect("parent");
1121 fs::create_dir_all(path.with_extension("tmp")).expect("tmp dir");
1122 let write_fail = write_verified_bytes(&cache5, &hex, b"another").expect_err("write");
1123 assert_eq!(
1124 write_fail.code,
1125 RegistryCacheErrorCode::RegistryPrepareFailed
1126 );
1127
1128 let cache6 = unique_cache();
1130 let digest = digest_for(b"rename-me");
1131 let hex = normalize_digest(&digest).expect("hex");
1132 let path = cache6.artifact_path(&hex);
1133 fs::create_dir_all(path.parent().expect("parent")).expect("parent");
1134 fs::create_dir_all(&path).expect("dest dir");
1135 let rename_fail = write_verified_bytes(&cache6, &hex, b"rename-me").expect_err("rename");
1136 assert_eq!(
1137 rename_fail.code,
1138 RegistryCacheErrorCode::RegistryPrepareFailed
1139 );
1140
1141 let cache7 = unique_cache();
1143 let target = cache7.root.join("refs").join("blocked.json");
1144 fs::create_dir_all(target.parent().expect("parent")).expect("parent");
1145 fs::create_dir_all(&target).expect("dest dir");
1146 let json_rename = write_json_atomic(&target, &json!({"ok": true})).expect_err("rename");
1147 assert_eq!(
1148 json_rename.code,
1149 RegistryCacheErrorCode::RegistryPrepareFailed
1150 );
1151
1152 let cache8 = unique_cache();
1154 let target = cache8.root.join("refs").join("writeme.json");
1155 fs::create_dir_all(target.parent().expect("parent")).expect("parent");
1156 fs::create_dir_all(target.with_extension("tmp")).expect("tmp dir");
1157 let json_write = write_json_atomic(&target, &json!({"ok": true})).expect_err("write");
1158 assert_eq!(
1159 json_write.code,
1160 RegistryCacheErrorCode::RegistryPrepareFailed
1161 );
1162
1163 let cache9 = unique_cache();
1165 let digest = digest_for(b"readable");
1166 let hex = normalize_digest(&digest).expect("hex");
1167 let path = cache9.artifact_path(&hex);
1168 fs::create_dir_all(&path).expect("dir as entry");
1169 let read_existing = write_verified_bytes(&cache9, &hex, b"readable").expect_err("read");
1170 assert_eq!(
1171 read_existing.code,
1172 RegistryCacheErrorCode::RegistryPrepareFailed
1173 );
1174 }
1175
1176 #[test]
1177 fn prepare_fails_when_refs_root_is_blocked_and_meta_can_be_corrupt() {
1178 let cache = unique_cache();
1179 fs::write(cache.root.join("refs"), b"not-a-directory").expect("block refs");
1180 let (snapshot, fetcher, reference) = sample_snapshot(false);
1181 let failure = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("refs");
1182 assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1183
1184 let cache2 = unique_cache();
1185 let (snapshot, fetcher, reference) = sample_snapshot(false);
1186 let evidence = prepare(&cache2, &snapshot, &reference, &fetcher).expect("prepare");
1187 let hex = normalize_digest(&evidence.artifact_digest).expect("hex");
1188 fs::write(cache2.meta_path(&hex), b"{not-json").expect("corrupt meta");
1189 let corrupt_meta = resolve_offline(&cache2, &reference).expect_err("meta");
1190 assert_eq!(
1191 corrupt_meta.code,
1192 RegistryCacheErrorCode::RegistryCacheEntryMissing
1193 );
1194 }
1195
1196 #[test]
1197 fn version_selection_skips_unrelated_namespace_records() {
1198 let cache = unique_cache();
1199 let (mut snapshot, fetcher, reference) = sample_snapshot(false);
1200 snapshot.capabilities.insert(
1201 0,
1202 PublicRegistryCapabilityRecord {
1203 namespace: "other".to_string(),
1204 id: "thing".to_string(),
1205 version: "9.9.9".to_string(),
1206 digest: digest_for(b"other"),
1207 artifact_url: "https://example.test/other.wasm".to_string(),
1208 contract_digest: digest_for(b"other-contract"),
1209 contract_url: "https://example.test/other.json".to_string(),
1210 deprecated: false,
1211 },
1212 );
1213 let evidence = prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
1214 assert_eq!(evidence.selected_version, "1.2.0");
1215 }
1216
1217 #[test]
1218 fn write_json_atomic_reports_serialize_failures() {
1219 #[derive(Debug)]
1220 struct Boom;
1221 impl serde::Serialize for Boom {
1222 fn serialize<S: serde::Serializer>(&self, _serializer: S) -> Result<S::Ok, S::Error> {
1223 Err(serde::ser::Error::custom("boom"))
1224 }
1225 }
1226 let cache = unique_cache();
1227 let path = cache.root.join("refs").join("boom.json");
1228 let failure = write_json_atomic(&path, &Boom).expect_err("serialize");
1229 assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1230 }
1231
1232 #[test]
1233 fn write_json_atomic_rejects_paths_without_parent() {
1234 let failure = write_json_atomic(Path::new("/"), &serde_json::json!({"ok": true}))
1235 .expect_err("root path");
1236 assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1237 assert!(failure.message.contains("no parent directory"));
1238 }
1239
1240 #[cfg(unix)]
1241 #[test]
1242 fn write_verified_bytes_reports_rename_failures_on_readonly_parent() {
1243 use std::os::unix::fs::PermissionsExt;
1244 let cache = unique_cache();
1245 let bytes = b"rename-readonly";
1246 let digest = digest_for(bytes);
1247 let hex = normalize_digest(&digest).expect("hex");
1248 let path = cache.artifact_path(&hex);
1249 let parent = path.parent().expect("parent").to_path_buf();
1250 fs::create_dir_all(&parent).expect("parent");
1251 let temporary = path.with_extension("tmp");
1252 fs::write(&temporary, bytes).expect("tmp");
1253 let mut perms = fs::metadata(&parent).expect("meta").permissions();
1254 perms.set_mode(0o555);
1255 fs::set_permissions(&parent, perms).expect("readonly");
1256 let failure = write_verified_bytes(&cache, &hex, bytes).expect_err("rename");
1257 let mut perms = fs::metadata(&parent).expect("meta").permissions();
1258 perms.set_mode(0o755);
1259 fs::set_permissions(&parent, perms).expect("restore");
1260 assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1261 }
1262
1263 #[test]
1264 fn offline_resolve_rejects_invalid_digest_shapes_in_pointer() {
1265 let cache = unique_cache();
1266 let reference = RegistryReference {
1267 namespace: "demo".to_string(),
1268 id: "greet".to_string(),
1269 version_range: "^1.0.0".to_string(),
1270 };
1271 let ref_path = cache.ref_path(&reference);
1272 fs::create_dir_all(ref_path.parent().expect("parent")).expect("dirs");
1273 fs::write(
1274 &ref_path,
1275 br#"{"artifact_digest":"bad","contract_digest":"bad"}"#,
1276 )
1277 .expect("write");
1278 let failure = resolve_offline(&cache, &reference).expect_err("bad digests");
1279 assert_eq!(
1280 failure.code,
1281 RegistryCacheErrorCode::RegistryCacheEntryMissing
1282 );
1283 }
1284
1285 #[test]
1286 fn resolve_component_rejects_non_contract_json_with_matching_digest() {
1287 let cache = unique_cache();
1288 let bogus = br#"{"kind":"not-a-capability-contract"}"#.to_vec();
1289 let artifact = b"wasm-bytes".to_vec();
1290 let artifact_digest = digest_for(&artifact);
1291 let contract_digest = digest_for(&bogus);
1292 let record = PublicRegistryCapabilityRecord {
1293 namespace: "demo".to_string(),
1294 id: "greet".to_string(),
1295 version: "1.0.0".to_string(),
1296 digest: artifact_digest,
1297 artifact_url: "https://example.test/greet.wasm".to_string(),
1298 contract_digest,
1299 contract_url: "https://example.test/greet.json".to_string(),
1300 deprecated: false,
1301 };
1302 let snapshot = SyncedPublicRegistryState {
1303 schema_version: "1".to_string(),
1304 workspace_id: "ws".to_string(),
1305 state_scope: "public".to_string(),
1306 source_repo: "traverse-framework/registry".to_string(),
1307 release_tag: "index-v9".to_string(),
1308 index_version: 9,
1309 generated_at: "2026-07-29T00:00:00Z".to_string(),
1310 source_commit: None,
1311 synced_at: "2026-07-29T00:00:00Z".to_string(),
1312 record_count: 1,
1313 validation_status: "valid".to_string(),
1314 governing_spec: "055-registry-sync".to_string(),
1315 capabilities: vec![record.clone()],
1316 };
1317 let mut assets = HashMap::new();
1318 assets.insert(record.artifact_url.clone(), artifact);
1319 assets.insert(record.contract_url.clone(), bogus);
1320 let fetcher = MapFetcher { assets };
1321 let reference = RegistryReference {
1322 namespace: "demo".to_string(),
1323 id: "greet".to_string(),
1324 version_range: "1.0.0".to_string(),
1325 };
1326 prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
1327 let failure = resolve_component(&cache, &reference).expect_err("parse");
1328 assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1329 }
1330
1331 #[test]
1332 fn resolve_component_rejects_non_utf8_contract_bytes() {
1333 let cache = unique_cache();
1334 let bogus = vec![0xff, 0xfe, 0xfd];
1335 let artifact = b"wasm-bytes".to_vec();
1336 let artifact_digest = digest_for(&artifact);
1337 let contract_digest = digest_for(&bogus);
1338 let record = PublicRegistryCapabilityRecord {
1339 namespace: "demo".to_string(),
1340 id: "greet".to_string(),
1341 version: "1.0.0".to_string(),
1342 digest: artifact_digest,
1343 artifact_url: "https://example.test/greet.wasm".to_string(),
1344 contract_digest,
1345 contract_url: "https://example.test/greet.json".to_string(),
1346 deprecated: false,
1347 };
1348 let snapshot = SyncedPublicRegistryState {
1349 schema_version: "1".to_string(),
1350 workspace_id: "ws".to_string(),
1351 state_scope: "public".to_string(),
1352 source_repo: "traverse-framework/registry".to_string(),
1353 release_tag: "index-v9".to_string(),
1354 index_version: 9,
1355 generated_at: "2026-07-29T00:00:00Z".to_string(),
1356 source_commit: None,
1357 synced_at: "2026-07-29T00:00:00Z".to_string(),
1358 record_count: 1,
1359 validation_status: "valid".to_string(),
1360 governing_spec: "055-registry-sync".to_string(),
1361 capabilities: vec![record.clone()],
1362 };
1363 let mut assets = HashMap::new();
1364 assets.insert(record.artifact_url.clone(), artifact);
1365 assets.insert(record.contract_url.clone(), bogus);
1366 let fetcher = MapFetcher { assets };
1367 let reference = RegistryReference {
1368 namespace: "demo".to_string(),
1369 id: "greet".to_string(),
1370 version_range: "1.0.0".to_string(),
1371 };
1372 prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
1373 let failure = resolve_component(&cache, &reference).expect_err("utf8");
1374 assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1375 }
1376}