diff --git a/crates/traverse-cli/src/main.rs b/crates/traverse-cli/src/main.rs index 6431a2fa..49d21dc6 100644 --- a/crates/traverse-cli/src/main.rs +++ b/crates/traverse-cli/src/main.rs @@ -7004,6 +7004,8 @@ mod tests { version: "1.0.0".to_string(), digest: "sha256:5647c39a".to_string(), artifact_url: "https://github.com/traverse-framework/registry/releases/download/artifacts/traverse-starter.process-1.0.0/traverse-starter.wasm".to_string(), + contract_digest: "sha256:5647c39a".to_string(), + contract_url: "https://github.com/traverse-framework/registry/releases/download/artifacts/traverse-starter.process-1.0.0/contract.json".to_string(), deprecated: false, }], } diff --git a/crates/traverse-registry/src/lib.rs b/crates/traverse-registry/src/lib.rs index 0488f262..f8e79a61 100644 --- a/crates/traverse-registry/src/lib.rs +++ b/crates/traverse-registry/src/lib.rs @@ -7,6 +7,7 @@ mod events; mod federation; mod graph; mod model_resolution; +mod public_registry_cache; mod public_registry_state; pub mod semver_resolver; mod workflows; @@ -21,6 +22,7 @@ pub use events::*; pub use federation::*; pub use graph::*; pub use model_resolution::*; +pub use public_registry_cache::*; pub use public_registry_state::*; pub use semver_resolver::{ AmbiguousCandidate, RangeResolutionError, ResolvedRangeCapability, resolve_version_range, diff --git a/crates/traverse-registry/src/public_registry_cache.rs b/crates/traverse-registry/src/public_registry_cache.rs new file mode 100644 index 00000000..2ec9d81c --- /dev/null +++ b/crates/traverse-registry/src/public_registry_cache.rs @@ -0,0 +1,270 @@ +use sha2::{Digest, Sha256}; +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PublicRegistryCacheErrorCode { + InvalidDigest, + DigestMismatch, + CacheReadFailed, + CacheWriteFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PublicRegistryCacheError { + pub code: PublicRegistryCacheErrorCode, + pub path: PathBuf, + pub message: String, +} + +/// Returns the shared content-addressed storage location for a SHA-256 digest. +#[must_use] +pub fn public_registry_cache_path(workspace_root: &Path, digest: &str) -> Option { + normalize_digest(digest).map(|digest| { + workspace_root + .join(".traverse") + .join("cache") + .join("sha256") + .join(digest) + }) +} + +/// Verifies bytes and persists them in the shared content-addressed cache. +/// +/// Existing cache entries are read and digest-verified before reuse. A cache +/// miss is written through a temporary sibling so callers never observe a +/// partial artifact. +/// +/// # Errors +/// +/// Returns a stable error when the declared digest is invalid, content does +/// not match it, an existing entry cannot be read, or the cache cannot be +/// committed atomically. +pub fn cache_verified_public_registry_bytes( + workspace_root: &Path, + declared_digest: &str, + bytes: &[u8], +) -> Result { + let Some(path) = public_registry_cache_path(workspace_root, declared_digest) else { + return Err(cache_error( + PublicRegistryCacheErrorCode::InvalidDigest, + workspace_root, + format!( + "public registry digest must be sha256: followed by 64 hex characters: {declared_digest}" + ), + )); + }; + let expected = normalize_digest(declared_digest).unwrap_or_default(); + if sha256_hex(bytes) != expected { + return Err(cache_error( + PublicRegistryCacheErrorCode::DigestMismatch, + &path, + format!( + "public registry content digest mismatch for {}", + path.display() + ), + )); + } + if path.exists() { + let cached = fs::read(&path).map_err(|error| { + cache_error( + PublicRegistryCacheErrorCode::CacheReadFailed, + &path, + format!("failed to read cached public registry content: {error}"), + ) + })?; + if sha256_hex(&cached) != expected { + return Err(cache_error( + PublicRegistryCacheErrorCode::DigestMismatch, + &path, + format!( + "cached public registry content digest mismatch for {}", + path.display() + ), + )); + } + return Ok(path); + } + let parent = workspace_root + .join(".traverse") + .join("cache") + .join("sha256"); + fs::create_dir_all(&parent).map_err(|error| { + cache_error( + PublicRegistryCacheErrorCode::CacheWriteFailed, + &parent, + format!("failed to create public registry cache directory: {error}"), + ) + })?; + let temporary = path.with_extension("tmp"); + fs::write(&temporary, bytes).map_err(|error| { + cache_error( + PublicRegistryCacheErrorCode::CacheWriteFailed, + &temporary, + format!("failed to write public registry cache entry: {error}"), + ) + })?; + commit_cache_entry(&temporary, &path)?; + Ok(path) +} + +fn commit_cache_entry(temporary: &Path, path: &Path) -> Result<(), PublicRegistryCacheError> { + fs::rename(temporary, path).map_err(|error| { + let _ = fs::remove_file(temporary); + cache_error( + PublicRegistryCacheErrorCode::CacheWriteFailed, + path, + format!("failed to commit public registry cache entry: {error}"), + ) + }) +} + +fn normalize_digest(digest: &str) -> Option { + let digest = digest.strip_prefix("sha256:")?; + if digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) { + Some(digest.to_ascii_lowercase()) + } else { + None + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut value = String::with_capacity(digest.len() * 2); + for byte in digest { + let _ = write!(value, "{byte:02x}"); + } + value +} + +fn cache_error( + code: PublicRegistryCacheErrorCode, + path: &Path, + message: String, +) -> PublicRegistryCacheError { + PublicRegistryCacheError { + code, + path: path.to_path_buf(), + message, + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::expect_used)] + + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + + #[test] + fn caches_and_revalidates_verified_content() { + let root = unique_temp_dir(); + let bytes = b"public contract"; + let digest = format!("sha256:{}", sha256_hex(bytes)); + let path = cache_verified_public_registry_bytes(&root, &digest, bytes) + .expect("content should cache"); + assert_eq!(fs::read(&path).expect("cache should read"), bytes); + cache_verified_public_registry_bytes(&root, &digest, bytes) + .expect("valid cache hit should succeed"); + fs::write(&path, b"tampered").expect("cache should be writable for test"); + let failure = cache_verified_public_registry_bytes(&root, &digest, bytes) + .expect_err("tampered cache must not be reused"); + assert_eq!(failure.code, PublicRegistryCacheErrorCode::DigestMismatch); + } + + #[test] + fn rejects_mismatched_declared_digest() { + let root = unique_temp_dir(); + let failure = cache_verified_public_registry_bytes( + &root, + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + b"different", + ) + .expect_err("mismatched bytes must fail"); + assert_eq!(failure.code, PublicRegistryCacheErrorCode::DigestMismatch); + } + + #[test] + fn rejects_invalid_digest_shapes() { + let root = unique_temp_dir(); + for digest in [ + "invalid", + "sha256:short", + "sha256:gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg", + ] { + let failure = cache_verified_public_registry_bytes(&root, digest, b"content") + .expect_err("invalid digest must fail"); + assert_eq!(failure.code, PublicRegistryCacheErrorCode::InvalidDigest); + } + } + + #[test] + fn reports_cache_read_and_directory_creation_failures() { + let root = unique_temp_dir(); + let bytes = b"public contract"; + let digest = format!("sha256:{}", sha256_hex(bytes)); + let path = public_registry_cache_path(&root, &digest).expect("digest should be valid"); + fs::create_dir_all(&path).expect("cache path directory should be created"); + let read_failure = cache_verified_public_registry_bytes(&root, &digest, bytes) + .expect_err("directory cache entry must not read as bytes"); + assert_eq!( + read_failure.code, + PublicRegistryCacheErrorCode::CacheReadFailed + ); + + let blocked_root = unique_temp_dir(); + fs::write(blocked_root.join(".traverse"), b"not a directory") + .expect("blocking file should be created"); + let write_failure = cache_verified_public_registry_bytes(&blocked_root, &digest, bytes) + .expect_err("cache directory creation must fail through a file"); + assert_eq!( + write_failure.code, + PublicRegistryCacheErrorCode::CacheWriteFailed + ); + } + + #[test] + fn reports_temporary_write_and_commit_failures() { + let root = unique_temp_dir(); + let bytes = b"public contract"; + let digest = format!("sha256:{}", sha256_hex(bytes)); + let path = public_registry_cache_path(&root, &digest).expect("digest should be valid"); + let parent = path.parent().expect("cache path should have a parent"); + fs::create_dir_all(parent).expect("cache parent should be created"); + fs::create_dir(path.with_extension("tmp")).expect("temporary path should be blocked"); + let write_failure = cache_verified_public_registry_bytes(&root, &digest, bytes) + .expect_err("writing through a directory must fail"); + assert_eq!( + write_failure.code, + PublicRegistryCacheErrorCode::CacheWriteFailed + ); + + let commit_root = unique_temp_dir(); + let temporary = commit_root.join("entry.tmp"); + let destination = commit_root.join("entry"); + fs::write(&temporary, bytes).expect("temporary entry should be written"); + fs::create_dir(&destination).expect("destination directory should block rename"); + let commit_failure = commit_cache_entry(&temporary, &destination) + .expect_err("rename onto a directory must fail"); + assert_eq!( + commit_failure.code, + PublicRegistryCacheErrorCode::CacheWriteFailed + ); + assert!(!temporary.exists()); + } + + fn unique_temp_dir() -> PathBuf { + let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos(); + let path = + std::env::temp_dir().join(format!("traverse-public-cache-test-{nanos}-{counter}")); + fs::create_dir_all(&path).expect("temp directory must be created"); + path + } +} diff --git a/crates/traverse-registry/src/public_registry_state.rs b/crates/traverse-registry/src/public_registry_state.rs index 5faefec8..4f0a9508 100644 --- a/crates/traverse-registry/src/public_registry_state.rs +++ b/crates/traverse-registry/src/public_registry_state.rs @@ -1,3 +1,4 @@ +use semver::{Version, VersionReq}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeSet; @@ -24,6 +25,8 @@ pub struct PublicRegistryCapabilityRecord { pub version: String, pub digest: String, pub artifact_url: String, + pub contract_digest: String, + pub contract_url: String, pub deprecated: bool, } @@ -54,6 +57,9 @@ pub enum PublicRegistryStateErrorCode { IncompatibleSchemaVersion, IncompatibleWorkspaceState, StateWriteFailed, + InvalidVersionRange, + NoMatchingVersion, + OnlyDeprecatedVersions, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -106,6 +112,20 @@ pub fn validate_public_registry_index( &record.artifact_url, "artifact_url", ); + validate_record_field( + &mut errors, + position, + "contract_digest", + &record.contract_digest, + "contract_digest", + ); + validate_record_field( + &mut errors, + position, + "contract_url", + &record.contract_url, + "contract_url", + ); if !seen.insert((&record.namespace, &record.id, &record.version)) { errors.push(error( PublicRegistryStateErrorCode::DuplicateRecord, @@ -235,6 +255,68 @@ pub fn resolve_synced_public_registry_record( })) } +/// Resolves the highest non-deprecated public record that satisfies a semver range. +/// +/// # Errors +/// +/// Returns stable resolution errors for malformed ranges, an absent matching +/// version, or a range whose only matches are deprecated records. This function +/// reads local synced state only and never contacts a registry service. +pub fn resolve_synced_public_registry_range( + workspace_root: &Path, + workspace_id: &str, + namespace: &str, + id: &str, + version_range: &str, +) -> Result { + let requirement = VersionReq::parse(version_range).map_err(|error| { + single_error( + PublicRegistryStateErrorCode::InvalidVersionRange, + Path::new(version_range), + format!("invalid registry_ref version_range {version_range}: {error}"), + ) + })?; + let state = load_synced_public_registry_state(workspace_root, workspace_id)?; + let matching = state + .capabilities + .into_iter() + .filter_map(|record| { + if record.namespace != namespace || record.id != id { + return None; + } + Version::parse(&record.version) + .ok() + .filter(|version| requirement.matches(version)) + .map(|version| (version, record)) + }) + .collect::>(); + let mut active = matching + .iter() + .filter(|(_, record)| !record.deprecated) + .cloned() + .collect::>(); + active.sort_by(|left, right| right.0.cmp(&left.0)); + if let Some((_, record)) = active.into_iter().next() { + return Ok(record); + } + let (code, message) = if matching.is_empty() { + ( + PublicRegistryStateErrorCode::NoMatchingVersion, + format!( + "no synced public registry version for {namespace}:{id} satisfies {version_range}" + ), + ) + } else { + ( + PublicRegistryStateErrorCode::OnlyDeprecatedVersions, + format!( + "only deprecated public registry versions for {namespace}:{id} satisfy {version_range}" + ), + ) + }; + Err(single_error(code, Path::new(version_range), message)) +} + fn validate_synced_public_registry_state( path: &Path, workspace_id: &str, @@ -348,6 +430,8 @@ fn capability_json_value(record: &PublicRegistryCapabilityRecord) -> Value { "version": record.version, "digest": record.digest, "artifact_url": record.artifact_url, + "contract_digest": record.contract_digest, + "contract_url": record.contract_url, "deprecated": record.deprecated }) } @@ -631,6 +715,77 @@ mod tests { assert!(missing.is_none()); } + #[test] + fn range_resolution_selects_highest_active_version_and_reports_yanked_only() { + let workspace_root = unique_temp_dir(); + let mut index = valid_index(); + let mut newer = index.capabilities[0].clone(); + newer.version = "1.2.0".to_string(); + index.capabilities.push(newer); + let mut yanked = index.capabilities[0].clone(); + yanked.version = "2.0.0".to_string(); + yanked.deprecated = true; + index.capabilities.push(yanked); + write_synced_public_registry_state( + &workspace_root, + "local", + "traverse-framework/registry", + "index-v7", + "2026-07-06T00:00:00Z", + index, + ) + .expect("state should write"); + + let selected = resolve_synced_public_registry_range( + &workspace_root, + "local", + "traverse-starter", + "traverse-starter.process", + "^1.0", + ) + .expect("active range match should resolve"); + assert_eq!(selected.version, "1.2.0"); + + let yanked_only = resolve_synced_public_registry_range( + &workspace_root, + "local", + "traverse-starter", + "traverse-starter.process", + "^2.0", + ) + .expect_err("yanked-only range should fail"); + assert_eq!( + yanked_only.errors[0].code, + PublicRegistryStateErrorCode::OnlyDeprecatedVersions + ); + + let missing = resolve_synced_public_registry_range( + &workspace_root, + "local", + "other-namespace", + "missing-capability", + "^1.0", + ) + .expect_err("missing range match should fail"); + assert_eq!( + missing.errors[0].code, + PublicRegistryStateErrorCode::NoMatchingVersion + ); + + let invalid = resolve_synced_public_registry_range( + &workspace_root, + "local", + "traverse-starter", + "traverse-starter.process", + "not a range", + ) + .expect_err("invalid range should fail"); + assert_eq!( + invalid.errors[0].code, + PublicRegistryStateErrorCode::InvalidVersionRange + ); + } + #[test] fn write_failure_when_parent_component_is_file_leaves_no_state() { let workspace_root = unique_temp_dir(); @@ -748,6 +903,8 @@ mod tests { version: "1.0.0".to_string(), digest: "sha256:5647".to_string(), artifact_url: "https://github.com/traverse-framework/registry/releases/download/artifacts/traverse-starter.process-1.0.0/traverse-starter.wasm".to_string(), + contract_digest: "sha256:5647".to_string(), + contract_url: "https://github.com/traverse-framework/registry/releases/download/artifacts/traverse-starter.process-1.0.0/contract.json".to_string(), deprecated: false, }], }