use serde::{Deserialize, Serialize};
use url::Url;
use crate::api_version::ApiVersion;
use crate::envelope::{CardKind, Metadata};
use crate::reference::CardRef;
use crate::registry::{
CardLifecycleStatus, CardUploadEntry, DeclarationOutcomeKind, RelativeArtifactPath,
};
pub fn canonical_artifact_manifest_hash(
artifacts: &[ArtifactManifestEntry],
) -> Result<Option<String>, crate::error::WyrdError> {
if artifacts.is_empty() {
return Ok(None);
}
let mut canonical = artifacts.to_vec();
canonical.sort_by(|left, right| left.relative_path.cmp(&right.relative_path));
let bytes = serde_jcs::to_vec(&canonical).map_err(|error| {
crate::error::WyrdError::from_spec_canonicalization(
crate::envelope::SpecCanonicalizationError::Serialize(error),
)
})?;
Ok(Some(blake3::hash(&bytes).to_hex().to_string()))
}
pub fn validate_artifact_manifest(
artifacts: &[ArtifactManifestEntry],
) -> Result<(), crate::error::WyrdError> {
let conflict = |path: &str, with: &str| crate::error::WyrdError::RegistryInvalidArtifactPath {
message: format!("artifact path {path} conflicts with {with}"),
details: serde_json::json!({ "relative_path": path, "conflicts_with": with }),
};
let mut paths = std::collections::HashSet::with_capacity(artifacts.len());
for artifact in artifacts {
let path = artifact.relative_path.as_str();
if !paths.insert(path) {
return Err(conflict(path, path));
}
}
for path in &paths {
if let Some((end, _)) = path
.match_indices('/')
.find(|(end, _)| paths.contains(&path[..*end]))
{
return Err(conflict(path, &path[..end]));
}
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct CardSubmission {
#[serde(rename = "apiVersion")]
pub api_version: ApiVersion,
pub kind: CardKind,
pub metadata: Metadata,
#[schemars(with = "serde_json::Value")]
#[cfg_attr(feature = "server", schema(value_type = serde_json::Value))]
pub spec: serde_json::Value,
#[serde(default)]
pub artifacts: Vec<ArtifactManifestEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct ArtifactManifestEntry {
pub relative_path: crate::registry::RelativeArtifactPath,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size_bytes: Option<u64>,
pub content_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct DeclareCardRequest {
pub submissions: Vec<CardSubmission>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct CardDeclarationOutcome {
pub card_ref: CardRef,
pub spec_hash: String,
#[serde(default)]
pub artifact_hash: Option<String>,
pub status: CardLifecycleStatus,
pub outcome: DeclarationOutcomeKind,
#[schemars(with = "Option<String>")]
#[cfg_attr(feature = "server", schema(value_type = Option<String>))]
#[serde(default)]
pub card_blob_uri: Option<Url>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct CardUploadPlan {
pub card_ref: CardRef,
pub entries: Vec<CardUploadEntry>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct DeclareCardResponse {
pub root: CardRef,
pub outcomes: Vec<CardDeclarationOutcome>,
pub upload_plans: Vec<CardUploadPlan>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct DeclarationReceipt {
pub root: CardRef,
pub outcomes: Vec<CardDeclarationOutcome>,
}
impl From<DeclareCardResponse> for DeclarationReceipt {
fn from(response: DeclareCardResponse) -> Self {
Self {
root: response.root,
outcomes: response.outcomes,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[cfg_attr(feature = "server", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields, rename_all = "snake_case")]
pub struct DeclarationReplaySeed {
pub root: CardRef,
pub outcomes: Vec<CardDeclarationOutcome>,
pub artifact_manifest_paths: Vec<(CardRef, Vec<RelativeArtifactPath>)>,
}
#[cfg(test)]
mod tests {
use super::{
ArtifactManifestEntry, CardSubmission, DeclarationReceipt, DeclareCardRequest,
DeclareCardResponse, canonical_artifact_manifest_hash, validate_artifact_manifest,
};
use crate::api_version::ApiVersion;
use crate::envelope::{CardKind, Metadata};
use crate::registry::{CardLifecycleStatus, DeclarationOutcomeKind, RelativeArtifactPath};
use serde_json::json;
use std::collections::BTreeMap;
fn artifact(path: &str) -> ArtifactManifestEntry {
ArtifactManifestEntry {
relative_path: RelativeArtifactPath::new(path).expect("test artifact path is valid"),
sha256: Some("YWJj".to_owned()),
size_bytes: Some(3),
content_type: None,
}
}
#[test]
fn canonical_artifact_manifest_hash_ignores_authored_order() {
let first = vec![artifact("weights.bin"), artifact("config.json")];
let second = vec![artifact("config.json"), artifact("weights.bin")];
assert_eq!(
canonical_artifact_manifest_hash(&first).expect("first manifest hashes"),
canonical_artifact_manifest_hash(&second).expect("second manifest hashes"),
);
}
#[test]
fn validate_artifact_manifest_refuses_repeated_and_nested_paths() {
for paths in [
&["a.txt", "a.txt"][..],
&["weights", "weights-v2", "weights/a.bin"],
] {
let manifest: Vec<_> = paths.iter().map(|path| artifact(path)).collect();
let refused =
validate_artifact_manifest(&manifest).expect_err("the manifest conflicts");
assert_eq!(refused.code(), "WYRD_REGISTRY_400_INVALID_ARTIFACT_PATH");
}
let valid = [
artifact("weights-v2"),
artifact("weights/a.bin"),
artifact("weights/b/c.bin"),
];
validate_artifact_manifest(&valid).expect("distinct paths are accepted");
}
fn submission() -> CardSubmission {
CardSubmission {
api_version: ApiVersion::v1(),
kind: CardKind::Audit,
metadata: Metadata {
name: "audit-a".parse().expect("test name is valid"),
version: None,
bump: None,
space: Some("default".parse().expect("test space is valid")),
uid: None,
labels: BTreeMap::default(),
annotations: BTreeMap::default(),
spec_hash: None,
artifact_hash: None,
origin: None,
},
spec: json!({}),
artifacts: Vec::new(),
}
}
#[test]
fn create_card_request_is_flat_submissions_list() {
let request: DeclareCardRequest = serde_json::from_value(json!({
"submissions": [{
"apiVersion": "wyrd/v1",
"kind": "Audit",
"metadata": {"name": "audit-a", "space": "default"},
"spec": {}
}]
}))
.expect("flat request deserializes");
assert_eq!(request.submissions.len(), 1);
assert!(
serde_json::from_value::<DeclareCardRequest>(json!({
"card": {}, "artifacts": []
}))
.is_err()
);
}
#[test]
fn card_submission_artifacts_default_is_empty() {
let encoded = serde_json::to_value(submission()).expect("submission serializes");
assert!(encoded["artifacts"].as_array().is_some());
assert!(encoded["artifacts"].as_array().expect("array").is_empty());
}
#[test]
fn card_submission_server_managed_fields_are_not_top_level_fields() {
let mut encoded = serde_json::to_value(submission()).expect("submission serializes");
encoded["relationships"] = json!([]);
encoded["status"] = json!("active");
assert!(serde_json::from_value::<CardSubmission>(encoded).is_err());
}
#[test]
fn create_card_response_carries_root_outcomes_and_upload_plans() {
let response = DeclareCardResponse {
root: serde_json::from_value(json!({
"kind": "Audit", "name": "audit-a", "space": "default", "version": "1.0.0"
}))
.expect("card ref deserializes"),
outcomes: Vec::new(),
upload_plans: Vec::new(),
};
assert!(response.outcomes.is_empty());
assert!(response.upload_plans.is_empty());
let _ = (
CardLifecycleStatus::Pending,
DeclarationOutcomeKind::Declared,
);
}
#[test]
fn registration_receipt_excludes_internal_upload_plans() {
let response = DeclareCardResponse {
root: serde_json::from_value(json!({
"kind": "Audit", "name": "audit-a", "space": "default", "version": "1.0.0"
}))
.expect("card ref deserializes"),
outcomes: Vec::new(),
upload_plans: Vec::new(),
};
let receipt = DeclarationReceipt::from(response);
let value = serde_json::to_value(receipt).expect("receipt serializes");
assert!(value.get("upload_plans").is_none());
assert!(value.get("operation_id").is_none());
}
}