Skip to content

Commit ece28f7

Browse files
feat(registry): cache verified public artifacts
1 parent c87008d commit ece28f7

2 files changed

Lines changed: 197 additions & 0 deletions

File tree

crates/traverse-registry/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod events;
77
mod federation;
88
mod graph;
99
mod model_resolution;
10+
mod public_registry_cache;
1011
mod public_registry_state;
1112
pub mod semver_resolver;
1213
mod workflows;
@@ -21,6 +22,7 @@ pub use events::*;
2122
pub use federation::*;
2223
pub use graph::*;
2324
pub use model_resolution::*;
25+
pub use public_registry_cache::*;
2426
pub use public_registry_state::*;
2527
pub use semver_resolver::{
2628
AmbiguousCandidate, RangeResolutionError, ResolvedRangeCapability, resolve_version_range,
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
use sha2::{Digest, Sha256};
2+
use std::fmt::Write as _;
3+
use std::fs;
4+
use std::path::{Path, PathBuf};
5+
6+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7+
pub enum PublicRegistryCacheErrorCode {
8+
InvalidDigest,
9+
DigestMismatch,
10+
CacheReadFailed,
11+
CacheWriteFailed,
12+
}
13+
14+
#[derive(Debug, Clone, PartialEq, Eq)]
15+
pub struct PublicRegistryCacheError {
16+
pub code: PublicRegistryCacheErrorCode,
17+
pub path: PathBuf,
18+
pub message: String,
19+
}
20+
21+
/// Returns the shared content-addressed storage location for a SHA-256 digest.
22+
#[must_use]
23+
pub fn public_registry_cache_path(workspace_root: &Path, digest: &str) -> Option<PathBuf> {
24+
normalize_digest(digest).map(|digest| {
25+
workspace_root
26+
.join(".traverse")
27+
.join("cache")
28+
.join("sha256")
29+
.join(digest)
30+
})
31+
}
32+
33+
/// Verifies bytes and persists them in the shared content-addressed cache.
34+
///
35+
/// Existing cache entries are read and digest-verified before reuse. A cache
36+
/// miss is written through a temporary sibling so callers never observe a
37+
/// partial artifact.
38+
///
39+
/// # Errors
40+
///
41+
/// Returns a stable error when the declared digest is invalid, content does
42+
/// not match it, an existing entry cannot be read, or the cache cannot be
43+
/// committed atomically.
44+
pub fn cache_verified_public_registry_bytes(
45+
workspace_root: &Path,
46+
declared_digest: &str,
47+
bytes: &[u8],
48+
) -> Result<PathBuf, PublicRegistryCacheError> {
49+
let Some(path) = public_registry_cache_path(workspace_root, declared_digest) else {
50+
return Err(cache_error(
51+
PublicRegistryCacheErrorCode::InvalidDigest,
52+
workspace_root,
53+
format!(
54+
"public registry digest must be sha256: followed by 64 hex characters: {declared_digest}"
55+
),
56+
));
57+
};
58+
let expected = normalize_digest(declared_digest).unwrap_or_default();
59+
if sha256_hex(bytes) != expected {
60+
return Err(cache_error(
61+
PublicRegistryCacheErrorCode::DigestMismatch,
62+
&path,
63+
format!(
64+
"public registry content digest mismatch for {}",
65+
path.display()
66+
),
67+
));
68+
}
69+
if path.exists() {
70+
let cached = fs::read(&path).map_err(|error| {
71+
cache_error(
72+
PublicRegistryCacheErrorCode::CacheReadFailed,
73+
&path,
74+
format!("failed to read cached public registry content: {error}"),
75+
)
76+
})?;
77+
if sha256_hex(&cached) != expected {
78+
return Err(cache_error(
79+
PublicRegistryCacheErrorCode::DigestMismatch,
80+
&path,
81+
format!(
82+
"cached public registry content digest mismatch for {}",
83+
path.display()
84+
),
85+
));
86+
}
87+
return Ok(path);
88+
}
89+
let parent = path.parent().ok_or_else(|| {
90+
cache_error(
91+
PublicRegistryCacheErrorCode::CacheWriteFailed,
92+
&path,
93+
"public registry cache path has no parent".to_string(),
94+
)
95+
})?;
96+
fs::create_dir_all(parent).map_err(|error| {
97+
cache_error(
98+
PublicRegistryCacheErrorCode::CacheWriteFailed,
99+
parent,
100+
format!("failed to create public registry cache directory: {error}"),
101+
)
102+
})?;
103+
let temporary = path.with_extension("tmp");
104+
fs::write(&temporary, bytes).map_err(|error| {
105+
cache_error(
106+
PublicRegistryCacheErrorCode::CacheWriteFailed,
107+
&temporary,
108+
format!("failed to write public registry cache entry: {error}"),
109+
)
110+
})?;
111+
fs::rename(&temporary, &path).map_err(|error| {
112+
let _ = fs::remove_file(&temporary);
113+
cache_error(
114+
PublicRegistryCacheErrorCode::CacheWriteFailed,
115+
&path,
116+
format!("failed to commit public registry cache entry: {error}"),
117+
)
118+
})?;
119+
Ok(path)
120+
}
121+
122+
fn normalize_digest(digest: &str) -> Option<String> {
123+
let digest = digest.strip_prefix("sha256:")?;
124+
if digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
125+
Some(digest.to_ascii_lowercase())
126+
} else {
127+
None
128+
}
129+
}
130+
131+
fn sha256_hex(bytes: &[u8]) -> String {
132+
let digest = Sha256::digest(bytes);
133+
let mut value = String::with_capacity(digest.len() * 2);
134+
for byte in digest {
135+
let _ = write!(value, "{byte:02x}");
136+
}
137+
value
138+
}
139+
140+
fn cache_error(
141+
code: PublicRegistryCacheErrorCode,
142+
path: &Path,
143+
message: String,
144+
) -> PublicRegistryCacheError {
145+
PublicRegistryCacheError {
146+
code,
147+
path: path.to_path_buf(),
148+
message,
149+
}
150+
}
151+
152+
#[cfg(test)]
153+
mod tests {
154+
#![allow(clippy::expect_used)]
155+
156+
use super::*;
157+
use std::sync::atomic::{AtomicU64, Ordering};
158+
159+
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
160+
161+
#[test]
162+
fn caches_and_revalidates_verified_content() {
163+
let root = unique_temp_dir();
164+
let bytes = b"public contract";
165+
let digest = format!("sha256:{}", sha256_hex(bytes));
166+
let path = cache_verified_public_registry_bytes(&root, &digest, bytes)
167+
.expect("content should cache");
168+
assert_eq!(fs::read(&path).expect("cache should read"), bytes);
169+
cache_verified_public_registry_bytes(&root, &digest, bytes)
170+
.expect("valid cache hit should succeed");
171+
fs::write(&path, b"tampered").expect("cache should be writable for test");
172+
let failure = cache_verified_public_registry_bytes(&root, &digest, bytes)
173+
.expect_err("tampered cache must not be reused");
174+
assert_eq!(failure.code, PublicRegistryCacheErrorCode::DigestMismatch);
175+
}
176+
177+
#[test]
178+
fn rejects_mismatched_declared_digest() {
179+
let root = unique_temp_dir();
180+
let failure = cache_verified_public_registry_bytes(
181+
&root,
182+
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
183+
b"different",
184+
)
185+
.expect_err("mismatched bytes must fail");
186+
assert_eq!(failure.code, PublicRegistryCacheErrorCode::DigestMismatch);
187+
}
188+
189+
fn unique_temp_dir() -> PathBuf {
190+
let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
191+
let path = std::env::temp_dir().join(format!("traverse-public-cache-test-{counter}"));
192+
fs::create_dir_all(&path).expect("temp directory must be created");
193+
path
194+
}
195+
}

0 commit comments

Comments
 (0)