use serde::{Deserialize, Serialize};
use crate::artifact::{ArtifactKind, RustCrateType};
use crate::identity::{
CMetadata, CrateName, CrateVersion, DependencyCMetadataJson, FeaturesJson, TargetTriple,
WireRustcVersion,
};
use crate::platform::Profile;
pub const ARTIFACT_INDEX_FORMAT_VERSION: u32 = 1;
pub const STOW_INDEX_MEDIA_TYPE: &str = "application/vnd.stow.index.v1+zstd";
pub const STOW_INDEX_CONFIG_MEDIA_TYPE: &str = "application/vnd.stow.index.config.v1+json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactIndex {
pub header: ArtifactIndexHeader,
pub rows: Vec<ArtifactIndexRow>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactIndexHeader {
pub format_version: u32,
pub target: TargetTriple,
pub rustc_version: WireRustcVersion,
pub generated_at: String,
pub row_count: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ArtifactIndexRow {
pub crate_name: CrateName,
pub version: CrateVersion,
pub features_json: FeaturesJson,
pub dependency_c_metadata_json: DependencyCMetadataJson,
pub c_metadata: CMetadata,
pub compile_key: String,
pub bundle_digest: String,
pub bundle_size: u64,
pub artifact_kind: ArtifactKind,
pub crate_types: Vec<RustCrateType>,
pub profile: Profile,
pub emit: Vec<String>,
}
#[must_use]
pub fn index_tag(target: &str, rustc_version: &str) -> String {
format!(
"index.{target}.{}",
crate::registry::sanitize_oci_tag_component(rustc_version)
)
}
pub fn content_sha256(index: &ArtifactIndex) -> Result<String, serde_json::Error> {
let canonical = serde_json::to_vec(&(
index.header.format_version,
&index.header.target,
&index.header.rustc_version,
&index.rows,
))?;
Ok(crate::registry::sha256_digest(&canonical))
}
#[derive(Debug, thiserror::Error)]
#[cfg(not(target_arch = "wasm32"))]
pub enum IndexError {
#[error("serialize index to JSON: {0}")]
Serialize(serde_json::Error),
#[error("parse index JSON: {0}")]
Deserialize(serde_json::Error),
#[error("zstd compress index: {0}")]
Compress(std::io::Error),
#[error("zstd decompress index: {0}")]
Decompress(std::io::Error),
#[error(
"unsupported index format_version {found}; this reader understands {ARTIFACT_INDEX_FORMAT_VERSION}"
)]
UnsupportedFormatVersion {
found: u32,
},
#[error("index header declares {declared} rows but the body carries {actual}")]
RowCountMismatch {
declared: u64,
actual: u64,
},
}
#[cfg(not(target_arch = "wasm32"))]
pub fn encode(index: &ArtifactIndex) -> Result<Vec<u8>, IndexError> {
let json = serde_json::to_vec(index).map_err(IndexError::Serialize)?;
zstd::stream::encode_all(std::io::Cursor::new(json), zstd::DEFAULT_COMPRESSION_LEVEL)
.map_err(IndexError::Compress)
}
#[cfg(not(target_arch = "wasm32"))]
pub fn decode(bytes: &[u8]) -> Result<ArtifactIndex, IndexError> {
use std::io::Read as _;
const MAX_INDEX_JSON_LEN: u64 = 256 * 1024 * 1024;
let decoder = zstd::stream::read::Decoder::new(std::io::Cursor::new(bytes))
.map_err(IndexError::Decompress)?;
let mut json = Vec::new();
decoder
.take(MAX_INDEX_JSON_LEN)
.read_to_end(&mut json)
.map_err(IndexError::Decompress)?;
let index: ArtifactIndex = serde_json::from_slice(&json).map_err(IndexError::Deserialize)?;
if index.header.format_version != ARTIFACT_INDEX_FORMAT_VERSION {
return Err(IndexError::UnsupportedFormatVersion {
found: index.header.format_version,
});
}
let actual = index.rows.len() as u64;
if index.header.row_count != actual {
return Err(IndexError::RowCountMismatch {
declared: index.header.row_count,
actual,
});
}
Ok(index)
}
#[cfg(test)]
mod tests {
use semver::Version;
use super::*;
use crate::api::CI_TARGET_TRIPLES;
use crate::platform::{PanicStrategy, StripLevel};
use crate::registry::{GHCR_BASE, oci_reference_tag};
fn index(rows: Vec<ArtifactIndexRow>) -> ArtifactIndex {
ArtifactIndex {
header: ArtifactIndexHeader {
format_version: ARTIFACT_INDEX_FORMAT_VERSION,
target: TargetTriple::parse("x86_64-unknown-linux-gnu").expect("target"),
rustc_version: WireRustcVersion::parse("1.91.1").expect("rustc"),
generated_at: "2026-09-20T12:00:00Z".to_owned(),
row_count: rows.len() as u64,
},
rows,
}
}
fn row(c_metadata: &str) -> ArtifactIndexRow {
ArtifactIndexRow {
crate_name: CrateName::parse("serde").expect("crate name"),
version: CrateVersion::new(Version::new(1, 0, 219)),
features_json: FeaturesJson::canonicalize(vec!["default".to_owned()])
.expect("features"),
dependency_c_metadata_json: DependencyCMetadataJson::default(),
c_metadata: CMetadata::parse(c_metadata).expect("c_metadata"),
compile_key: format!("{c_metadata}{c_metadata}"),
bundle_digest: format!("sha256:{c_metadata:0>64}"),
bundle_size: 1234,
artifact_kind: ArtifactKind::Rlib,
crate_types: vec![RustCrateType::Rlib],
profile: Profile {
opt_level: "3".to_owned(),
debuginfo: 0,
debug_assertions: false,
overflow_checks: false,
panic: PanicStrategy::Unwind,
strip: StripLevel::None,
},
emit: vec!["link".to_owned(), "metadata".to_owned()],
}
}
#[test]
fn encode_decode_round_trips() {
let index = index(vec![row("aaaa"), row("bbbb")]);
let bytes = encode(&index).expect("encode");
assert_eq!(decode(&bytes).expect("decode"), index);
}
#[test]
fn decode_rejects_a_foreign_format_version() {
let mut index = index(Vec::new());
index.header.format_version = 99;
let bytes = encode(&index).expect("encode");
let error = decode(&bytes).expect_err("foreign format_version must fail");
assert!(matches!(
error,
IndexError::UnsupportedFormatVersion { found: 99 }
));
}
#[test]
fn decode_rejects_a_row_count_that_disagrees_with_the_body() {
let mut index = index(vec![row("aaaa")]);
index.header.row_count = 7;
let bytes = encode(&index).expect("encode");
let error = decode(&bytes).expect_err("a lying row_count must fail");
assert!(matches!(
error,
IndexError::RowCountMismatch {
declared: 7,
actual: 1
}
));
}
#[test]
fn every_ci_target_produces_a_legal_index_tag() {
for target in CI_TARGET_TRIPLES {
let tag = index_tag(target, "1.91.1");
let reference = format!("{GHCR_BASE}:{tag}");
assert_eq!(
oci_reference_tag(&reference),
Some(tag.as_str()),
"illegal tag for {target}: {tag}"
);
}
}
#[test]
fn index_tag_format() {
assert_eq!(
index_tag("x86_64-unknown-linux-gnu", "1.91.1"),
"index.x86_64-unknown-linux-gnu.1.91.1"
);
assert_eq!(
index_tag("wasm32-unknown-unknown", "1.92.0+dist"),
"index.wasm32-unknown-unknown.1.92.0_dist"
);
}
}