use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Artifact {
pub path: String,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EvidenceFile {
pub version: u32,
pub review: Artifact,
pub dispositions: Vec<Disposition>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Disposition {
pub finding_id: String,
pub status: Status,
pub reason: String,
pub evidence: Vec<Evidence>,
pub delivery: Vec<Delivery>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Status {
Unverified,
Confirmed,
Rejected,
Known,
Inconclusive,
}
impl Status {
pub fn as_str(self) -> &'static str {
match self {
Self::Unverified => "unverified",
Self::Confirmed => "confirmed",
Self::Rejected => "rejected",
Self::Known => "known",
Self::Inconclusive => "inconclusive",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum EvidenceKind {
Execution,
SourceTrace,
Counterexample,
Acceptance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Outcome {
ProductFailure,
ExpectedBehaviour,
SetupFailure,
ObservationError,
Inconclusive,
AcceptedRisk,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Consumer {
pub name: String,
pub version: String,
pub source: String,
pub lockfile: Artifact,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Evidence {
pub kind: EvidenceKind,
pub outcome: Outcome,
pub captured_at: String,
pub repository: String,
pub revision: String,
pub configuration: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
pub artifact: Artifact,
pub observed: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub positive_control: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authority: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub working_tree: Option<Artifact>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub consumer: Option<Consumer>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Stage {
Source,
Tested,
Merged,
Published,
Adopted,
Deployed,
}
impl Stage {
pub fn as_str(self) -> &'static str {
match self {
Self::Source => "source",
Self::Tested => "tested",
Self::Merged => "merged",
Self::Published => "published",
Self::Adopted => "adopted",
Self::Deployed => "deployed",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Delivery {
pub stage: Stage,
pub target: String,
pub evidence: Vec<Evidence>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Report {
pub findings_total: usize,
pub counts: BTreeMap<String, usize>,
pub unresolved: usize,
pub delivery_counts: BTreeMap<String, usize>,
pub errors: Vec<String>,
}
impl Report {
pub fn valid(&self) -> bool {
self.errors.is_empty()
}
}
pub fn findings(bytes: &[u8]) -> Result<BTreeSet<String>, String> {
let rows: Vec<serde_json::Value> = serde_json::from_slice(bytes).map_err(|e| e.to_string())?;
let mut ids = BTreeSet::new();
for (index, row) in rows.iter().enumerate() {
let id = row
.get("id")
.and_then(|v| v.as_str())
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| format!("finding {index}: non-empty string id required"))?;
if !ids.insert(id.to_owned()) {
return Err(format!("duplicate finding id `{id}`"));
}
}
Ok(ids)
}
fn hex(value: &str, lengths: &[usize]) -> bool {
lengths.contains(&value.len()) && value.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
}
fn nonempty(value: &str, name: &str, errors: &mut Vec<String>) {
if value.trim().is_empty() {
errors.push(format!("{name}: must not be empty"));
}
}
pub fn validate_artifact(a: &Artifact) -> Result<(), String> {
if a.path.is_empty()
|| a.path.contains(['\\', ':', '\0'])
|| a.path.split('/').any(|p| p.is_empty() || p == "." || p == "..")
{
return Err(format!("artefact path `{}` must be a portable relative file path", a.path));
}
if !hex(&a.sha256, &[64]) {
return Err(format!("{}: expected lower-case SHA-256", a.path));
}
Ok(())
}
fn check_evidence(e: &Evidence, errors: &mut Vec<String>) {
for (name, value) in [
("captured_at", &e.captured_at),
("repository", &e.repository),
("configuration", &e.configuration),
("observed", &e.observed),
] {
nonempty(value, name, errors);
}
if !hex(&e.revision, &[40, 64]) {
errors.push("revision: full lower-case Git commit required".into());
}
if e.kind == EvidenceKind::Execution && e.command.as_deref().unwrap_or("").trim().is_empty() {
errors.push("execution evidence requires the executed command".into());
}
if e.kind == EvidenceKind::Execution
&& e.outcome == Outcome::ProductFailure
&& e.positive_control.as_deref().unwrap_or("").trim().is_empty()
{
errors.push("executable defect reproduction requires a positive control".into());
}
if e.kind == EvidenceKind::Acceptance
&& (e.outcome != Outcome::AcceptedRisk || e.authority.as_deref().unwrap_or("").trim().is_empty())
{
errors.push("acceptance requires accepted-risk outcome and decision authority".into());
}
if let Some(c) = &e.consumer {
for (name, value) in
[("consumer.name", &c.name), ("consumer.version", &c.version), ("consumer.source", &c.source)]
{
nonempty(value, name, errors);
}
}
}
pub fn artifacts(file: &EvidenceFile) -> Vec<&Artifact> {
let mut out = vec![&file.review];
for disposition in &file.dispositions {
for e in disposition.evidence.iter().chain(disposition.delivery.iter().flat_map(|d| &d.evidence)) {
out.push(&e.artifact);
if let Some(snapshot) = &e.working_tree {
out.push(snapshot);
}
if let Some(consumer) = &e.consumer {
out.push(&consumer.lockfile);
}
}
}
out
}
pub fn validate(file: &EvidenceFile, ids: &BTreeSet<String>) -> Report {
let mut report = Report {
findings_total: ids.len(),
counts: BTreeMap::from([
("unverified".into(), ids.len()),
("confirmed".into(), 0),
("rejected".into(), 0),
("known".into(), 0),
("inconclusive".into(), 0),
]),
unresolved: ids.len(),
delivery_counts: ["source", "tested", "merged", "published", "adopted", "deployed"]
.into_iter()
.map(|s| (s.into(), 0))
.collect(),
errors: Vec::new(),
};
if file.version != 1 {
report.errors.push(format!("unsupported evidence version {} (expected 1)", file.version));
}
for artifact in artifacts(file) {
if let Err(error) = validate_artifact(artifact) {
report.errors.push(error);
}
}
let mut seen = BTreeSet::new();
for d in &file.dispositions {
let start = report.errors.len();
if !ids.contains(&d.finding_id) {
report.errors.push("finding absent from original review".into());
} else if !seen.insert(&d.finding_id) {
report.errors.push("duplicate disposition".into());
} else {
*report.counts.get_mut("unverified").expect("initialised") -= 1;
*report.counts.get_mut(d.status.as_str()).expect("all statuses initialised") += 1;
}
if d.status != Status::Unverified {
nonempty(&d.reason, "reason", &mut report.errors);
}
for e in &d.evidence {
check_evidence(e, &mut report.errors);
}
let supported = match d.status {
Status::Unverified | Status::Inconclusive => true,
Status::Confirmed => d.evidence.iter().any(|e| {
matches!(e.kind, EvidenceKind::Execution | EvidenceKind::SourceTrace)
&& e.outcome == Outcome::ProductFailure
}),
Status::Rejected => d
.evidence
.iter()
.any(|e| e.kind == EvidenceKind::Counterexample && e.outcome == Outcome::ExpectedBehaviour),
Status::Known => {
d.evidence.iter().any(|e| e.kind == EvidenceKind::Acceptance && e.outcome == Outcome::AcceptedRisk)
}
};
if !supported {
report.errors.push(format!("{} disposition lacks supporting evidence", d.status.as_str()));
}
let mut targets = BTreeSet::new();
for delivery in &d.delivery {
if d.status != Status::Confirmed {
report.errors.push("repair delivery requires a confirmed finding".into());
}
nonempty(&delivery.target, "delivery.target", &mut report.errors);
if !targets.insert((delivery.stage, &delivery.target)) {
report.errors.push("duplicate stage/target attestation".into());
}
let runtime = matches!(delivery.stage, Stage::Tested | Stage::Deployed);
if !delivery.evidence.iter().any(|e| {
e.outcome == Outcome::ExpectedBehaviour
&& if runtime {
e.kind == EvidenceKind::Execution
} else {
matches!(e.kind, EvidenceKind::Execution | EvidenceKind::SourceTrace)
}
}) {
report.errors.push(format!(
"{} requires its own successful {}evidence",
delivery.stage.as_str(),
if runtime { "execution " } else { "" }
));
}
if delivery.stage == Stage::Adopted
&& !delivery.evidence.iter().any(|e| {
e.consumer.is_some()
&& e.outcome == Outcome::ExpectedBehaviour
&& matches!(e.kind, EvidenceKind::Execution | EvidenceKind::SourceTrace)
})
{
report
.errors
.push("adopted requires successful evidence of the consumed version and lock artefact".into());
}
for e in &delivery.evidence {
check_evidence(e, &mut report.errors);
}
*report.delivery_counts.get_mut(delivery.stage.as_str()).expect("all stages initialised") += 1;
}
for error in &mut report.errors[start..] {
*error = format!("{}: {error}", d.finding_id);
}
}
report.unresolved = report.counts["unverified"] + report.counts["inconclusive"];
report
}