use std::collections::BTreeMap;
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const SCHEMA: &str = "phoxal-artifacts";
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
pub schema: String,
pub revision: String,
pub assets: Vec<AssetEntry>,
pub services: Vec<ArtifactEntry>,
pub drivers: Vec<ArtifactEntry>,
pub tools: Vec<ArtifactEntry>,
pub simulators: Vec<ArtifactEntry>,
}
impl Manifest {
pub fn new(
assets: Vec<AssetEntry>,
services: Vec<ArtifactEntry>,
drivers: Vec<ArtifactEntry>,
tools: Vec<ArtifactEntry>,
simulators: Vec<ArtifactEntry>,
) -> Self {
Self {
schema: SCHEMA.to_string(),
revision: String::new(),
assets,
services,
drivers,
tools,
simulators,
}
}
pub fn total_entries(&self) -> usize {
self.assets.len()
+ self.services.len()
+ self.drivers.len()
+ self.tools.len()
+ self.simulators.len()
}
pub fn artifact_entries(&self) -> impl Iterator<Item = &ArtifactEntry> {
self.services
.iter()
.chain(&self.drivers)
.chain(&self.tools)
.chain(&self.simulators)
}
pub fn finalize(mut self) -> Result<Self> {
let checksum = self.content_sha256()?;
self.revision = format!("sha256:{checksum}");
Ok(self)
}
pub fn verify(&self) -> Result<()> {
if self.schema != SCHEMA {
bail!(
"catalog schema '{}' did not match expected '{}'",
self.schema,
SCHEMA
);
}
let expected = self.content_sha256()?;
let expected_revision = format!("sha256:{expected}");
if self.revision != expected_revision {
bail!(
"catalog revision '{}' did not match computed '{}'",
self.revision,
expected_revision
);
}
Ok(())
}
fn content_sha256(&self) -> Result<String> {
let mut unsigned = self.clone();
unsigned.revision.clear();
let bytes = serde_json::to_vec(&unsigned).map_err(|error| {
anyhow::anyhow!("failed to serialize catalog for checksum: {error}")
})?;
Ok(to_hex(&Sha256::digest(bytes)))
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Channel {
Stable,
Preview,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Artifact {
pub tarball: String,
pub sha256: String,
}
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Contract {
pub family: String,
pub schema_id: String,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AssetEntry {
pub package: String,
pub version: String,
pub artifacts: BTreeMap<String, Artifact>,
pub channels: BTreeMap<Channel, String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ArtifactEntry {
pub package: String,
pub version: String,
pub api_generation: String,
pub contracts: Vec<Contract>,
pub config_schema: Option<serde_json::Value>,
pub bus_abi: String,
pub artifacts: BTreeMap<String, Artifact>,
pub channels: BTreeMap<Channel, String>,
pub changed_contracts: Vec<String>,
}
pub fn is_schema_id(value: &str) -> bool {
value.len() == 16
&& value
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
}
pub fn is_sha256(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
}
fn to_hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
let _ = write!(out, "{byte:02x}");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn artifact_entry(package: &str) -> ArtifactEntry {
let mut channels = BTreeMap::new();
channels.insert(Channel::Stable, "0.1.0".to_string());
ArtifactEntry {
package: package.to_string(),
version: "0.1.0".to_string(),
api_generation: "y2026_1".to_string(),
contracts: vec![Contract {
family: "drive::Target".to_string(),
schema_id: "0123456789abcdef".to_string(),
}],
config_schema: Some(serde_json::json!({ "type": "object" })),
bus_abi: "phoxal-bus/v0".to_string(),
artifacts: BTreeMap::new(),
channels,
changed_contracts: Vec::new(),
}
}
#[test]
fn finalize_then_verify_round_trips() -> Result<()> {
let manifest = Manifest::new(
Vec::new(),
vec![artifact_entry("phoxal/service-drive")],
Vec::new(),
Vec::new(),
Vec::new(),
)
.finalize()?;
manifest.verify()?;
assert!(manifest.revision.starts_with("sha256:"));
let json = serde_json::to_string_pretty(&manifest)?;
let reparsed: Manifest = serde_json::from_str(&json)?;
assert_eq!(manifest, reparsed);
reparsed.verify()?;
Ok(())
}
#[test]
fn verify_rejects_edited_content() -> Result<()> {
let mut manifest = Manifest::new(
Vec::new(),
vec![artifact_entry("phoxal/service-drive")],
Vec::new(),
Vec::new(),
Vec::new(),
)
.finalize()?;
manifest.services[0].version = "0.2.0".to_string();
let err = manifest.verify().unwrap_err();
assert!(err.to_string().contains("did not match computed"));
Ok(())
}
#[test]
fn verify_rejects_wrong_schema() -> Result<()> {
let mut manifest =
Manifest::new(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()).finalize()?;
manifest.schema = "phoxal-artifacts/999".to_string();
let err = manifest.verify().unwrap_err();
assert!(err.to_string().contains("catalog schema"));
Ok(())
}
#[test]
fn schema_id_and_sha256_format_checks() {
assert!(is_schema_id("0123456789abcdef"));
assert!(!is_schema_id("0123456789ABCDEF"));
assert!(!is_schema_id("too-short"));
assert!(is_sha256(&"a".repeat(64)));
assert!(!is_sha256(&"A".repeat(64)));
}
}