use std::path::{Path, PathBuf};
use serde_yaml::{Mapping, Value};
use super::external_corpora::{is_external_corpora_schema, validate_external_corpora};
use super::parser::parse_contract_str;
use super::validator::validate_contract;
use crate::binding::validate_binding_registry;
use crate::error::{ContractError, Severity, Violation};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtifactKind {
Contract,
Binding,
PublishManifest,
ExternalCorpora,
}
impl std::fmt::Display for ArtifactKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Contract => "contract",
Self::Binding => "binding",
Self::PublishManifest => "publish-manifest",
Self::ExternalCorpora => "external-corpora",
};
write!(f, "{s}")
}
}
#[must_use]
pub fn classify_artifact(yaml: &str) -> ArtifactKind {
let Ok(Value::Mapping(map)) = serde_yaml::from_str::<Value>(yaml) else {
return ArtifactKind::Contract;
};
if map.contains_key("metadata") {
return ArtifactKind::Contract;
}
if map
.get("schema")
.and_then(Value::as_str)
.is_some_and(is_external_corpora_schema)
{
return ArtifactKind::ExternalCorpora;
}
if map.contains_key("bindings") && map.contains_key("target_crate") {
return ArtifactKind::Binding;
}
if map.contains_key("model_id") && map.contains_key("provenance") {
return ArtifactKind::PublishManifest;
}
ArtifactKind::Contract
}
pub fn validate_artifact(path: &Path) -> Result<(ArtifactKind, Vec<Violation>), ContractError> {
let content = std::fs::read_to_string(path)?;
match classify_artifact(&content) {
ArtifactKind::Contract => {
let contract = parse_contract_str(&content)?;
Ok((ArtifactKind::Contract, validate_contract(&contract)))
}
ArtifactKind::Binding => {
let registry = crate::binding::parse_binding_str(&content)?;
Ok((ArtifactKind::Binding, validate_binding_registry(®istry)))
}
ArtifactKind::PublishManifest => {
let manifest: Value = serde_yaml::from_str(&content)?;
Ok((
ArtifactKind::PublishManifest,
validate_publish_manifest(&manifest, path),
))
}
ArtifactKind::ExternalCorpora => Ok((
ArtifactKind::ExternalCorpora,
validate_external_corpora(&content),
)),
}
}
fn violation(rule: &str, message: String, location: &str) -> Violation {
Violation {
severity: Severity::Error,
rule: rule.to_string(),
message,
location: Some(location.to_string()),
}
}
const PUBLISH_MANIFEST_SCHEMA: &str = "publish-manifest-v1.yaml";
fn find_publish_manifest_schema(path: &Path) -> Option<PathBuf> {
let mut dir = path.parent()?;
for _ in 0..3 {
let candidate = dir.join(PUBLISH_MANIFEST_SCHEMA);
if candidate.is_file() {
return Some(candidate);
}
dir = dir.parent()?;
}
None
}
fn schema_required_fields(schema_path: &Path) -> Option<(Vec<String>, Vec<String>)> {
let text = std::fs::read_to_string(schema_path).ok()?;
let doc: Value = serde_yaml::from_str(&text).ok()?;
let schema = doc.get("schema")?;
let read = |key: &str| -> Vec<String> {
schema
.get(key)
.and_then(Value::as_sequence)
.map(|items| {
items
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
};
Some((read("required_fields"), read("provenance_required_fields")))
}
#[must_use]
pub fn validate_publish_manifest(manifest: &Value, path: &Path) -> Vec<Violation> {
let mut violations = Vec::new();
let Some(top) = manifest.as_mapping() else {
violations.push(violation(
"PM-SHAPE-001",
"publish manifest is not a YAML mapping".to_string(),
"",
));
return violations;
};
let Some(schema_path) = find_publish_manifest_schema(path) else {
violations.push(violation(
"PM-SHAPE-000",
format!(
"cannot locate {PUBLISH_MANIFEST_SCHEMA} near {} — the manifest's \
required-field list is declared there, so without it nothing about \
this manifest can be checked",
path.display()
),
"",
));
return violations;
};
let Some((required_top, required_provenance)) = schema_required_fields(&schema_path) else {
violations.push(violation(
"PM-SHAPE-000",
format!(
"{} declares no `schema.required_fields` — the publish-manifest schema \
contract has lost the list this gate reads",
schema_path.display()
),
"",
));
return violations;
};
check_required(top, &required_top, "PM-SHAPE-001", "", &mut violations);
let provenance = top.get("provenance").and_then(Value::as_mapping);
if let Some(provenance) = provenance {
check_required(
provenance,
&required_provenance,
"PM-SHAPE-002",
"provenance.",
&mut violations,
);
}
check_sha256(top, &mut violations);
check_size_bytes(top, &mut violations);
check_artifact_url(top, &mut violations);
violations
}
fn check_required(
map: &Mapping,
required: &[String],
rule: &str,
prefix: &str,
violations: &mut Vec<Violation>,
) {
for field in required {
let why = match map.get(field.as_str()) {
None => "missing",
Some(Value::Null) => "null",
Some(Value::String(s)) if s.trim().is_empty() => "an empty string",
Some(_) => continue,
};
violations.push(violation(
rule,
format!(
"required manifest field `{prefix}{field}` is {why} — \
publish-manifest-v1 RJ-PM-002: a manifest missing a required field \
cannot ship"
),
&format!("{prefix}{field}"),
));
}
}
fn check_sha256(top: &Mapping, violations: &mut Vec<Violation>) {
let Some(Value::String(sha)) = top.get("sha256") else {
return;
};
let ok = sha.len() == 64
&& sha
.chars()
.all(|c| c.is_ascii_digit() || matches!(c, 'a'..='f'));
if !ok {
violations.push(violation(
"PM-SHAPE-003",
format!(
"sha256 {sha:?} is not 64 lowercase hex characters — the published-artifact \
check compares this string byte-for-byte, so any other spelling can never \
match the artifact it names"
),
"sha256",
));
}
}
fn check_size_bytes(top: &Mapping, violations: &mut Vec<Violation>) {
let Some(value) = top.get("size_bytes") else {
return;
};
if value.as_u64().is_some_and(|n| n > 0) {
return;
}
violations.push(violation(
"PM-SHAPE-004",
format!(
"size_bytes must be a positive integer, got {value:?} — it is what the \
content-length check compares against, and a zero or non-integer makes \
a truncated upload undetectable"
),
"size_bytes",
));
}
fn check_artifact_url(top: &Mapping, violations: &mut Vec<Violation>) {
let Some(Value::String(url)) = top.get("artifact_url") else {
return;
};
if url.starts_with("https://") && url.len() > "https://".len() {
return;
}
violations.push(violation(
"PM-SHAPE-005",
format!(
"artifact_url {url:?} is not an https:// URL — publish-manifest-v1 §schema \
requires an HTTPS URL resolving to the binary"
),
"artifact_url",
));
}
#[cfg(test)]
mod tests {
include!("artifact_tests.rs");
}