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    /// Public metadata generation is missing, malformed, incompatible, or unverified.
36    RegistryMetadataCacheInvalid,
37}
38
39impl RegistryCacheErrorCode {
40    /// Stable `snake_case` wire representation.
41    #[must_use]
42    pub fn as_str(self) -> &'static str {
43        match self {
44            Self::RegistrySyncMissing => "registry_sync_missing",
45            Self::RegistryVersionNotFound => "registry_version_not_found",
46            Self::RegistryDependencyYanked => "registry_dependency_yanked",
47            Self::RegistryPrepareFailed => "registry_prepare_failed",
48            Self::RegistryArtifactDigestMismatch => "registry_artifact_digest_mismatch",
49            Self::RegistryCacheEntryMissing => "registry_cache_entry_missing",
50            Self::RegistryMetadataCacheInvalid => "registry_metadata_cache_invalid",
51        }
52    }
53}
54
55/// Secret-free registry-cache failure.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct RegistryCacheError {
58    /// Stable machine-readable code.
59    pub code: RegistryCacheErrorCode,
60    /// Human-readable explanation without paths, credentials, or bytes.
61    pub message: String,
62}
63
64impl RegistryCacheError {
65    fn new(code: RegistryCacheErrorCode, message: impl Into<String>) -> Self {
66        Self {
67            code,
68            message: message.into(),
69        }
70    }
71}
72
73/// Host-supplied network fetch used only by [`prepare`].
74pub trait RegistryArtifactFetcher {
75    /// Fetch raw bytes for a published registry URL.
76    ///
77    /// # Errors
78    ///
79    /// Returns a secret-free message when the host cannot retrieve the asset.
80    fn fetch(&self, url: &str) -> Result<Vec<u8>, String>;
81}
82
83/// Host-chosen content-addressed registry cache root.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub struct HostRegistryCache {
86    root: PathBuf,
87}
88
89impl HostRegistryCache {
90    /// Bind Traverse to a host-selected cache directory.
91    ///
92    /// The host owns creation, permissions, backup, and eviction policy.
93    /// Traverse never invents a default production cache path.
94    #[must_use]
95    pub fn new(root: impl Into<PathBuf>) -> Self {
96        Self { root: root.into() }
97    }
98
99    /// Host-selected cache root.
100    #[must_use]
101    pub fn root(&self) -> &Path {
102        &self.root
103    }
104
105    /// Remove one verified entry by artifact digest (`sha256:…`).
106    ///
107    /// # Errors
108    ///
109    /// Returns [`RegistryCacheErrorCode::RegistryPrepareFailed`] when the
110    /// entry cannot be removed. Missing entries succeed without error.
111    pub fn evict(&self, artifact_digest: &str) -> Result<(), RegistryCacheError> {
112        let digest = normalize_digest(artifact_digest).ok_or_else(|| {
113            RegistryCacheError::new(
114                RegistryCacheErrorCode::RegistryPrepareFailed,
115                "artifact digest must be sha256: followed by 64 hex characters",
116            )
117        })?;
118        let artifact = self.artifact_path(&digest);
119        let meta = self.meta_path(&digest);
120        if artifact.exists() {
121            fs::remove_file(&artifact).map_err(|error| {
122                RegistryCacheError::new(
123                    RegistryCacheErrorCode::RegistryPrepareFailed,
124                    format!("failed to evict verified registry artifact: {error}"),
125                )
126            })?;
127        }
128        if meta.exists() {
129            fs::remove_file(&meta).map_err(|error| {
130                RegistryCacheError::new(
131                    RegistryCacheErrorCode::RegistryPrepareFailed,
132                    format!("failed to evict registry cache metadata: {error}"),
133                )
134            })?;
135        }
136        Ok(())
137    }
138
139    /// Remove every verified entry under this cache root.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`RegistryCacheErrorCode::RegistryPrepareFailed`] when the
144    /// cache directory cannot be cleared.
145    pub fn evict_all(&self) -> Result<(), RegistryCacheError> {
146        for sub in ["sha256", "meta", "refs"] {
147            let path = self.root.join(sub);
148            if path.exists() {
149                fs::remove_dir_all(&path).map_err(|error| {
150                    RegistryCacheError::new(
151                        RegistryCacheErrorCode::RegistryPrepareFailed,
152                        format!("failed to clear host registry cache: {error}"),
153                    )
154                })?;
155            }
156        }
157        Ok(())
158    }
159
160    fn artifact_path(&self, digest_hex: &str) -> PathBuf {
161        self.root.join("sha256").join(digest_hex)
162    }
163
164    fn meta_path(&self, digest_hex: &str) -> PathBuf {
165        self.root.join("meta").join(format!("{digest_hex}.json"))
166    }
167
168    fn ref_path(&self, reference: &RegistryReference) -> PathBuf {
169        let key = sha256_hex(
170            format!(
171                "{}:{}:{}",
172                reference.namespace, reference.id, reference.version_range
173            )
174            .as_bytes(),
175        );
176        self.root.join("refs").join(format!("{key}.json"))
177    }
178
179    fn public_metadata_path(&self) -> PathBuf {
180        self.root.join("public-metadata").join("current.json")
181    }
182}
183
184/// A sanitized, searchable public capability record (Spec 116).
185#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
186pub struct PublicCapabilityMetadata {
187    pub namespace: String,
188    pub id: String,
189    pub version: String,
190    pub artifact_digest: String,
191    pub source_release: String,
192    pub index_digest: String,
193    pub summary: String,
194    pub description: String,
195    pub scenarios: Vec<String>,
196    pub service_type: String,
197    pub permitted_targets: Vec<String>,
198    pub lifecycle: String,
199    pub provenance: Option<Value>,
200}
201
202/// One complete, verified public metadata generation.
203///
204/// The generation-level provenance is retained even when a consumer's query
205/// matches no individual capability records.
206#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
207pub struct PublicMetadataRead {
208    pub records: Vec<PublicCapabilityMetadata>,
209    pub stale: bool,
210    pub source_release: String,
211    pub index_digest: String,
212}
213
214#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
215struct PublicMetadataGeneration {
216    schema_version: u32,
217    stale: bool,
218    source_release: String,
219    index_digest: String,
220    records: Vec<PublicCapabilityMetadata>,
221}
222
223/// Atomically publish the verified public-only projection of a synced registry state.
224/// This is explicit preparation; it performs neither network I/O nor implicit refresh.
225///
226/// # Errors
227///
228/// Returns a stable registry-cache error when the supplied state is empty or
229/// the complete generation cannot be atomically persisted.
230pub fn publish_public_metadata(
231    cache: &HostRegistryCache,
232    snapshot: &SyncedPublicRegistryState,
233    stale: bool,
234) -> Result<(), RegistryCacheError> {
235    if snapshot.capabilities.is_empty() {
236        return Err(RegistryCacheError::new(
237            RegistryCacheErrorCode::RegistrySyncMissing,
238            "synced registry index snapshot contains no capabilities",
239        ));
240    }
241    let index_digest = index_snapshot_digest(snapshot);
242    let records = snapshot
243        .capabilities
244        .iter()
245        .map(|record| PublicCapabilityMetadata {
246            namespace: record.namespace.clone(),
247            id: record.id.clone(),
248            version: record.version.clone(),
249            artifact_digest: record.digest.clone(),
250            source_release: snapshot.release_tag.clone(),
251            index_digest: index_digest.clone(),
252            summary: record.summary.clone(),
253            description: record.description.clone(),
254            scenarios: record
255                .use_cases
256                .iter()
257                .map(|use_case| use_case.scenario.clone())
258                .collect(),
259            service_type: record.service_type.clone(),
260            permitted_targets: record.permitted_targets.clone(),
261            lifecycle: record.lifecycle.clone(),
262            provenance: record.provenance.clone(),
263        })
264        .collect();
265    write_json_atomic(
266        &cache.public_metadata_path(),
267        &PublicMetadataGeneration {
268            schema_version: 1,
269            stale,
270            source_release: snapshot.release_tag.clone(),
271            index_digest,
272            records,
273        },
274    )
275}
276
277/// Read one complete verified metadata generation without network access.
278///
279/// # Errors
280///
281/// Returns a stable registry-cache error when the generation is missing,
282/// malformed, unsupported, or has invalid digest/provenance bindings.
283pub fn read_public_metadata(
284    cache: &HostRegistryCache,
285) -> Result<PublicMetadataRead, RegistryCacheError> {
286    let bytes = fs::read(cache.public_metadata_path()).map_err(|_| {
287        RegistryCacheError::new(
288            RegistryCacheErrorCode::RegistrySyncMissing,
289            "public metadata cache generation is missing",
290        )
291    })?;
292    let generation: PublicMetadataGeneration = serde_json::from_slice(&bytes).map_err(|_| {
293        RegistryCacheError::new(
294            RegistryCacheErrorCode::RegistryMetadataCacheInvalid,
295            "public metadata cache generation is malformed",
296        )
297    })?;
298    if generation.schema_version != 1
299        || generation.source_release.is_empty()
300        || normalize_digest(&generation.index_digest).is_none()
301        || generation.records.iter().any(|record| {
302            record.namespace.is_empty()
303                || record.id.is_empty()
304                || record.version.is_empty()
305                || normalize_digest(&record.artifact_digest).is_none()
306                || record.source_release != generation.source_release
307                || record.index_digest != generation.index_digest
308                || record.service_type.is_empty()
309                || record.lifecycle.is_empty()
310        })
311    {
312        return Err(RegistryCacheError::new(
313            RegistryCacheErrorCode::RegistryMetadataCacheInvalid,
314            "public metadata cache generation has invalid verification bindings",
315        ));
316    }
317    Ok(PublicMetadataRead {
318        records: generation.records,
319        stale: generation.stale,
320        source_release: generation.source_release,
321        index_digest: generation.index_digest,
322    })
323}
324
325/// FR-008 resolution evidence retained after a successful prepare.
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct RegistryPrepareEvidence {
328    /// Registry namespace.
329    pub namespace: String,
330    /// Capability id.
331    pub id: String,
332    /// Selected concrete version.
333    pub selected_version: String,
334    /// Requested semver range.
335    pub version_range: String,
336    /// Source release tag from the synced index snapshot.
337    pub source_release: String,
338    /// Digest of the synced index snapshot supplied to prepare.
339    pub index_digest: String,
340    /// Published artifact digest.
341    pub artifact_digest: String,
342    /// Unix-epoch seconds when the entry was verified.
343    pub verified_at: u64,
344    /// Outcome label (`prepared`).
345    pub outcome: &'static str,
346}
347
348impl RegistryPrepareEvidence {
349    /// Secret-free JSON projection of prepare evidence.
350    #[must_use]
351    pub fn as_value(&self) -> Value {
352        json!({
353            "namespace": self.namespace,
354            "id": self.id,
355            "selected_version": self.selected_version,
356            "version_range": self.version_range,
357            "source_release": self.source_release,
358            "index_digest": self.index_digest,
359            "artifact_digest": self.artifact_digest,
360            "verified_at": self.verified_at,
361            "outcome": self.outcome,
362        })
363    }
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
367struct CacheEntryMeta {
368    namespace: String,
369    id: String,
370    selected_version: String,
371    version_range: String,
372    source_release: String,
373    index_digest: String,
374    artifact_digest: String,
375    contract_digest: String,
376    verified_at: u64,
377}
378
379/// Offline lookup of a previously prepared `registry_ref`.
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct VerifiedRegistryDependency {
382    /// Absolute path to digest-verified WASM bytes.
383    pub wasm_binary_path: PathBuf,
384    /// Absolute path to digest-verified contract JSON.
385    pub contract_path: PathBuf,
386    /// Digest-verified contract bytes.
387    pub contract_bytes: Vec<u8>,
388    /// Published artifact digest.
389    pub wasm_digest: String,
390    /// Prepare evidence for the selected record.
391    pub evidence: RegistryPrepareEvidence,
392}
393
394/// Prepare one `registry_ref` into a host-owned verified cache.
395///
396/// Only this function may call `fetcher`. Partial writes never become
397/// executable entries.
398///
399/// # Errors
400///
401/// Returns a Spec 080 FR-007 code when the snapshot is empty, version
402/// selection fails, fetch fails, or digest verification fails.
403pub fn prepare(
404    cache: &HostRegistryCache,
405    snapshot: &SyncedPublicRegistryState,
406    reference: &RegistryReference,
407    fetcher: &dyn RegistryArtifactFetcher,
408) -> Result<RegistryPrepareEvidence, RegistryCacheError> {
409    if snapshot.capabilities.is_empty() {
410        return Err(RegistryCacheError::new(
411            RegistryCacheErrorCode::RegistrySyncMissing,
412            "synced registry index snapshot contains no capabilities",
413        ));
414    }
415    let record = select_highest_active(snapshot, reference)?;
416    let index_digest = index_snapshot_digest(snapshot);
417    let artifact_bytes = fetcher.fetch(&record.artifact_url).map_err(|message| {
418        RegistryCacheError::new(
419            RegistryCacheErrorCode::RegistryPrepareFailed,
420            format!("host registry artifact fetch failed: {message}"),
421        )
422    })?;
423    let artifact_hex = verify_digest(&record.digest, &artifact_bytes)?;
424    let contract_bytes = fetcher.fetch(&record.contract_url).map_err(|message| {
425        RegistryCacheError::new(
426            RegistryCacheErrorCode::RegistryPrepareFailed,
427            format!("host registry contract fetch failed: {message}"),
428        )
429    })?;
430    let contract_hex = verify_digest(&record.contract_digest, &contract_bytes)?;
431
432    write_verified_bytes(cache, &artifact_hex, &artifact_bytes)?;
433    write_verified_bytes(cache, &contract_hex, &contract_bytes)?;
434
435    let verified_at = unix_seconds();
436    let meta = CacheEntryMeta {
437        namespace: record.namespace.clone(),
438        id: record.id.clone(),
439        selected_version: record.version.clone(),
440        version_range: reference.version_range.clone(),
441        source_release: snapshot.release_tag.clone(),
442        index_digest: index_digest.clone(),
443        artifact_digest: record.digest.clone(),
444        contract_digest: record.contract_digest.clone(),
445        verified_at,
446    };
447    write_json_atomic(&cache.meta_path(&artifact_hex), &meta)?;
448    write_json_atomic(
449        &cache.ref_path(reference),
450        &json!({
451            "artifact_digest": record.digest,
452            "contract_digest": record.contract_digest,
453        }),
454    )?;
455
456    Ok(RegistryPrepareEvidence {
457        namespace: record.namespace,
458        id: record.id,
459        selected_version: record.version,
460        version_range: reference.version_range.clone(),
461        source_release: snapshot.release_tag.clone(),
462        index_digest,
463        artifact_digest: record.digest,
464        verified_at,
465        outcome: "prepared",
466    })
467}
468
469/// Resolve a `registry_ref` from verified local cache entries only.
470///
471/// # Errors
472///
473/// Returns [`RegistryCacheErrorCode::RegistryCacheEntryMissing`] when no
474/// verified prepare result exists, or digest mismatch when stored bytes were
475/// tampered with.
476pub fn resolve_offline(
477    cache: &HostRegistryCache,
478    reference: &RegistryReference,
479) -> Result<VerifiedRegistryDependency, RegistryCacheError> {
480    let ref_path = cache.ref_path(reference);
481    let ref_bytes = fs::read(&ref_path).map_err(|_| {
482        RegistryCacheError::new(
483            RegistryCacheErrorCode::RegistryCacheEntryMissing,
484            "verified registry cache entry is missing for registry_ref",
485        )
486    })?;
487    let pointer: Value = serde_json::from_slice(&ref_bytes).map_err(|_| {
488        RegistryCacheError::new(
489            RegistryCacheErrorCode::RegistryCacheEntryMissing,
490            "verified registry cache entry is missing for registry_ref",
491        )
492    })?;
493    let artifact_digest = pointer
494        .get("artifact_digest")
495        .and_then(Value::as_str)
496        .ok_or_else(|| {
497            RegistryCacheError::new(
498                RegistryCacheErrorCode::RegistryCacheEntryMissing,
499                "verified registry cache entry is missing for registry_ref",
500            )
501        })?;
502    let contract_digest = pointer
503        .get("contract_digest")
504        .and_then(Value::as_str)
505        .ok_or_else(|| {
506            RegistryCacheError::new(
507                RegistryCacheErrorCode::RegistryCacheEntryMissing,
508                "verified registry cache entry is missing for registry_ref",
509            )
510        })?;
511    let artifact_hex = normalize_digest(artifact_digest).ok_or_else(|| {
512        RegistryCacheError::new(
513            RegistryCacheErrorCode::RegistryCacheEntryMissing,
514            "verified registry cache entry is missing for registry_ref",
515        )
516    })?;
517    let contract_hex = normalize_digest(contract_digest).ok_or_else(|| {
518        RegistryCacheError::new(
519            RegistryCacheErrorCode::RegistryCacheEntryMissing,
520            "verified registry cache entry is missing for registry_ref",
521        )
522    })?;
523    let (wasm_binary_path, _) = read_verified(cache, &artifact_hex, artifact_digest)?;
524    let (contract_path, contract_bytes) = read_verified(cache, &contract_hex, contract_digest)?;
525    let meta_bytes = fs::read(cache.meta_path(&artifact_hex)).map_err(|_| {
526        RegistryCacheError::new(
527            RegistryCacheErrorCode::RegistryCacheEntryMissing,
528            "verified registry cache entry is missing for registry_ref",
529        )
530    })?;
531    let meta: CacheEntryMeta = serde_json::from_slice(&meta_bytes).map_err(|_| {
532        RegistryCacheError::new(
533            RegistryCacheErrorCode::RegistryCacheEntryMissing,
534            "verified registry cache entry is missing for registry_ref",
535        )
536    })?;
537    Ok(VerifiedRegistryDependency {
538        wasm_binary_path,
539        contract_path,
540        contract_bytes,
541        wasm_digest: artifact_digest.to_string(),
542        evidence: RegistryPrepareEvidence {
543            namespace: meta.namespace,
544            id: meta.id,
545            selected_version: meta.selected_version,
546            version_range: meta.version_range,
547            source_release: meta.source_release,
548            index_digest: meta.index_digest,
549            artifact_digest: meta.artifact_digest,
550            verified_at: meta.verified_at,
551            outcome: "resolved",
552        },
553    })
554}
555
556/// Resolve a `registry_ref` into a registry component materialization.
557///
558/// # Errors
559///
560/// Returns Spec 080 FR-007 codes when the entry is missing, tampered, or the
561/// verified contract bytes cannot be parsed.
562pub fn resolve_component(
563    cache: &HostRegistryCache,
564    reference: &RegistryReference,
565) -> Result<ResolvedRegistryComponent, RegistryCacheError> {
566    let resolved = resolve_offline(cache, reference)?;
567    let contract_text = String::from_utf8(resolved.contract_bytes).map_err(|_| {
568        RegistryCacheError::new(
569            RegistryCacheErrorCode::RegistryPrepareFailed,
570            "verified registry contract is invalid: not utf-8",
571        )
572    })?;
573    let contract = parse_contract(&contract_text).map_err(|failure| {
574        RegistryCacheError::new(
575            RegistryCacheErrorCode::RegistryPrepareFailed,
576            format!(
577                "verified registry contract is invalid: {}",
578                failure
579                    .errors
580                    .first()
581                    .map_or("parse failed", |error| error.message.as_str())
582            ),
583        )
584    })?;
585    Ok(ResolvedRegistryComponent {
586        contract_path: resolved.contract_path,
587        contract,
588        wasm_binary_path: resolved.wasm_binary_path,
589        wasm_digest: resolved.wasm_digest,
590    })
591}
592
593fn select_highest_active(
594    snapshot: &SyncedPublicRegistryState,
595    reference: &RegistryReference,
596) -> Result<PublicRegistryCapabilityRecord, RegistryCacheError> {
597    let requirement = semver::VersionReq::parse(&reference.version_range).map_err(|error| {
598        RegistryCacheError::new(
599            RegistryCacheErrorCode::RegistryVersionNotFound,
600            format!(
601                "invalid registry_ref version_range {}: {error}",
602                reference.version_range
603            ),
604        )
605    })?;
606    let matching = snapshot
607        .capabilities
608        .iter()
609        .filter_map(|record| {
610            if record.namespace != reference.namespace || record.id != reference.id {
611                return None;
612            }
613            semver::Version::parse(&record.version)
614                .ok()
615                .filter(|version| requirement.matches(version))
616                .map(|version| (version, record.clone()))
617        })
618        .collect::<Vec<_>>();
619    let mut active = matching
620        .iter()
621        .filter(|(_, record)| !record.deprecated)
622        .cloned()
623        .collect::<Vec<_>>();
624    active.sort_by(|left, right| right.0.cmp(&left.0));
625    if let Some((_, record)) = active.into_iter().next() {
626        return Ok(record);
627    }
628    if matching.is_empty() {
629        Err(RegistryCacheError::new(
630            RegistryCacheErrorCode::RegistryVersionNotFound,
631            format!(
632                "no synced public registry version for {}:{} satisfies {}",
633                reference.namespace, reference.id, reference.version_range
634            ),
635        ))
636    } else {
637        Err(RegistryCacheError::new(
638            RegistryCacheErrorCode::RegistryDependencyYanked,
639            format!(
640                "only yanked public registry versions for {}:{} satisfy {}",
641                reference.namespace, reference.id, reference.version_range
642            ),
643        ))
644    }
645}
646
647fn index_snapshot_digest(snapshot: &SyncedPublicRegistryState) -> String {
648    let encoded = serde_json::to_vec(snapshot).unwrap_or_default();
649    format!("sha256:{}", sha256_hex(&encoded))
650}
651
652fn verify_digest(declared: &str, bytes: &[u8]) -> Result<String, RegistryCacheError> {
653    let expected = normalize_digest(declared).ok_or_else(|| {
654        RegistryCacheError::new(
655            RegistryCacheErrorCode::RegistryArtifactDigestMismatch,
656            "registry digest must be sha256: followed by 64 hex characters",
657        )
658    })?;
659    if sha256_hex(bytes) == expected {
660        Ok(expected)
661    } else {
662        Err(RegistryCacheError::new(
663            RegistryCacheErrorCode::RegistryArtifactDigestMismatch,
664            "registry artifact bytes do not match the published digest",
665        ))
666    }
667}
668
669fn write_verified_bytes(
670    cache: &HostRegistryCache,
671    digest_hex: &str,
672    bytes: &[u8],
673) -> Result<PathBuf, RegistryCacheError> {
674    let path = cache.artifact_path(digest_hex);
675    if path.exists() {
676        let cached = fs::read(&path).map_err(|error| {
677            RegistryCacheError::new(
678                RegistryCacheErrorCode::RegistryPrepareFailed,
679                format!("failed to read existing verified cache entry: {error}"),
680            )
681        })?;
682        if sha256_hex(&cached) != digest_hex {
683            return Err(RegistryCacheError::new(
684                RegistryCacheErrorCode::RegistryArtifactDigestMismatch,
685                "existing registry cache entry digest mismatch",
686            ));
687        }
688        return Ok(path);
689    }
690    let parent = cache.root.join("sha256");
691    fs::create_dir_all(&parent).map_err(|error| {
692        RegistryCacheError::new(
693            RegistryCacheErrorCode::RegistryPrepareFailed,
694            format!("failed to create host registry cache directory: {error}"),
695        )
696    })?;
697    let temporary = path.with_extension("tmp");
698    fs::write(&temporary, bytes).map_err(|error| {
699        let _ = fs::remove_file(&temporary);
700        RegistryCacheError::new(
701            RegistryCacheErrorCode::RegistryPrepareFailed,
702            format!("failed to write registry cache entry: {error}"),
703        )
704    })?;
705    fs::rename(&temporary, &path).map_err(|error| {
706        let _ = fs::remove_file(&temporary);
707        RegistryCacheError::new(
708            RegistryCacheErrorCode::RegistryPrepareFailed,
709            format!("failed to commit registry cache entry: {error}"),
710        )
711    })?;
712    Ok(path)
713}
714
715fn read_verified(
716    cache: &HostRegistryCache,
717    digest_hex: &str,
718    declared: &str,
719) -> Result<(PathBuf, Vec<u8>), RegistryCacheError> {
720    let path = cache.artifact_path(digest_hex);
721    let bytes = fs::read(&path).map_err(|_| {
722        RegistryCacheError::new(
723            RegistryCacheErrorCode::RegistryCacheEntryMissing,
724            "verified registry cache entry is missing for registry_ref",
725        )
726    })?;
727    verify_digest(declared, &bytes)?;
728    Ok((path, bytes))
729}
730
731fn write_json_atomic(path: &Path, value: &impl serde::Serialize) -> Result<(), RegistryCacheError> {
732    let parent = path.parent().ok_or_else(|| {
733        RegistryCacheError::new(
734            RegistryCacheErrorCode::RegistryPrepareFailed,
735            "registry cache metadata path has no parent directory",
736        )
737    })?;
738    fs::create_dir_all(parent).map_err(|error| {
739        RegistryCacheError::new(
740            RegistryCacheErrorCode::RegistryPrepareFailed,
741            format!("failed to create host registry cache directory: {error}"),
742        )
743    })?;
744    let bytes = serde_json::to_vec(value).map_err(|error| {
745        RegistryCacheError::new(
746            RegistryCacheErrorCode::RegistryPrepareFailed,
747            format!("failed to encode registry cache metadata: {error}"),
748        )
749    })?;
750    let temporary = path.with_extension("tmp");
751    fs::write(&temporary, bytes).map_err(|error| {
752        let _ = fs::remove_file(&temporary);
753        RegistryCacheError::new(
754            RegistryCacheErrorCode::RegistryPrepareFailed,
755            format!("failed to write registry cache metadata: {error}"),
756        )
757    })?;
758    fs::rename(&temporary, path).map_err(|error| {
759        let _ = fs::remove_file(&temporary);
760        RegistryCacheError::new(
761            RegistryCacheErrorCode::RegistryPrepareFailed,
762            format!("failed to commit registry cache metadata: {error}"),
763        )
764    })?;
765    Ok(())
766}
767
768fn normalize_digest(digest: &str) -> Option<String> {
769    let digest = digest.strip_prefix("sha256:")?;
770    if digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
771        Some(digest.to_ascii_lowercase())
772    } else {
773        None
774    }
775}
776
777fn sha256_hex(bytes: &[u8]) -> String {
778    let digest = Sha256::digest(bytes);
779    let mut value = String::with_capacity(digest.len() * 2);
780    for byte in digest {
781        let _ = write!(value, "{byte:02x}");
782    }
783    value
784}
785
786fn unix_seconds() -> u64 {
787    SystemTime::now()
788        .duration_since(UNIX_EPOCH)
789        .map_or(0, |duration| duration.as_secs())
790}
791
792#[cfg(test)]
793mod tests {
794    #![allow(clippy::expect_used, clippy::unwrap_used)]
795
796    use super::*;
797    use std::collections::HashMap;
798    use std::sync::atomic::{AtomicU64, Ordering};
799
800    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
801
802    struct MapFetcher {
803        assets: HashMap<String, Vec<u8>>,
804    }
805
806    impl RegistryArtifactFetcher for MapFetcher {
807        fn fetch(&self, url: &str) -> Result<Vec<u8>, String> {
808            self.assets
809                .get(url)
810                .cloned()
811                .ok_or_else(|| "missing asset".to_string())
812        }
813    }
814
815    fn digest_for(bytes: &[u8]) -> String {
816        format!("sha256:{}", sha256_hex(bytes))
817    }
818
819    fn sample_snapshot(
820        deprecated: bool,
821    ) -> (SyncedPublicRegistryState, MapFetcher, RegistryReference) {
822        let artifact = b"wasm-bytes".to_vec();
823        let contract = br#"{"kind":"capability_contract"}"#.to_vec();
824        let artifact_digest = digest_for(&artifact);
825        let contract_digest = digest_for(&contract);
826        let record = PublicRegistryCapabilityRecord {
827            namespace: "demo".to_string(),
828            id: "greet".to_string(),
829            version: "1.2.0".to_string(),
830            digest: artifact_digest,
831            artifact_url: "https://example.test/greet.wasm".to_string(),
832            contract_digest,
833            contract_url: "https://example.test/greet.json".to_string(),
834            deprecated,
835            summary: "Greeting".to_string(),
836            description: "Greets a person".to_string(),
837            use_cases: Vec::new(),
838            service_type: "stateless".to_string(),
839            permitted_targets: Vec::new(),
840            lifecycle: "active".to_string(),
841            provenance: None,
842        };
843        let older = PublicRegistryCapabilityRecord {
844            namespace: "demo".to_string(),
845            id: "greet".to_string(),
846            version: "1.0.0".to_string(),
847            digest: digest_for(b"older"),
848            artifact_url: "https://example.test/older.wasm".to_string(),
849            contract_digest: digest_for(b"older-contract"),
850            contract_url: "https://example.test/older.json".to_string(),
851            deprecated: false,
852            summary: String::new(),
853            description: String::new(),
854            use_cases: Vec::new(),
855            service_type: "stateless".to_string(),
856            permitted_targets: Vec::new(),
857            lifecycle: "active".to_string(),
858            provenance: None,
859        };
860        let snapshot = SyncedPublicRegistryState {
861            schema_version: "1".to_string(),
862            workspace_id: "ws".to_string(),
863            state_scope: "public".to_string(),
864            source_repo: "traverse-framework/registry".to_string(),
865            release_tag: "index-v9".to_string(),
866            index_version: 9,
867            generated_at: "2026-07-29T00:00:00Z".to_string(),
868            source_commit: None,
869            synced_at: "2026-07-29T00:00:00Z".to_string(),
870            record_count: 2,
871            validation_status: "valid".to_string(),
872            governing_spec: "055-registry-sync".to_string(),
873            capabilities: vec![older, record.clone()],
874            events: Vec::new(),
875        };
876        let mut assets = HashMap::new();
877        assets.insert(record.artifact_url.clone(), artifact);
878        assets.insert(record.contract_url.clone(), contract);
879        let fetcher = MapFetcher { assets };
880        let reference = RegistryReference {
881            namespace: "demo".to_string(),
882            id: "greet".to_string(),
883            version_range: "^1.0.0".to_string(),
884        };
885        (snapshot, fetcher, reference)
886    }
887
888    fn unique_cache() -> HostRegistryCache {
889        let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
890        let nanos = SystemTime::now()
891            .duration_since(UNIX_EPOCH)
892            .expect("clock")
893            .as_nanos();
894        let root = std::env::temp_dir().join(format!("traverse-embedder-cache-{nanos}-{counter}"));
895        fs::create_dir_all(&root).expect("temp");
896        HostRegistryCache::new(root)
897    }
898
899    #[test]
900    fn prepare_then_offline_resolve_round_trip() {
901        let cache = unique_cache();
902        let (snapshot, fetcher, reference) = sample_snapshot(false);
903        let evidence = prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
904        assert_eq!(evidence.selected_version, "1.2.0");
905        assert_eq!(evidence.source_release, "index-v9");
906        assert_eq!(evidence.outcome, "prepared");
907        let resolved = resolve_offline(&cache, &reference).expect("offline");
908        assert_eq!(resolved.evidence.selected_version, "1.2.0");
909        assert_eq!(
910            fs::read(&resolved.wasm_binary_path).expect("wasm"),
911            b"wasm-bytes"
912        );
913    }
914
915    #[test]
916    fn offline_resolve_without_prepare_is_missing() {
917        let cache = unique_cache();
918        let reference = RegistryReference {
919            namespace: "demo".to_string(),
920            id: "greet".to_string(),
921            version_range: "^1.0.0".to_string(),
922        };
923        let failure = resolve_offline(&cache, &reference).expect_err("missing");
924        assert_eq!(
925            failure.code,
926            RegistryCacheErrorCode::RegistryCacheEntryMissing
927        );
928    }
929
930    #[test]
931    fn yanked_only_range_fails_closed() {
932        let cache = unique_cache();
933        let (mut snapshot, fetcher, reference) = sample_snapshot(true);
934        snapshot
935            .capabilities
936            .retain(|record| record.version == "1.2.0");
937        let failure = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("yanked");
938        assert_eq!(
939            failure.code,
940            RegistryCacheErrorCode::RegistryDependencyYanked
941        );
942        assert!(
943            cache
944                .root
945                .join("sha256")
946                .read_dir()
947                .ok()
948                .is_none_or(|mut d| d.next().is_none())
949        );
950    }
951
952    #[test]
953    fn digest_mismatch_leaves_no_usable_entry() {
954        let cache = unique_cache();
955        let (snapshot, mut fetcher, reference) = sample_snapshot(false);
956        fetcher.assets.insert(
957            "https://example.test/greet.wasm".to_string(),
958            b"tampered".to_vec(),
959        );
960        let failure = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("mismatch");
961        assert_eq!(
962            failure.code,
963            RegistryCacheErrorCode::RegistryArtifactDigestMismatch
964        );
965        let missing = resolve_offline(&cache, &reference).expect_err("no entry");
966        assert_eq!(
967            missing.code,
968            RegistryCacheErrorCode::RegistryCacheEntryMissing
969        );
970    }
971
972    #[test]
973    fn empty_snapshot_is_sync_missing() {
974        let cache = unique_cache();
975        let (mut snapshot, fetcher, reference) = sample_snapshot(false);
976        snapshot.capabilities.clear();
977        let failure = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("empty");
978        assert_eq!(failure.code, RegistryCacheErrorCode::RegistrySyncMissing);
979    }
980
981    #[test]
982    fn evict_removes_prepared_entry() {
983        let cache = unique_cache();
984        let (snapshot, fetcher, reference) = sample_snapshot(false);
985        let evidence = prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
986        cache.evict(&evidence.artifact_digest).expect("evict");
987        let missing = resolve_offline(&cache, &reference).expect_err("gone");
988        assert!(matches!(
989            missing.code,
990            RegistryCacheErrorCode::RegistryCacheEntryMissing
991                | RegistryCacheErrorCode::RegistryArtifactDigestMismatch
992        ));
993    }
994
995    #[test]
996    fn error_codes_and_evidence_project_secret_free_values() {
997        for (code, wire) in [
998            (
999                RegistryCacheErrorCode::RegistrySyncMissing,
1000                "registry_sync_missing",
1001            ),
1002            (
1003                RegistryCacheErrorCode::RegistryVersionNotFound,
1004                "registry_version_not_found",
1005            ),
1006            (
1007                RegistryCacheErrorCode::RegistryDependencyYanked,
1008                "registry_dependency_yanked",
1009            ),
1010            (
1011                RegistryCacheErrorCode::RegistryPrepareFailed,
1012                "registry_prepare_failed",
1013            ),
1014            (
1015                RegistryCacheErrorCode::RegistryArtifactDigestMismatch,
1016                "registry_artifact_digest_mismatch",
1017            ),
1018            (
1019                RegistryCacheErrorCode::RegistryCacheEntryMissing,
1020                "registry_cache_entry_missing",
1021            ),
1022        ] {
1023            assert_eq!(code.as_str(), wire);
1024        }
1025        let cache = unique_cache();
1026        assert!(cache.root().exists());
1027        let (snapshot, fetcher, reference) = sample_snapshot(false);
1028        let evidence = prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
1029        let value = evidence.as_value();
1030        assert_eq!(value["outcome"], "prepared");
1031        assert_eq!(value["selected_version"], "1.2.0");
1032        cache.evict_all().expect("evict_all");
1033        assert!(
1034            resolve_offline(&cache, &reference)
1035                .expect_err("cleared")
1036                .code
1037                == RegistryCacheErrorCode::RegistryCacheEntryMissing
1038        );
1039        let invalid = cache.evict("not-a-digest").expect_err("invalid");
1040        assert_eq!(invalid.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1041    }
1042
1043    #[test]
1044    fn prepare_reports_fetch_and_version_selection_failures() {
1045        let cache = unique_cache();
1046        let (snapshot, fetcher, mut reference) = sample_snapshot(false);
1047        reference.version_range = "not a range!!!".to_string();
1048        let invalid_range = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("range");
1049        assert_eq!(
1050            invalid_range.code,
1051            RegistryCacheErrorCode::RegistryVersionNotFound
1052        );
1053
1054        let (snapshot, fetcher, mut reference) = sample_snapshot(false);
1055        reference.version_range = "^9.0.0".to_string();
1056        let missing = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("missing");
1057        assert_eq!(
1058            missing.code,
1059            RegistryCacheErrorCode::RegistryVersionNotFound
1060        );
1061
1062        let (snapshot, fetcher, reference) = sample_snapshot(false);
1063        let empty_fetcher = MapFetcher {
1064            assets: HashMap::new(),
1065        };
1066        let fetch_failed =
1067            prepare(&cache, &snapshot, &reference, &empty_fetcher).expect_err("fetch");
1068        assert_eq!(
1069            fetch_failed.code,
1070            RegistryCacheErrorCode::RegistryPrepareFailed
1071        );
1072        let _ = fetcher;
1073    }
1074
1075    #[test]
1076    fn prepare_rejects_invalid_declared_digests_and_reuses_verified_hits() {
1077        let cache = unique_cache();
1078        let (mut snapshot, fetcher, reference) = sample_snapshot(false);
1079        snapshot.capabilities[1].digest = "sha256:short".to_string();
1080        let invalid = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("digest");
1081        assert_eq!(
1082            invalid.code,
1083            RegistryCacheErrorCode::RegistryArtifactDigestMismatch
1084        );
1085
1086        let (snapshot, fetcher, reference) = sample_snapshot(false);
1087        let first = prepare(&cache, &snapshot, &reference, &fetcher).expect("first");
1088        let second = prepare(&cache, &snapshot, &reference, &fetcher).expect("reuse");
1089        assert_eq!(first.artifact_digest, second.artifact_digest);
1090
1091        let artifact_hex = normalize_digest(&first.artifact_digest).expect("hex");
1092        let path = cache.artifact_path(&artifact_hex);
1093        fs::write(&path, b"tampered").expect("tamper");
1094        let tampered = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("tampered");
1095        assert_eq!(
1096            tampered.code,
1097            RegistryCacheErrorCode::RegistryArtifactDigestMismatch
1098        );
1099    }
1100    #[test]
1101    fn offline_resolve_rejects_corrupt_pointers_and_missing_meta() {
1102        let cache = unique_cache();
1103        let reference = RegistryReference {
1104            namespace: "demo".to_string(),
1105            id: "greet".to_string(),
1106            version_range: "^1.0.0".to_string(),
1107        };
1108        let ref_path = cache.ref_path(&reference);
1109        fs::create_dir_all(ref_path.parent().expect("parent")).expect("dirs");
1110        fs::write(&ref_path, b"{not-json").expect("corrupt");
1111        let corrupt = resolve_offline(&cache, &reference).expect_err("corrupt");
1112        assert_eq!(
1113            corrupt.code,
1114            RegistryCacheErrorCode::RegistryCacheEntryMissing
1115        );
1116
1117        fs::write(
1118            &ref_path,
1119            br#"{"contract_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#,
1120        )
1121        .expect("partial artifact");
1122        let missing_artifact = resolve_offline(&cache, &reference).expect_err("artifact");
1123        assert_eq!(
1124            missing_artifact.code,
1125            RegistryCacheErrorCode::RegistryCacheEntryMissing
1126        );
1127
1128        fs::write(
1129            &ref_path,
1130            br#"{"artifact_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","contract_digest":"bad"}"#,
1131        )
1132        .expect("bad contract digest");
1133        let bad_contract = resolve_offline(&cache, &reference).expect_err("contract dig");
1134        assert_eq!(
1135            bad_contract.code,
1136            RegistryCacheErrorCode::RegistryCacheEntryMissing
1137        );
1138
1139        fs::write(
1140            &ref_path,
1141            br#"{"artifact_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}"#,
1142        )
1143        .expect("partial");
1144        let partial = resolve_offline(&cache, &reference).expect_err("partial");
1145        assert_eq!(
1146            partial.code,
1147            RegistryCacheErrorCode::RegistryCacheEntryMissing
1148        );
1149
1150        let (snapshot, fetcher, reference) = sample_snapshot(false);
1151        let evidence = prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
1152        let artifact_hex = normalize_digest(&evidence.artifact_digest).expect("hex");
1153        fs::remove_file(cache.meta_path(&artifact_hex)).expect("remove meta");
1154        let missing_meta = resolve_offline(&cache, &reference).expect_err("meta");
1155        assert_eq!(
1156            missing_meta.code,
1157            RegistryCacheErrorCode::RegistryCacheEntryMissing
1158        );
1159    }
1160
1161    #[test]
1162    fn contract_fetch_failure_and_invalid_contract_digest_fail_closed() {
1163        let cache = unique_cache();
1164        let (snapshot, mut fetcher, reference) = sample_snapshot(false);
1165        fetcher.assets.remove("https://example.test/greet.json");
1166        let failure = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("contract fetch");
1167        assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1168
1169        let (snapshot, mut fetcher, reference) = sample_snapshot(false);
1170        fetcher.assets.insert(
1171            "https://example.test/greet.json".to_string(),
1172            b"wrong-contract".to_vec(),
1173        );
1174        let mismatch = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("contract dig");
1175        assert_eq!(
1176            mismatch.code,
1177            RegistryCacheErrorCode::RegistryArtifactDigestMismatch
1178        );
1179    }
1180
1181    #[test]
1182    fn evict_all_and_missing_evict_paths_are_idempotent() {
1183        let cache = unique_cache();
1184        cache.evict_all().expect("empty evict_all");
1185        let digest = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
1186        cache.evict(digest).expect("missing evict ok");
1187        let (snapshot, fetcher, reference) = sample_snapshot(false);
1188        let evidence = prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
1189        cache.evict_all().expect("clear");
1190        cache.evict(&evidence.artifact_digest).expect("again");
1191    }
1192
1193    #[test]
1194    fn write_verified_bytes_reports_directory_collision_failures() {
1195        let cache = unique_cache();
1196        let bytes = b"content";
1197        let digest = digest_for(bytes);
1198        let hex = normalize_digest(&digest).expect("hex");
1199        let path = cache.artifact_path(&hex);
1200        fs::create_dir_all(&path).expect("block as directory");
1201        let failure = write_verified_bytes(&cache, &hex, bytes).expect_err("dir");
1202        assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1203    }
1204
1205    #[test]
1206    fn evict_and_write_json_report_filesystem_failures() {
1207        // Fail artifact eviction with a non-empty directory collision.
1208        let cache_art = unique_cache();
1209        let (snapshot, fetcher, reference) = sample_snapshot(false);
1210        let evidence = prepare(&cache_art, &snapshot, &reference, &fetcher).expect("prepare");
1211        let hex = normalize_digest(&evidence.artifact_digest).expect("hex");
1212        let artifact = cache_art.artifact_path(&hex);
1213        fs::remove_file(&artifact).expect("remove");
1214        fs::create_dir_all(&artifact).expect("dir");
1215        fs::write(artifact.join("nested"), b"x").expect("nested");
1216        let art_evict = cache_art
1217            .evict(&evidence.artifact_digest)
1218            .expect_err("artifact evict");
1219        assert_eq!(
1220            art_evict.code,
1221            RegistryCacheErrorCode::RegistryPrepareFailed
1222        );
1223
1224        let cache = unique_cache();
1225        let (snapshot, fetcher, reference) = sample_snapshot(false);
1226        let evidence = prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
1227        let hex = normalize_digest(&evidence.artifact_digest).expect("hex");
1228
1229        // Fail meta eviction: leave artifact removable, block meta path as a directory.
1230        let meta = cache.meta_path(&hex);
1231        fs::remove_file(&meta).expect("remove meta file");
1232        fs::create_dir_all(&meta).expect("meta dir");
1233        fs::write(meta.join("nested"), b"x").expect("nested");
1234        let meta_evict = cache
1235            .evict(&evidence.artifact_digest)
1236            .expect_err("meta evict");
1237        assert_eq!(
1238            meta_evict.code,
1239            RegistryCacheErrorCode::RegistryPrepareFailed
1240        );
1241
1242        let cache2 = unique_cache();
1243        fs::write(cache2.root.join("sha256"), b"not-dir").expect("file");
1244        let clear_fail = cache2.evict_all().expect_err("evict_all");
1245        assert_eq!(
1246            clear_fail.code,
1247            RegistryCacheErrorCode::RegistryPrepareFailed
1248        );
1249
1250        let cache3 = unique_cache();
1251        let meta_parent = cache3.root.join("meta");
1252        fs::write(&meta_parent, b"not-dir").expect("block meta dir");
1253        let meta_fail = write_json_atomic(
1254            &cache3.meta_path("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"),
1255            &json!({"ok": true}),
1256        )
1257        .expect_err("meta");
1258        assert_eq!(
1259            meta_fail.code,
1260            RegistryCacheErrorCode::RegistryPrepareFailed
1261        );
1262
1263        // create_dir_all failure for sha256 parent
1264        let cache4 = unique_cache();
1265        fs::write(cache4.root.join("sha256"), b"not-dir").expect("block");
1266        let bytes = b"fresh";
1267        let digest = digest_for(bytes);
1268        let hex = normalize_digest(&digest).expect("hex");
1269        let create_fail = write_verified_bytes(&cache4, &hex, bytes).expect_err("create");
1270        assert_eq!(
1271            create_fail.code,
1272            RegistryCacheErrorCode::RegistryPrepareFailed
1273        );
1274    }
1275
1276    #[test]
1277    fn write_helpers_report_tmp_rename_and_existing_entry_failures() {
1278        // temporary write failure: tmp path is a directory
1279        let cache5 = unique_cache();
1280        let digest = digest_for(b"another");
1281        let hex = normalize_digest(&digest).expect("hex");
1282        let path = cache5.artifact_path(&hex);
1283        fs::create_dir_all(path.parent().expect("parent")).expect("parent");
1284        fs::create_dir_all(path.with_extension("tmp")).expect("tmp dir");
1285        let write_fail = write_verified_bytes(&cache5, &hex, b"another").expect_err("write");
1286        assert_eq!(
1287            write_fail.code,
1288            RegistryCacheErrorCode::RegistryPrepareFailed
1289        );
1290
1291        // rename failure: destination is a directory
1292        let cache6 = unique_cache();
1293        let digest = digest_for(b"rename-me");
1294        let hex = normalize_digest(&digest).expect("hex");
1295        let path = cache6.artifact_path(&hex);
1296        fs::create_dir_all(path.parent().expect("parent")).expect("parent");
1297        fs::create_dir_all(&path).expect("dest dir");
1298        let rename_fail = write_verified_bytes(&cache6, &hex, b"rename-me").expect_err("rename");
1299        assert_eq!(
1300            rename_fail.code,
1301            RegistryCacheErrorCode::RegistryPrepareFailed
1302        );
1303
1304        // write_json_atomic rename failure
1305        let cache7 = unique_cache();
1306        let target = cache7.root.join("refs").join("blocked.json");
1307        fs::create_dir_all(target.parent().expect("parent")).expect("parent");
1308        fs::create_dir_all(&target).expect("dest dir");
1309        let json_rename = write_json_atomic(&target, &json!({"ok": true})).expect_err("rename");
1310        assert_eq!(
1311            json_rename.code,
1312            RegistryCacheErrorCode::RegistryPrepareFailed
1313        );
1314
1315        // write_json_atomic write failure: tmp is a directory
1316        let cache8 = unique_cache();
1317        let target = cache8.root.join("refs").join("writeme.json");
1318        fs::create_dir_all(target.parent().expect("parent")).expect("parent");
1319        fs::create_dir_all(target.with_extension("tmp")).expect("tmp dir");
1320        let json_write = write_json_atomic(&target, &json!({"ok": true})).expect_err("write");
1321        assert_eq!(
1322            json_write.code,
1323            RegistryCacheErrorCode::RegistryPrepareFailed
1324        );
1325
1326        // existing cache entry that cannot be read as a file
1327        let cache9 = unique_cache();
1328        let digest = digest_for(b"readable");
1329        let hex = normalize_digest(&digest).expect("hex");
1330        let path = cache9.artifact_path(&hex);
1331        fs::create_dir_all(&path).expect("dir as entry");
1332        let read_existing = write_verified_bytes(&cache9, &hex, b"readable").expect_err("read");
1333        assert_eq!(
1334            read_existing.code,
1335            RegistryCacheErrorCode::RegistryPrepareFailed
1336        );
1337    }
1338
1339    #[test]
1340    fn prepare_fails_when_refs_root_is_blocked_and_meta_can_be_corrupt() {
1341        let cache = unique_cache();
1342        fs::write(cache.root.join("refs"), b"not-a-directory").expect("block refs");
1343        let (snapshot, fetcher, reference) = sample_snapshot(false);
1344        let failure = prepare(&cache, &snapshot, &reference, &fetcher).expect_err("refs");
1345        assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1346
1347        let cache2 = unique_cache();
1348        let (snapshot, fetcher, reference) = sample_snapshot(false);
1349        let evidence = prepare(&cache2, &snapshot, &reference, &fetcher).expect("prepare");
1350        let hex = normalize_digest(&evidence.artifact_digest).expect("hex");
1351        fs::write(cache2.meta_path(&hex), b"{not-json").expect("corrupt meta");
1352        let corrupt_meta = resolve_offline(&cache2, &reference).expect_err("meta");
1353        assert_eq!(
1354            corrupt_meta.code,
1355            RegistryCacheErrorCode::RegistryCacheEntryMissing
1356        );
1357    }
1358
1359    #[test]
1360    fn version_selection_skips_unrelated_namespace_records() {
1361        let cache = unique_cache();
1362        let (mut snapshot, fetcher, reference) = sample_snapshot(false);
1363        snapshot.capabilities.insert(
1364            0,
1365            PublicRegistryCapabilityRecord {
1366                namespace: "other".to_string(),
1367                id: "thing".to_string(),
1368                version: "9.9.9".to_string(),
1369                digest: digest_for(b"other"),
1370                artifact_url: "https://example.test/other.wasm".to_string(),
1371                contract_digest: digest_for(b"other-contract"),
1372                contract_url: "https://example.test/other.json".to_string(),
1373                deprecated: false,
1374                summary: String::new(),
1375                description: String::new(),
1376                use_cases: Vec::new(),
1377                service_type: String::new(),
1378                permitted_targets: Vec::new(),
1379                lifecycle: String::new(),
1380                provenance: None,
1381            },
1382        );
1383        let evidence = prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
1384        assert_eq!(evidence.selected_version, "1.2.0");
1385    }
1386
1387    #[test]
1388    fn write_json_atomic_reports_serialize_failures() {
1389        #[derive(Debug)]
1390        struct Boom;
1391        impl serde::Serialize for Boom {
1392            fn serialize<S: serde::Serializer>(&self, _serializer: S) -> Result<S::Ok, S::Error> {
1393                Err(serde::ser::Error::custom("boom"))
1394            }
1395        }
1396        let cache = unique_cache();
1397        let path = cache.root.join("refs").join("boom.json");
1398        let failure = write_json_atomic(&path, &Boom).expect_err("serialize");
1399        assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1400    }
1401
1402    #[test]
1403    fn write_json_atomic_rejects_paths_without_parent() {
1404        let failure = write_json_atomic(Path::new("/"), &serde_json::json!({"ok": true}))
1405            .expect_err("root path");
1406        assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1407        assert!(failure.message.contains("no parent directory"));
1408    }
1409
1410    #[cfg(unix)]
1411    #[test]
1412    fn write_verified_bytes_reports_rename_failures_on_readonly_parent() {
1413        use std::os::unix::fs::PermissionsExt;
1414        let cache = unique_cache();
1415        let bytes = b"rename-readonly";
1416        let digest = digest_for(bytes);
1417        let hex = normalize_digest(&digest).expect("hex");
1418        let path = cache.artifact_path(&hex);
1419        let parent = path.parent().expect("parent").to_path_buf();
1420        fs::create_dir_all(&parent).expect("parent");
1421        let temporary = path.with_extension("tmp");
1422        fs::write(&temporary, bytes).expect("tmp");
1423        let mut perms = fs::metadata(&parent).expect("meta").permissions();
1424        perms.set_mode(0o555);
1425        fs::set_permissions(&parent, perms).expect("readonly");
1426        let failure = write_verified_bytes(&cache, &hex, bytes).expect_err("rename");
1427        let mut perms = fs::metadata(&parent).expect("meta").permissions();
1428        perms.set_mode(0o755);
1429        fs::set_permissions(&parent, perms).expect("restore");
1430        assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1431    }
1432
1433    #[test]
1434    fn offline_resolve_rejects_invalid_digest_shapes_in_pointer() {
1435        let cache = unique_cache();
1436        let reference = RegistryReference {
1437            namespace: "demo".to_string(),
1438            id: "greet".to_string(),
1439            version_range: "^1.0.0".to_string(),
1440        };
1441        let ref_path = cache.ref_path(&reference);
1442        fs::create_dir_all(ref_path.parent().expect("parent")).expect("dirs");
1443        fs::write(
1444            &ref_path,
1445            br#"{"artifact_digest":"bad","contract_digest":"bad"}"#,
1446        )
1447        .expect("write");
1448        let failure = resolve_offline(&cache, &reference).expect_err("bad digests");
1449        assert_eq!(
1450            failure.code,
1451            RegistryCacheErrorCode::RegistryCacheEntryMissing
1452        );
1453    }
1454
1455    #[test]
1456    fn resolve_component_rejects_non_contract_json_with_matching_digest() {
1457        let cache = unique_cache();
1458        let bogus = br#"{"kind":"not-a-capability-contract"}"#.to_vec();
1459        let artifact = b"wasm-bytes".to_vec();
1460        let artifact_digest = digest_for(&artifact);
1461        let contract_digest = digest_for(&bogus);
1462        let record = PublicRegistryCapabilityRecord {
1463            namespace: "demo".to_string(),
1464            id: "greet".to_string(),
1465            version: "1.0.0".to_string(),
1466            digest: artifact_digest,
1467            artifact_url: "https://example.test/greet.wasm".to_string(),
1468            contract_digest,
1469            contract_url: "https://example.test/greet.json".to_string(),
1470            deprecated: false,
1471            summary: String::new(),
1472            description: String::new(),
1473            use_cases: Vec::new(),
1474            service_type: String::new(),
1475            permitted_targets: Vec::new(),
1476            lifecycle: String::new(),
1477            provenance: None,
1478        };
1479        let snapshot = SyncedPublicRegistryState {
1480            schema_version: "1".to_string(),
1481            workspace_id: "ws".to_string(),
1482            state_scope: "public".to_string(),
1483            source_repo: "traverse-framework/registry".to_string(),
1484            release_tag: "index-v9".to_string(),
1485            index_version: 9,
1486            generated_at: "2026-07-29T00:00:00Z".to_string(),
1487            source_commit: None,
1488            synced_at: "2026-07-29T00:00:00Z".to_string(),
1489            record_count: 1,
1490            validation_status: "valid".to_string(),
1491            governing_spec: "055-registry-sync".to_string(),
1492            capabilities: vec![record.clone()],
1493            events: Vec::new(),
1494        };
1495        let mut assets = HashMap::new();
1496        assets.insert(record.artifact_url.clone(), artifact);
1497        assets.insert(record.contract_url.clone(), bogus);
1498        let fetcher = MapFetcher { assets };
1499        let reference = RegistryReference {
1500            namespace: "demo".to_string(),
1501            id: "greet".to_string(),
1502            version_range: "1.0.0".to_string(),
1503        };
1504        prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
1505        let failure = resolve_component(&cache, &reference).expect_err("parse");
1506        assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1507    }
1508
1509    #[test]
1510    fn resolve_component_rejects_non_utf8_contract_bytes() {
1511        let cache = unique_cache();
1512        let bogus = vec![0xff, 0xfe, 0xfd];
1513        let artifact = b"wasm-bytes".to_vec();
1514        let artifact_digest = digest_for(&artifact);
1515        let contract_digest = digest_for(&bogus);
1516        let record = PublicRegistryCapabilityRecord {
1517            namespace: "demo".to_string(),
1518            id: "greet".to_string(),
1519            version: "1.0.0".to_string(),
1520            digest: artifact_digest,
1521            artifact_url: "https://example.test/greet.wasm".to_string(),
1522            contract_digest,
1523            contract_url: "https://example.test/greet.json".to_string(),
1524            deprecated: false,
1525            summary: String::new(),
1526            description: String::new(),
1527            use_cases: Vec::new(),
1528            service_type: String::new(),
1529            permitted_targets: Vec::new(),
1530            lifecycle: String::new(),
1531            provenance: None,
1532        };
1533        let snapshot = SyncedPublicRegistryState {
1534            schema_version: "1".to_string(),
1535            workspace_id: "ws".to_string(),
1536            state_scope: "public".to_string(),
1537            source_repo: "traverse-framework/registry".to_string(),
1538            release_tag: "index-v9".to_string(),
1539            index_version: 9,
1540            generated_at: "2026-07-29T00:00:00Z".to_string(),
1541            source_commit: None,
1542            synced_at: "2026-07-29T00:00:00Z".to_string(),
1543            record_count: 1,
1544            validation_status: "valid".to_string(),
1545            governing_spec: "055-registry-sync".to_string(),
1546            capabilities: vec![record.clone()],
1547            events: Vec::new(),
1548        };
1549        let mut assets = HashMap::new();
1550        assets.insert(record.artifact_url.clone(), artifact);
1551        assets.insert(record.contract_url.clone(), bogus);
1552        let fetcher = MapFetcher { assets };
1553        let reference = RegistryReference {
1554            namespace: "demo".to_string(),
1555            id: "greet".to_string(),
1556            version_range: "1.0.0".to_string(),
1557        };
1558        prepare(&cache, &snapshot, &reference, &fetcher).expect("prepare");
1559        let failure = resolve_component(&cache, &reference).expect_err("utf8");
1560        assert_eq!(failure.code, RegistryCacheErrorCode::RegistryPrepareFailed);
1561    }
1562
1563    #[test]
1564    fn public_metadata_round_trip_is_sanitized_and_can_be_stale() {
1565        let cache = unique_cache();
1566        let (mut snapshot, _, _) = sample_snapshot(false);
1567        let record = snapshot.capabilities.last_mut().expect("record");
1568        record.summary = "Greeting capability".to_string();
1569        record.description = "Greets a public user".to_string();
1570        record.use_cases = vec![traverse_registry::PublicUseCaseSummary {
1571            scenario: "Greet a new user".to_string(),
1572        }];
1573        publish_public_metadata(&cache, &snapshot, true).expect("publish");
1574        let generation = read_public_metadata(&cache).expect("read");
1575        assert!(generation.stale);
1576        assert_eq!(generation.source_release, snapshot.release_tag);
1577        assert!(!generation.index_digest.is_empty());
1578        assert!(
1579            generation
1580                .records
1581                .iter()
1582                .any(|record| record.description == "Greets a public user"
1583                    && record.scenarios == ["Greet a new user"])
1584        );
1585        let encoded = fs::read(cache.public_metadata_path()).expect("generation");
1586        let text = String::from_utf8(encoded).expect("utf8");
1587        assert!(!text.contains("input_example"));
1588        assert!(!text.contains("output_example"));
1589    }
1590
1591    #[test]
1592    fn public_metadata_rejects_tampered_generation_binding() {
1593        let cache = unique_cache();
1594        let (snapshot, _, _) = sample_snapshot(false);
1595        publish_public_metadata(&cache, &snapshot, false).expect("publish");
1596        let path = cache.public_metadata_path();
1597        let mut generation: Value =
1598            serde_json::from_slice(&fs::read(&path).expect("read")).expect("json");
1599        generation["records"][0]["index_digest"] =
1600            json!("sha256:0000000000000000000000000000000000000000000000000000000000000000");
1601        write_json_atomic(&path, &generation).expect("tamper");
1602        assert_eq!(
1603            read_public_metadata(&cache).expect_err("invalid").code,
1604            RegistryCacheErrorCode::RegistryMetadataCacheInvalid
1605        );
1606    }
1607
1608    #[test]
1609    fn public_metadata_rejects_empty_snapshots_and_has_stable_error_code() {
1610        let cache = unique_cache();
1611        let (mut snapshot, _, _) = sample_snapshot(false);
1612        snapshot.capabilities.clear();
1613        assert_eq!(
1614            publish_public_metadata(&cache, &snapshot, false)
1615                .expect_err("empty")
1616                .code,
1617            RegistryCacheErrorCode::RegistrySyncMissing
1618        );
1619        assert_eq!(
1620            RegistryCacheErrorCode::RegistryMetadataCacheInvalid.as_str(),
1621            "registry_metadata_cache_invalid"
1622        );
1623    }
1624
1625    #[test]
1626    fn public_metadata_fails_closed_for_missing_and_malformed_generations() {
1627        let cache = unique_cache();
1628        assert_eq!(
1629            read_public_metadata(&cache).expect_err("missing").code,
1630            RegistryCacheErrorCode::RegistrySyncMissing
1631        );
1632        let path = cache.public_metadata_path();
1633        fs::create_dir_all(path.parent().expect("parent")).expect("directory");
1634        fs::write(&path, b"not json").expect("malformed generation");
1635        assert_eq!(
1636            read_public_metadata(&cache).expect_err("malformed").code,
1637            RegistryCacheErrorCode::RegistryMetadataCacheInvalid
1638        );
1639    }
1640}