Skip to main content

stow_cli/
index.rs

1//! The locally cached, signature-verified artifact index (stow#194).
2//!
3//! Each `(target, rustc_version)` slice is published to the OCI registry as
4//! `index.<target>.<rustc>` by `.github/workflows/index-publish.yml`. The
5//! wrapper pulls the slice's manifest and single zstd layer, verifies the
6//! cosign signature against the index-publish workflow identity, and caches
7//! the verified blob under `cache_dir/index/<target>/<rustc_version>/`: the
8//! blob named by manifest digest plus a `current.json` pointer carrying
9//! `{manifest_digest, fetched_at, row_count}`.
10//!
11//! Refresh policy: a pointer younger than `index_refresh_interval`
12//! short-circuits the network entirely; past it, one manifest request
13//! re-validates the digest and only a moved digest costs a download and
14//! re-verification. Network failure with a cached slice logs at `info` and
15//! serves it; no cached slice and no reachable registry is a hard error
16//! naming the tag — resolution then proceeds only from signed bytes.
17
18use 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
30/// The pointer file inside each slice directory.
31const POINTER_FILE: &str = "current.json";
32
33/// The cached-slice pointer: which verified blob `current` selects, when
34/// the registry last confirmed it, and the row count it decoded to so
35/// `stow index status` never pays a decode.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37struct SlicePointer {
38    manifest_digest: String,
39    fetched_at: u64,
40    row_count: u64,
41}
42
43/// A verified index slice: the decoded rows plus the manifest digest that
44/// carried them, so callers can report what they resolved against.
45#[derive(Debug, Clone)]
46pub struct IndexSlice {
47    /// `sha256:…` of the OCI manifest the blob came from.
48    pub manifest_digest: String,
49    /// The decoded index — header identity already checked against the
50    /// requested `(target, rustc_version)`.
51    pub index: ArtifactIndex,
52}
53
54/// One cached slice's status, surfaced by `stow index status`.
55#[derive(Debug, Clone)]
56pub struct CachedSliceStatus {
57    /// Compilation target triple of the slice.
58    pub target: String,
59    /// Stable rustc version of the slice.
60    pub rustc_version: String,
61    /// `sha256:…` of the manifest the cached blob came from.
62    pub manifest_digest: String,
63    /// Unix seconds at which the registry last confirmed the digest.
64    pub fetched_at: u64,
65    /// Number of rows the slice carries.
66    pub row_count: u64,
67}
68
69/// The slice every resolution path consumes: serve the verified cache while
70/// it is fresh, re-validate against the registry past the refresh interval,
71/// and fall back to the cache when the registry is unreachable. With no
72/// cached slice, a registry failure is the error the caller surfaces — the
73/// resolver never runs against unsigned or absent data.
74///
75/// # Errors
76///
77/// Returns an error when no usable slice can be produced.
78pub 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
86/// The wrapper's slice source: the verified cache only, never the network.
87/// Freshness is the driver's job — `cargo`-side analysis calls
88/// [`ensure_slice`] once per build — so a per-invocation wrapper that paid
89/// a manifest revalidation would put a registry round trip on every rustc
90/// call. `None` means "no verified slice is cached": the caller treats it
91/// as a cache miss, not an error.
92///
93/// # Errors
94///
95/// Returns an error when the cached bytes or pointer cannot be decoded —
96/// corruption is surfaced, absence is not an error.
97pub 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
109/// `stow index refresh` — hit the registry even when the pointer is fresh.
110///
111/// # Errors
112///
113/// Returns an error when the registry is unreachable and no cached slice
114/// exists, when the fetched artifact fails signature or digest
115/// verification, or when the cached bytes cannot be decoded.
116pub 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
124/// Every cached slice under `cache_dir/index/`, for `stow index status`.
125///
126/// # Errors
127///
128/// Returns an error when the index directory cannot be traversed.
129pub 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
250/// Pull, verify, and decode the index artifact `tag` resolves to. Any
251/// failure — manifest shape, blob digest, signature, slice identity —
252/// aborts before a byte is cached.
253async 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    // The signer binds the canonical GHCR reference, not whichever
280    // transport base the pull came through.
281    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
297/// Atomically swap the verified blob into `dir`: temp-write, rename over
298/// the digest-named slot, then drop every other blob so one slice dir never
299/// holds two generations.
300async 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
366/// `sha256:<hex>` → `sha256_<hex>` — the on-disk blob name for a manifest
367/// digest (a `:` cannot appear in a file name on every platform).
368fn 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}