Skip to main content

traverse_embedder/
registry_cache.rs

1//! Host-owned verified registry dependency cache (Spec `080-embedded-registry-cache`).
2//!
3//! Separates network-capable [`prepare`] from offline [`HostRegistryCache`]
4//! resolution used at embedder `init`. The host chooses the cache root and
5//! supplies the artifact fetcher; Traverse never synthesizes a production
6//! path or performs network I/O outside `prepare`.
7
8use 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/// Stable, secret-free registry-cache failure codes (Spec 080 FR-007).
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum RegistryCacheErrorCode {
23    /// No synced index snapshot was supplied to prepare.
24    RegistrySyncMissing,
25    /// No non-yanked version satisfies the requested range.
26    RegistryVersionNotFound,
27    /// Matching versions exist but every candidate is yanked/deprecated.
28    RegistryDependencyYanked,
29    /// Host fetch or cache persistence failed during prepare.
30    RegistryPrepareFailed,
31    /// Declared digest does not match fetched or stored bytes.
32    RegistryArtifactDigestMismatch,
33    /// Offline resolve found no verified cache entry for the reference.
34    RegistryCacheEntryMissing,
35}
36
37impl RegistryCacheErrorCode {
38    /// Stable `snake_case` wire representation.
39    #[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/// Secret-free registry-cache failure.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct RegistryCacheError {
55    /// Stable machine-readable code.
56    pub code: RegistryCacheErrorCode,
57    /// Human-readable explanation without paths, credentials, or bytes.
58    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
70/// Host-supplied network fetch used only by [`prepare`].
71pub trait RegistryArtifactFetcher {
72    /// Fetch raw bytes for a published registry URL.
73    ///
74    /// # Errors
75    ///
76    /// Returns a secret-free message when the host cannot retrieve the asset.
77    fn fetch(&self, url: &str) -> Result<Vec<u8>, String>;
78}
79
80/// Host-chosen content-addressed registry cache root.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct HostRegistryCache {
83    root: PathBuf,
84}
85
86impl HostRegistryCache {
87    /// Bind Traverse to a host-selected cache directory.
88    ///
89    /// The host owns creation, permissions, backup, and eviction policy.
90    /// Traverse never invents a default production cache path.
91    #[must_use]
92    pub fn new(root: impl Into<PathBuf>) -> Self {
93        Self { root: root.into() }
94    }
95
96    /// Host-selected cache root.
97    #[must_use]
98    pub fn root(&self) -> &Path {
99        &self.root
100    }
101
102    /// Remove one verified entry by artifact digest (`sha256:…`).
103    ///
104    /// # Errors
105    ///
106    /// Returns [`RegistryCacheErrorCode::RegistryPrepareFailed`] when the
107    /// entry cannot be removed. Missing entries succeed without error.
108    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    /// Remove every verified entry under this cache root.
137    ///
138    /// # Errors
139    ///
140    /// Returns [`RegistryCacheErrorCode::RegistryPrepareFailed`] when the
141    /// cache directory cannot be cleared.
142    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/// FR-008 resolution evidence retained after a successful prepare.
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct RegistryPrepareEvidence {
180    /// Registry namespace.
181    pub namespace: String,
182    /// Capability id.
183    pub id: String,
184    /// Selected concrete version.
185    pub selected_version: String,
186    /// Requested semver range.
187    pub version_range: String,
188    /// Source release tag from the synced index snapshot.
189    pub source_release: String,
190    /// Digest of the synced index snapshot supplied to prepare.
191    pub index_digest: String,
192    /// Published artifact digest.
193    pub artifact_digest: String,
194    /// Unix-epoch seconds when the entry was verified.
195    pub verified_at: u64,
196    /// Outcome label (`prepared`).
197    pub outcome: &'static str,
198}
199
200impl RegistryPrepareEvidence {
201    /// Secret-free JSON projection of prepare evidence.
202    #[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/// Offline lookup of a previously prepared `registry_ref`.
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct VerifiedRegistryDependency {
234    /// Absolute path to digest-verified WASM bytes.
235    pub wasm_binary_path: PathBuf,
236    /// Absolute path to digest-verified contract JSON.
237    pub contract_path: PathBuf,
238    /// Digest-verified contract bytes.
239    pub contract_bytes: Vec<u8>,
240    /// Published artifact digest.
241    pub wasm_digest: String,
242    /// Prepare evidence for the selected record.
243    pub evidence: RegistryPrepareEvidence,
244}
245
246/// Prepare one `registry_ref` into a host-owned verified cache.
247///
248/// Only this function may call `fetcher`. Partial writes never become
249/// executable entries.
250///
251/// # Errors
252///
253/// Returns a Spec 080 FR-007 code when the snapshot is empty, version
254/// selection fails, fetch fails, or digest verification fails.
255pub 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
321/// Resolve a `registry_ref` from verified local cache entries only.
322///
323/// # Errors
324///
325/// Returns [`RegistryCacheErrorCode::RegistryCacheEntryMissing`] when no
326/// verified prepare result exists, or digest mismatch when stored bytes were
327/// tampered with.
328pub 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
408/// Resolve a `registry_ref` into a registry component materialization.
409///
410/// # Errors
411///
412/// Returns Spec 080 FR-007 codes when the entry is missing, tampered, or the
413/// verified contract bytes cannot be parsed.
414pub 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        // Fail artifact eviction with a non-empty directory collision.
1045        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        // Fail meta eviction: leave artifact removable, block meta path as a directory.
1067        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        // create_dir_all failure for sha256 parent
1101        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        // temporary write failure: tmp path is a directory
1116        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        // rename failure: destination is a directory
1129        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        // write_json_atomic rename failure
1142        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        // write_json_atomic write failure: tmp is a directory
1153        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        // existing cache entry that cannot be read as a file
1164        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}