1use 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
26pub const ARTIFACT_INDEX_FORMAT_VERSION: u32 = 1;
29
30pub const STOW_INDEX_MEDIA_TYPE: &str = "application/vnd.stow.index.v1+zstd";
33
34pub const STOW_INDEX_CONFIG_MEDIA_TYPE: &str = "application/vnd.stow.index.config.v1+json";
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ArtifactIndex {
40 pub header: ArtifactIndexHeader,
42 pub rows: Vec<ArtifactIndexRow>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ArtifactIndexHeader {
49 pub format_version: u32,
52 pub target: TargetTriple,
54 pub rustc_version: WireRustcVersion,
56 pub generated_at: String,
60 pub row_count: u64,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
68pub struct ArtifactIndexRow {
69 pub crate_name: CrateName,
71 pub version: CrateVersion,
73 pub features_json: FeaturesJson,
75 pub dependency_c_metadata_json: DependencyCMetadataJson,
78 pub c_metadata: CMetadata,
80 pub compile_key: String,
86 pub bundle_digest: String,
90 pub bundle_size: u64,
92 pub artifact_kind: ArtifactKind,
94 pub crate_types: Vec<RustCrateType>,
96 pub profile: Profile,
98 pub emit: Vec<String>,
100}
101
102#[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
120pub 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#[derive(Debug, thiserror::Error)]
143#[cfg(not(target_arch = "wasm32"))]
144pub enum IndexError {
145 #[error("serialize index to JSON: {0}")]
147 Serialize(serde_json::Error),
148 #[error("parse index JSON: {0}")]
150 Deserialize(serde_json::Error),
151 #[error("zstd compress index: {0}")]
153 Compress(std::io::Error),
154 #[error("zstd decompress index: {0}")]
156 Decompress(std::io::Error),
157 #[error(
159 "unsupported index format_version {found}; this reader understands {ARTIFACT_INDEX_FORMAT_VERSION}"
160 )]
161 UnsupportedFormatVersion {
162 found: u32,
164 },
165 #[error("index header declares {declared} rows but the body carries {actual}")]
167 RowCountMismatch {
168 declared: u64,
170 actual: u64,
172 },
173}
174
175#[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#[cfg(not(target_arch = "wasm32"))]
202pub fn decode(bytes: &[u8]) -> Result<ArtifactIndex, IndexError> {
203 use std::io::Read as _;
204
205 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 assert_eq!(
336 index_tag("wasm32-unknown-unknown", "1.92.0+dist"),
337 "index.wasm32-unknown-unknown.1.92.0_dist"
338 );
339 }
340}