Skip to main content

stow_types/
index.rs

1//! The signed per-`(target, rustc_version)` artifact index published to
2//! GHCR (water-rs/stow#188).
3//!
4//! One index file covers one slice of the `artifacts` table: every servable
5//! row (a pushed bundle) for one target triple and one stable rustc,
6//! ordered by `c_metadata`. The CLI downloads the slice its build needs,
7//! verifies the cosign signature against
8//! [`crate::trusted_builder::INDEX_CERTIFICATE_IDENTITY`], and computes
9//! hits, semver-compatible upgrades and misses locally — the dependency
10//! graph never leaves the machine.
11//!
12//! On the wire the index is `zstd`-compressed JSON of [`ArtifactIndex`]
13//! with [`encode`] / [`decode`] the only entry points. The header pins a
14//! `format_version` readers reject on mismatch and a `row_count` checked
15//! against the decoded rows.
16
17use serde::{Deserialize, Serialize};
18
19use crate::artifact::{ArtifactKind, RustCrateType};
20use crate::identity::{
21    CMetadata, CrateName, CrateVersion, DependencyCMetadataJson, FeaturesJson, TargetTriple,
22    WireRustcVersion,
23};
24use crate::platform::Profile;
25
26/// The `format_version` this crate writes and the only one [`decode`]
27/// accepts.
28pub const ARTIFACT_INDEX_FORMAT_VERSION: u32 = 1;
29
30/// Media type of the index's single OCI layer — the zstd-compressed
31/// [`ArtifactIndex`] JSON.
32pub const STOW_INDEX_MEDIA_TYPE: &str = "application/vnd.stow.index.v1+zstd";
33
34/// Media type of the OCI config the index artifact carries.
35pub const STOW_INDEX_CONFIG_MEDIA_TYPE: &str = "application/vnd.stow.index.config.v1+json";
36
37/// The published artifact index: a versioned header plus the slice's rows.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ArtifactIndex {
40    /// Versioned header; `row_count` must equal `rows.len()` on decode.
41    pub header: ArtifactIndexHeader,
42    /// Every servable row of the slice, ordered by `c_metadata`.
43    pub rows: Vec<ArtifactIndexRow>,
44}
45
46/// The index's self-describing header.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ArtifactIndexHeader {
49    /// Format version — [`ARTIFACT_INDEX_FORMAT_VERSION`] in every file
50    /// this crate encodes.
51    pub format_version: u32,
52    /// The slice's compilation target triple.
53    pub target: TargetTriple,
54    /// The slice's stable rustc version (e.g. `"1.91.1"`).
55    pub rustc_version: WireRustcVersion,
56    /// RFC 3339 UTC timestamp of the export. Informational only — it
57    /// makes every export's bytes unique, so publish-side change
58    /// detection digests the rows, never the blob.
59    pub generated_at: String,
60    /// Number of rows the body carries; [`decode`] rejects a mismatch.
61    pub row_count: u64,
62}
63
64/// One servable artifact row of the slice — everything a client needs to
65/// resolve `(crate, version, features, dependency identities)` to a
66/// `bundle_digest` it can fetch.
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
68pub struct ArtifactIndexRow {
69    /// Crate name as published on crates.io.
70    pub crate_name: CrateName,
71    /// Exact crate version.
72    pub version: CrateVersion,
73    /// Canonicalized features list (sorted, deduplicated).
74    pub features_json: FeaturesJson,
75    /// Sorted `(crate_name, c_metadata)` identities of the dependencies
76    /// this artifact was built against — the transitive-closure chain.
77    pub dependency_c_metadata_json: DependencyCMetadataJson,
78    /// Cargo's `-C metadata` value — the exact cache lookup key.
79    pub c_metadata: CMetadata,
80    /// Blake3 compile key of this artifact. A row is *canonical* — the
81    /// identity semantic matching and closure walks may use — only when
82    /// [`crate::public_cache::stable_c_metadata_for_compile_key`] maps it
83    /// back to `c_metadata`; non-canonical rows remain servable by exact
84    /// `c_metadata` lookup but must not participate in graph analysis.
85    pub compile_key: String,
86    /// Digest (`sha256:…`) of the `<tag>.bundle` blob the edge streams.
87    /// Always non-empty: unbundled rows are not servable and never enter
88    /// the index.
89    pub bundle_digest: String,
90    /// Byte length of that blob.
91    pub bundle_size: u64,
92    /// Primary artifact kind (rlib / dylib / proc-macro).
93    pub artifact_kind: ArtifactKind,
94    /// Declared Rust crate types.
95    pub crate_types: Vec<RustCrateType>,
96    /// Compilation profile observed from the captured rustc invocation.
97    pub profile: Profile,
98    /// Sorted, deduplicated `--emit` modes observed from the invocation.
99    pub emit: Vec<String>,
100}
101
102/// The OCI tag of the index artifact for one slice:
103/// `index.<target>.<rustc_short>` on `ghcr.io/water-rs/stow-cache`.
104///
105/// `rustc_short` is the same short form artifact tags carry — the semver
106/// rendering `RustcVersion::short` produces, which is exactly the wire
107/// version string — passed through the registry's tag-component sanitizer
108/// so a `+`-bearing build-metadata suffix folds the same way a crate
109/// version does. The target stays verbatim: the `arch-os` short form
110/// collapses `aarch64-apple-ios` and `aarch64-apple-ios-sim` onto one tag,
111/// so only the full triple keeps the slices distinct.
112#[must_use]
113pub fn index_tag(target: &str, rustc_version: &str) -> String {
114    format!(
115        "index.{target}.{}",
116        crate::registry::sanitize_oci_tag_component(rustc_version)
117    )
118}
119
120/// The publish-side content digest: `sha256` over the canonical JSON of
121/// everything in the index except the wall-clock `generated_at`.
122///
123/// `generated_at` makes every export's blob bytes unique, so the blob
124/// digest can never signal "unchanged"; this digest is what the
125/// index-publish workflow compares (carried in a manifest annotation)
126/// to decide whether the slice changed.
127///
128/// # Errors
129///
130/// Fails only if serde cannot serialize the fields, which cannot happen.
131pub fn content_sha256(index: &ArtifactIndex) -> Result<String, serde_json::Error> {
132    let canonical = serde_json::to_vec(&(
133        index.header.format_version,
134        &index.header.target,
135        &index.header.rustc_version,
136        &index.rows,
137    ))?;
138    Ok(crate::registry::sha256_digest(&canonical))
139}
140
141/// Errors raised by [`encode`] and [`decode`].
142#[derive(Debug, thiserror::Error)]
143#[cfg(not(target_arch = "wasm32"))]
144pub enum IndexError {
145    /// Serializing the index to JSON failed.
146    #[error("serialize index to JSON: {0}")]
147    Serialize(serde_json::Error),
148    /// The decompressed payload is not index JSON.
149    #[error("parse index JSON: {0}")]
150    Deserialize(serde_json::Error),
151    /// zstd compression failed.
152    #[error("zstd compress index: {0}")]
153    Compress(std::io::Error),
154    /// The payload is not a zstd frame or decompression failed.
155    #[error("zstd decompress index: {0}")]
156    Decompress(std::io::Error),
157    /// The header names a format this reader does not understand.
158    #[error(
159        "unsupported index format_version {found}; this reader understands {ARTIFACT_INDEX_FORMAT_VERSION}"
160    )]
161    UnsupportedFormatVersion {
162        /// The version the decoded header declared.
163        found: u32,
164    },
165    /// The header's `row_count` does not match the decoded body.
166    #[error("index header declares {declared} rows but the body carries {actual}")]
167    RowCountMismatch {
168        /// `row_count` as written in the header.
169        declared: u64,
170        /// The body's actual row count.
171        actual: u64,
172    },
173}
174
175/// Serialize `index` as JSON and zstd-compress it — the bytes the OCI
176/// layer carries.
177///
178/// Not compiled for `wasm32`: `zstd` does not build there and the edge
179/// never encodes indexes — producers (admin, CI) and the CLI consumer are
180/// all native.
181///
182/// # Errors
183///
184/// [`IndexError::Serialize`] or [`IndexError::Compress`].
185#[cfg(not(target_arch = "wasm32"))]
186pub fn encode(index: &ArtifactIndex) -> Result<Vec<u8>, IndexError> {
187    let json = serde_json::to_vec(index).map_err(IndexError::Serialize)?;
188    zstd::stream::encode_all(std::io::Cursor::new(json), zstd::DEFAULT_COMPRESSION_LEVEL)
189        .map_err(IndexError::Compress)
190}
191
192/// Decompress and parse index bytes, then check the header: a foreign
193/// `format_version` or a `row_count` that does not match the body is
194/// rejected.
195///
196/// # Errors
197///
198/// [`IndexError::Decompress`], [`IndexError::Deserialize`],
199/// [`IndexError::UnsupportedFormatVersion`], or
200/// [`IndexError::RowCountMismatch`].
201#[cfg(not(target_arch = "wasm32"))]
202pub fn decode(bytes: &[u8]) -> Result<ArtifactIndex, IndexError> {
203    use std::io::Read as _;
204
205    /// Decompressed JSON the decoder will read before giving up — a bound
206    /// on how much a hostile or corrupt blob can inflate into memory. At
207    /// ~1 KB per row this still covers a slice orders of magnitude past
208    /// any plausible pool size.
209    const MAX_INDEX_JSON_LEN: u64 = 256 * 1024 * 1024;
210
211    let decoder = zstd::stream::read::Decoder::new(std::io::Cursor::new(bytes))
212        .map_err(IndexError::Decompress)?;
213    let mut json = Vec::new();
214    decoder
215        .take(MAX_INDEX_JSON_LEN)
216        .read_to_end(&mut json)
217        .map_err(IndexError::Decompress)?;
218    let index: ArtifactIndex = serde_json::from_slice(&json).map_err(IndexError::Deserialize)?;
219    if index.header.format_version != ARTIFACT_INDEX_FORMAT_VERSION {
220        return Err(IndexError::UnsupportedFormatVersion {
221            found: index.header.format_version,
222        });
223    }
224    let actual = index.rows.len() as u64;
225    if index.header.row_count != actual {
226        return Err(IndexError::RowCountMismatch {
227            declared: index.header.row_count,
228            actual,
229        });
230    }
231    Ok(index)
232}
233
234#[cfg(test)]
235mod tests {
236    use semver::Version;
237
238    use super::*;
239    use crate::api::CI_TARGET_TRIPLES;
240    use crate::platform::{PanicStrategy, StripLevel};
241    use crate::registry::{GHCR_BASE, oci_reference_tag};
242
243    fn index(rows: Vec<ArtifactIndexRow>) -> ArtifactIndex {
244        ArtifactIndex {
245            header: ArtifactIndexHeader {
246                format_version: ARTIFACT_INDEX_FORMAT_VERSION,
247                target: TargetTriple::parse("x86_64-unknown-linux-gnu").expect("target"),
248                rustc_version: WireRustcVersion::parse("1.91.1").expect("rustc"),
249                generated_at: "2026-09-20T12:00:00Z".to_owned(),
250                row_count: rows.len() as u64,
251            },
252            rows,
253        }
254    }
255
256    fn row(c_metadata: &str) -> ArtifactIndexRow {
257        ArtifactIndexRow {
258            crate_name: CrateName::parse("serde").expect("crate name"),
259            version: CrateVersion::new(Version::new(1, 0, 219)),
260            features_json: FeaturesJson::canonicalize(vec!["default".to_owned()])
261                .expect("features"),
262            dependency_c_metadata_json: DependencyCMetadataJson::default(),
263            c_metadata: CMetadata::parse(c_metadata).expect("c_metadata"),
264            compile_key: format!("{c_metadata}{c_metadata}"),
265            bundle_digest: format!("sha256:{c_metadata:0>64}"),
266            bundle_size: 1234,
267            artifact_kind: ArtifactKind::Rlib,
268            crate_types: vec![RustCrateType::Rlib],
269            profile: Profile {
270                opt_level: "3".to_owned(),
271                debuginfo: 0,
272                debug_assertions: false,
273                overflow_checks: false,
274                panic: PanicStrategy::Unwind,
275                strip: StripLevel::None,
276            },
277            emit: vec!["link".to_owned(), "metadata".to_owned()],
278        }
279    }
280
281    #[test]
282    fn encode_decode_round_trips() {
283        let index = index(vec![row("aaaa"), row("bbbb")]);
284        let bytes = encode(&index).expect("encode");
285        assert_eq!(decode(&bytes).expect("decode"), index);
286    }
287
288    #[test]
289    fn decode_rejects_a_foreign_format_version() {
290        let mut index = index(Vec::new());
291        index.header.format_version = 99;
292        let bytes = encode(&index).expect("encode");
293        let error = decode(&bytes).expect_err("foreign format_version must fail");
294        assert!(matches!(
295            error,
296            IndexError::UnsupportedFormatVersion { found: 99 }
297        ));
298    }
299
300    #[test]
301    fn decode_rejects_a_row_count_that_disagrees_with_the_body() {
302        let mut index = index(vec![row("aaaa")]);
303        index.header.row_count = 7;
304        let bytes = encode(&index).expect("encode");
305        let error = decode(&bytes).expect_err("a lying row_count must fail");
306        assert!(matches!(
307            error,
308            IndexError::RowCountMismatch {
309                declared: 7,
310                actual: 1
311            }
312        ));
313    }
314
315    #[test]
316    fn every_ci_target_produces_a_legal_index_tag() {
317        for target in CI_TARGET_TRIPLES {
318            let tag = index_tag(target, "1.91.1");
319            let reference = format!("{GHCR_BASE}:{tag}");
320            assert_eq!(
321                oci_reference_tag(&reference),
322                Some(tag.as_str()),
323                "illegal tag for {target}: {tag}"
324            );
325        }
326    }
327
328    #[test]
329    fn index_tag_format() {
330        assert_eq!(
331            index_tag("x86_64-unknown-linux-gnu", "1.91.1"),
332            "index.x86_64-unknown-linux-gnu.1.91.1"
333        );
334        // Build metadata folds the same way artifact tags fold it.
335        assert_eq!(
336            index_tag("wasm32-unknown-unknown", "1.92.0+dist"),
337            "index.wasm32-unknown-unknown.1.92.0_dist"
338        );
339    }
340}