1use std::path::{Path, PathBuf};
19use std::time::{SystemTime, UNIX_EPOCH};
20
21use futures_lite::StreamExt as _;
22use serde::{Deserialize, Serialize};
23use stow_types::error::Context;
24use stow_types::index::{ArtifactIndex, STOW_INDEX_MEDIA_TYPE, index_tag};
25use stow_types::registry::GHCR_BASE;
26
27use crate::config::StowConfig;
28use crate::verify;
29
30const POINTER_FILE: &str = "current.json";
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
37struct SlicePointer {
38 manifest_digest: String,
39 fetched_at: u64,
40 row_count: u64,
41}
42
43#[derive(Debug, Clone)]
46pub struct IndexSlice {
47 pub manifest_digest: String,
49 pub index: ArtifactIndex,
52}
53
54#[derive(Debug, Clone)]
56pub struct CachedSliceStatus {
57 pub target: String,
59 pub rustc_version: String,
61 pub manifest_digest: String,
63 pub fetched_at: u64,
65 pub row_count: u64,
67}
68
69pub async fn ensure_slice(
79 config: &StowConfig,
80 target: &str,
81 rustc_version: &str,
82) -> stow_types::error::Result<IndexSlice> {
83 fetch_slice(config, target, rustc_version, false).await
84}
85
86pub async fn cached_slice(
98 config: &StowConfig,
99 target: &str,
100 rustc_version: &str,
101) -> stow_types::error::Result<Option<IndexSlice>> {
102 let dir = slice_dir(config, target, rustc_version);
103 let Some(pointer) = read_pointer(&dir).await else {
104 return Ok(None);
105 };
106 load_cached(&dir, &pointer).await.map(Some)
107}
108
109pub async fn refresh_slice(
117 config: &StowConfig,
118 target: &str,
119 rustc_version: &str,
120) -> stow_types::error::Result<IndexSlice> {
121 fetch_slice(config, target, rustc_version, true).await
122}
123
124pub async fn cached_slices(
130 config: &StowConfig,
131) -> stow_types::error::Result<Vec<CachedSliceStatus>> {
132 let root = config.cache_dir.join("index");
133 let mut slices = Vec::new();
134 let Ok(mut targets) = async_fs::read_dir(&root).await else {
135 return Ok(slices);
136 };
137 while let Some(target_entry) = targets
138 .next()
139 .await
140 .transpose()
141 .wrap_err_with(|| format!("traverse index cache {}", root.display()))?
142 {
143 if !target_entry
144 .file_type()
145 .await
146 .is_ok_and(|kind| kind.is_dir())
147 {
148 continue;
149 }
150 let target = target_entry.file_name().to_string_lossy().into_owned();
151 let mut versions = async_fs::read_dir(target_entry.path())
152 .await
153 .wrap_err_with(|| format!("read index dir {}", target_entry.path().display()))?;
154 while let Some(version_entry) = versions
155 .next()
156 .await
157 .transpose()
158 .wrap_err_with(|| format!("traverse index dir {}", target_entry.path().display()))?
159 {
160 let dir = version_entry.path();
161 let Some(pointer) = read_pointer(&dir).await else {
162 continue;
163 };
164 slices.push(CachedSliceStatus {
165 target: target.clone(),
166 rustc_version: version_entry.file_name().to_string_lossy().into_owned(),
167 manifest_digest: pointer.manifest_digest,
168 fetched_at: pointer.fetched_at,
169 row_count: pointer.row_count,
170 });
171 }
172 }
173 slices.sort_by(|a, b| {
174 a.target
175 .cmp(&b.target)
176 .then_with(|| a.rustc_version.cmp(&b.rustc_version))
177 });
178 Ok(slices)
179}
180
181async fn fetch_slice(
182 config: &StowConfig,
183 target: &str,
184 rustc_version: &str,
185 force: bool,
186) -> stow_types::error::Result<IndexSlice> {
187 let dir = slice_dir(config, target, rustc_version);
188 let pointer = read_pointer(&dir).await;
189 if !force
190 && let Some(pointer) = &pointer
191 && now_secs().saturating_sub(pointer.fetched_at) < config.index_refresh_interval.as_secs()
192 {
193 return load_cached(&dir, pointer).await;
194 }
195
196 let tag = index_tag(target, rustc_version);
197 let base = stow_oci::RegistryBase::parse(&config.registry_base_url)?;
198 let (client, auth) = base.client();
199 let reference = base.reference(&tag)?;
200
201 match client.fetch_manifest_digest(&reference, &auth).await {
202 Ok(remote_digest) => {
203 if let Some(pointer) = &pointer
204 && pointer.manifest_digest == remote_digest
205 {
206 let pointer = SlicePointer {
207 row_count: pointer.row_count,
208 manifest_digest: remote_digest,
209 fetched_at: now_secs(),
210 };
211 write_pointer(&dir, &pointer).await?;
212 return load_cached(&dir, &pointer).await;
213 }
214 let (blob, manifest_digest, index) = download_verified_slice(
215 config,
216 &client,
217 &auth,
218 &reference,
219 &tag,
220 target,
221 rustc_version,
222 )
223 .await?;
224 store_slice(&dir, &manifest_digest, &blob).await?;
225 let pointer = SlicePointer {
226 row_count: index.rows.len() as u64,
227 manifest_digest: manifest_digest.clone(),
228 fetched_at: now_secs(),
229 };
230 write_pointer(&dir, &pointer).await?;
231 Ok(IndexSlice {
232 manifest_digest,
233 index,
234 })
235 }
236 Err(error) => {
237 if let Some(pointer) = &pointer {
238 tracing::info!(
239 error = %error,
240 tag = %tag,
241 "index refresh failed; serving cached slice"
242 );
243 return load_cached(&dir, pointer).await;
244 }
245 Err(stow_types::stow_error!("fetch index slice {tag}: {error}"))
246 }
247 }
248}
249
250async fn download_verified_slice(
254 config: &StowConfig,
255 client: &oci_client::Client,
256 auth: &oci_client::secrets::RegistryAuth,
257 reference: &oci_client::Reference,
258 tag: &str,
259 target: &str,
260 rustc_version: &str,
261) -> stow_types::error::Result<(Vec<u8>, String, ArtifactIndex)> {
262 let (manifest_digest, manifest) =
263 stow_oci::pull_tagged_manifest(client, auth, reference).await?;
264 let [layer] = manifest.layers.as_slice() else {
265 return Err(stow_types::stow_error!(
266 "index manifest {reference} carries {} layers, expected exactly one",
267 manifest.layers.len()
268 ));
269 };
270 if layer.media_type != STOW_INDEX_MEDIA_TYPE {
271 return Err(stow_types::stow_error!(
272 "index manifest {reference} layer is {}, expected {STOW_INDEX_MEDIA_TYPE}",
273 layer.media_type
274 ));
275 }
276 let blob = stow_oci::pull_blob_verified(client, reference, layer).await?;
277 let materials =
278 stow_oci::pull_signature_materials(client, auth, reference, &manifest_digest).await?;
279 let identity_reference = format!("{GHCR_BASE}:{tag}");
282 verify::verify_index_signature(config, &identity_reference, &manifest_digest, &materials)
283 .await?;
284 let index = stow_types::index::decode(&blob).wrap_err("decode index slice")?;
285 if index.header.target.as_str() != target
286 || index.header.rustc_version.as_str() != rustc_version
287 {
288 return Err(stow_types::stow_error!(
289 "index slice {tag} was published for {}@{}",
290 index.header.target.as_str(),
291 index.header.rustc_version.as_str()
292 ));
293 }
294 Ok((blob, manifest_digest, index))
295}
296
297async fn store_slice(
301 dir: &Path,
302 manifest_digest: &str,
303 blob: &[u8],
304) -> stow_types::error::Result<()> {
305 async_fs::create_dir_all(dir)
306 .await
307 .wrap_err_with(|| format!("create index dir {}", dir.display()))?;
308 let keep = blob_name(manifest_digest);
309 let tmp = dir.join(format!(".tmp-{}", std::process::id()));
310 async_fs::write(&tmp, blob)
311 .await
312 .wrap_err_with(|| format!("write index blob {}", tmp.display()))?;
313 async_fs::rename(&tmp, dir.join(&keep))
314 .await
315 .wrap_err_with(|| format!("commit index blob into {}", dir.display()))?;
316 let mut entries = async_fs::read_dir(dir)
317 .await
318 .wrap_err_with(|| format!("read index dir {}", dir.display()))?;
319 while let Some(entry) = entries
320 .next()
321 .await
322 .transpose()
323 .wrap_err_with(|| format!("traverse index dir {}", dir.display()))?
324 {
325 let name = entry.file_name();
326 if name != POINTER_FILE && name != keep.as_str() {
327 async_fs::remove_file(entry.path())
328 .await
329 .wrap_err_with(|| format!("evict stale index blob {}", entry.path().display()))?;
330 }
331 }
332 Ok(())
333}
334
335async fn write_pointer(dir: &Path, pointer: &SlicePointer) -> stow_types::error::Result<()> {
336 async_fs::create_dir_all(dir)
337 .await
338 .wrap_err_with(|| format!("create index dir {}", dir.display()))?;
339 let tmp = dir.join(format!(".{POINTER_FILE}.tmp-{}", std::process::id()));
340 async_fs::write(&tmp, serde_json::to_vec(pointer)?)
341 .await
342 .wrap_err_with(|| format!("write index pointer {}", tmp.display()))?;
343 async_fs::rename(&tmp, dir.join(POINTER_FILE))
344 .await
345 .wrap_err_with(|| format!("commit index pointer in {}", dir.display()))?;
346 Ok(())
347}
348
349async fn read_pointer(dir: &Path) -> Option<SlicePointer> {
350 let bytes = async_fs::read(dir.join(POINTER_FILE)).await.ok()?;
351 serde_json::from_slice(&bytes).ok()
352}
353
354async fn load_cached(dir: &Path, pointer: &SlicePointer) -> stow_types::error::Result<IndexSlice> {
355 let path = dir.join(blob_name(&pointer.manifest_digest));
356 let bytes = async_fs::read(&path)
357 .await
358 .wrap_err_with(|| format!("read cached index {}", path.display()))?;
359 let index = stow_types::index::decode(&bytes).wrap_err("decode cached index slice")?;
360 Ok(IndexSlice {
361 manifest_digest: pointer.manifest_digest.clone(),
362 index,
363 })
364}
365
366fn blob_name(manifest_digest: &str) -> String {
369 manifest_digest.replace(':', "_")
370}
371
372fn slice_dir(config: &StowConfig, target: &str, rustc_version: &str) -> PathBuf {
373 config
374 .cache_dir
375 .join("index")
376 .join(target)
377 .join(rustc_version)
378}
379
380fn now_secs() -> u64 {
381 SystemTime::now()
382 .duration_since(UNIX_EPOCH)
383 .map_or(0, |since| since.as_secs())
384}
385
386#[cfg(test)]
387mod tests {
388 use semver::Version;
389 use stow_types::artifact::{ArtifactKind, RustCrateType};
390 use stow_types::identity::{
391 CMetadata, CrateName, CrateVersion, DependencyCMetadataJson, FeaturesJson, TargetTriple,
392 WireRustcVersion,
393 };
394 use stow_types::index::{ARTIFACT_INDEX_FORMAT_VERSION, ArtifactIndexHeader, ArtifactIndexRow};
395 use stow_types::platform::{PanicStrategy, Profile, StripLevel};
396
397 use super::*;
398 use crate::config::VerifyMode;
399
400 const TARGET: &str = "x86_64-unknown-linux-gnu";
401 const RUSTC: &str = "1.91.1";
402
403 fn test_config(cache_dir: &Path) -> StowConfig {
404 StowConfig {
405 edge_url: "http://127.0.0.1:8787".to_owned(),
406 registry_base_url: "http://127.0.0.1:8787/v2/water-rs/stow-cache".to_owned(),
407 cache_dir: cache_dir.to_path_buf(),
408 request_timeout: std::time::Duration::from_secs(15),
409 negative_cache_ttl: std::time::Duration::from_mins(5),
410 circuit_reset_after: std::time::Duration::from_mins(1),
411 circuit_trip_threshold: 5,
412 artifact_cache_max_bytes: 1024,
413 index_refresh_interval: std::time::Duration::from_mins(10),
414 verify_mode: VerifyMode::GithubCi,
415 state_db_pool: StowConfig::default_state_db_pool(),
416 trust_material: std::sync::Arc::default(),
417 }
418 }
419
420 fn test_row(c_metadata: &str) -> ArtifactIndexRow {
421 ArtifactIndexRow {
422 crate_name: CrateName::parse("serde").expect("crate name"),
423 version: CrateVersion::new(Version::new(1, 0, 219)),
424 features_json: FeaturesJson::canonicalize(vec!["default".to_owned()])
425 .expect("features"),
426 dependency_c_metadata_json: DependencyCMetadataJson::default(),
427 c_metadata: CMetadata::parse(c_metadata).expect("c_metadata"),
428 compile_key: format!("{c_metadata}{c_metadata}"),
429 bundle_digest: format!("sha256:{c_metadata:0>64}"),
430 bundle_size: 1234,
431 artifact_kind: ArtifactKind::Rlib,
432 crate_types: vec![RustCrateType::Rlib],
433 profile: Profile {
434 opt_level: "3".to_owned(),
435 debuginfo: 0,
436 debug_assertions: false,
437 overflow_checks: false,
438 panic: PanicStrategy::Unwind,
439 strip: StripLevel::None,
440 },
441 emit: vec!["link".to_owned(), "metadata".to_owned()],
442 }
443 }
444
445 fn test_index(rows: Vec<ArtifactIndexRow>) -> ArtifactIndex {
446 ArtifactIndex {
447 header: ArtifactIndexHeader {
448 format_version: ARTIFACT_INDEX_FORMAT_VERSION,
449 target: TargetTriple::parse(TARGET).expect("target"),
450 rustc_version: WireRustcVersion::parse(RUSTC).expect("rustc"),
451 generated_at: "2026-09-24T12:00:00Z".to_owned(),
452 row_count: rows.len() as u64,
453 },
454 rows,
455 }
456 }
457
458 #[test]
459 fn blob_name_sanitizes_digest_colon() {
460 assert_eq!(blob_name("sha256:ab12"), "sha256_ab12");
461 }
462
463 #[tokio::test]
464 async fn store_pointer_then_load_cached_round_trips() {
465 let tempdir = tempfile::tempdir().expect("tempdir");
466 let config = test_config(tempdir.path());
467 let dir = slice_dir(&config, TARGET, RUSTC);
468 let index = test_index(vec![test_row("aaaa"), test_row("bbbb")]);
469 let blob = stow_types::index::encode(&index).expect("encode");
470 let digest = "sha256:deadbeef".to_owned();
471
472 store_slice(&dir, &digest, &blob).await.expect("store");
473 write_pointer(
474 &dir,
475 &SlicePointer {
476 manifest_digest: digest.clone(),
477 fetched_at: 1234,
478 row_count: 2,
479 },
480 )
481 .await
482 .expect("write pointer");
483
484 let pointer = read_pointer(&dir).await.expect("pointer");
485 assert_eq!(pointer.manifest_digest, digest);
486 let loaded = load_cached(&dir, &pointer).await.expect("load cached");
487 assert_eq!(loaded.manifest_digest, digest);
488 assert_eq!(loaded.index, index);
489 }
490
491 #[tokio::test]
492 async fn store_slice_evicts_stale_blobs_and_pointer_stays() {
493 let tempdir = tempfile::tempdir().expect("tempdir");
494 let config = test_config(tempdir.path());
495 let dir = slice_dir(&config, TARGET, RUSTC);
496 let blob = stow_types::index::encode(&test_index(vec![test_row("aaaa")])).expect("encode");
497
498 store_slice(&dir, "sha256:aaaa", &blob)
499 .await
500 .expect("first store");
501 write_pointer(
502 &dir,
503 &SlicePointer {
504 manifest_digest: "sha256:aaaa".to_owned(),
505 fetched_at: 1,
506 row_count: 1,
507 },
508 )
509 .await
510 .expect("pointer");
511 store_slice(&dir, "sha256:bbbb", &blob)
512 .await
513 .expect("second store");
514
515 let mut names: Vec<String> = std::fs::read_dir(&dir)
516 .expect("read dir")
517 .map(|entry| {
518 entry
519 .expect("entry")
520 .file_name()
521 .to_string_lossy()
522 .into_owned()
523 })
524 .collect();
525 names.sort();
526 assert_eq!(names, vec!["current.json", "sha256_bbbb"]);
527 }
528
529 #[tokio::test]
530 async fn cached_slices_reports_pointer_fields() {
531 let tempdir = tempfile::tempdir().expect("tempdir");
532 let config = test_config(tempdir.path());
533 let dir = slice_dir(&config, TARGET, RUSTC);
534 let index = test_index(vec![test_row("aaaa")]);
535 let blob = stow_types::index::encode(&index).expect("encode");
536 store_slice(&dir, "sha256:aaaa", &blob)
537 .await
538 .expect("store");
539 write_pointer(
540 &dir,
541 &SlicePointer {
542 manifest_digest: "sha256:aaaa".to_owned(),
543 fetched_at: 4242,
544 row_count: 1,
545 },
546 )
547 .await
548 .expect("pointer");
549
550 let slices = cached_slices(&config).await.expect("cached slices");
551 assert_eq!(slices.len(), 1);
552 let slice = &slices[0];
553 assert_eq!(slice.target, TARGET);
554 assert_eq!(slice.rustc_version, RUSTC);
555 assert_eq!(slice.manifest_digest, "sha256:aaaa");
556 assert_eq!(slice.fetched_at, 4242);
557 assert_eq!(slice.row_count, 1);
558 }
559
560 #[tokio::test]
561 async fn cached_slices_empty_without_cache() {
562 let tempdir = tempfile::tempdir().expect("tempdir");
563 let config = test_config(tempdir.path());
564 assert!(
565 cached_slices(&config)
566 .await
567 .expect("cached slices")
568 .is_empty()
569 );
570 }
571}