use std::{fmt, marker::PhantomData};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Blake3Hash(pub String);
impl Blake3Hash {
#[must_use]
pub fn content_address(bytes: &[u8]) -> Self {
Blake3Hash(blake3::hash(bytes).to_hex().to_string())
}
pub fn from_hex(hex: impl Into<String>) -> Self {
Blake3Hash(hex.into())
}
#[must_use]
pub fn as_hex(&self) -> &str {
&self.0
}
}
impl fmt::Display for Blake3Hash {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for Blake3Hash {
fn from(s: String) -> Self {
Blake3Hash(s)
}
}
impl From<Blake3Hash> for String {
fn from(h: Blake3Hash) -> Self {
h.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ObjectRef {
pub id: String,
#[serde(rename = "type")]
pub type_: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub qualifier: Option<String>,
}
impl fmt::Display for ObjectRef {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.qualifier {
Some(q) => write!(f, "{}:{}:{}", self.id, self.type_, q),
None => write!(f, "{}:{}", self.id, self.type_),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid object ref '{input}': {reason}")]
pub struct ObjectRefParseError {
pub input: String,
pub reason: &'static str,
}
impl ObjectRef {
pub fn parse(s: &str) -> Result<Self, ObjectRefParseError> {
let parts: Vec<&str> = s.splitn(3, ':').collect();
match parts.as_slice() {
[id, type_] if !id.is_empty() && !type_.is_empty() => Ok(ObjectRef {
id: (*id).to_string(),
type_: (*type_).to_string(),
qualifier: None,
}),
[id, type_, qualifier]
if !id.is_empty() && !type_.is_empty() && !qualifier.is_empty() =>
{
Ok(ObjectRef {
id: (*id).to_string(),
type_: (*type_).to_string(),
qualifier: Some((*qualifier).to_string()),
})
}
_ => Err(ObjectRefParseError {
input: s.to_string(),
reason: "expected 'id:type' or 'id:type:qualifier' with non-empty segments",
}),
}
}
}
impl std::str::FromStr for ObjectRef {
type Err = ObjectRefParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
ObjectRef::parse(s)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod object_ref_parse_tests {
use super::*;
#[test]
fn parse_id_type() {
let obj: ObjectRef = "suite:test-suite".parse().unwrap();
assert_eq!(obj.id, "suite");
assert_eq!(obj.type_, "test-suite");
assert!(obj.qualifier.is_none());
}
#[test]
fn parse_id_type_qualifier() {
let obj = ObjectRef::parse("artifact-1:artifact:input").unwrap();
assert_eq!(obj.qualifier.as_deref(), Some("input"));
}
#[test]
fn display_parse_round_trip() {
let original = ObjectRef {
id: "repo".to_string(),
type_: "git".to_string(),
qualifier: Some("output".to_string()),
};
let s = original.to_string();
let parsed = ObjectRef::parse(&s).unwrap();
assert_eq!(original, parsed);
}
#[test]
fn parse_rejects_missing_type() {
assert!(ObjectRef::parse("onlyone").is_err());
assert!(ObjectRef::parse("").is_err());
}
#[test]
fn parse_rejects_empty_segments() {
assert!(ObjectRef::parse(":type").is_err());
assert!(ObjectRef::parse("id:").is_err());
}
}
pub fn canonical_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, serde_json::Error> {
let v = serde_json::to_value(value)?;
let sorted = sort_value(v);
serde_json::to_vec(&sorted)
}
fn sort_value(value: serde_json::Value) -> serde_json::Value {
use serde_json::Value;
match value {
Value::Object(map) => {
let sorted: std::collections::BTreeMap<String, Value> =
map.into_iter().map(|(k, v)| (k, sort_value(v))).collect();
let mut out = serde_json::Map::new();
for (k, v) in sorted {
out.insert(k, v);
}
Value::Object(out)
}
Value::Array(arr) => Value::Array(arr.into_iter().map(sort_value).collect()),
other => other,
}
}
mod sealed {
pub trait LifecycleState {}
}
pub struct Raw;
impl sealed::LifecycleState for Raw {}
pub struct Validated;
impl sealed::LifecycleState for Validated {}
pub struct Admitted;
impl sealed::LifecycleState for Admitted {}
pub struct Evidence<T, State: sealed::LifecycleState, Witness> {
inner: T,
_state: PhantomData<State>,
_witness: PhantomData<Witness>,
}
impl<T, Witness> Evidence<T, Raw, Witness> {
pub fn raw(inner: T) -> Self {
Self {
inner,
_state: PhantomData,
_witness: PhantomData,
}
}
pub fn new(inner: T) -> Self {
Self {
inner,
_state: PhantomData,
_witness: PhantomData,
}
}
pub fn inner(&self) -> &T {
&self.inner
}
}
impl<T, Witness> Evidence<T, Validated, Witness> {
pub fn inner(&self) -> &T {
&self.inner
}
#[allow(dead_code)]
pub(crate) fn validate_unchecked(inner: T) -> Self {
Self {
inner,
_state: PhantomData,
_witness: PhantomData,
}
}
}
impl<T, Witness> Evidence<T, Admitted, Witness> {
pub fn inner(&self) -> &T {
&self.inner
}
pub(crate) fn admit_unchecked(inner: T) -> Self {
Self {
inner,
_state: PhantomData,
_witness: PhantomData,
}
}
}
pub trait Admit {
type Input;
type Witness;
type Error;
fn admit(
input: Evidence<Self::Input, Raw, Self::Witness>,
) -> Result<Evidence<Self::Input, Admitted, Self::Witness>, Self::Error>;
}
pub type RawEvidence<T, W> = Evidence<T, Raw, W>;
pub type ValidatedEvidence<T, W> = Evidence<T, Validated, W>;
pub type AdmittedEvidence<T, W> = Evidence<T, Admitted, W>;
#[allow(clippy::manual_non_exhaustive)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AdmittedReceipt {
pub chain_hash: [u8; 32],
pub timestamp: u64,
#[serde(skip)]
_seal: (), }
impl AdmittedReceipt {
#[allow(dead_code)]
pub(crate) fn new(chain_hash: [u8; 32], timestamp: u64) -> Self {
Self {
chain_hash,
timestamp,
_seal: (),
}
}
}
#[non_exhaustive]
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ProfileId {
CoreV1,
}
impl ProfileId {
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
ProfileId::CoreV1 => "core/v1",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PolicyVerdict {
Pass,
Warn,
Suggest,
}
#[derive(Debug, Clone)]
pub struct PolicyConfig {
pub behind_threshold: usize,
pub dirty_threshold: usize,
pub evidence_staleness_secs: u64,
}
impl Default for PolicyConfig {
fn default() -> Self {
Self {
behind_threshold: 3,
dirty_threshold: 5,
evidence_staleness_secs: 3_600,
}
}
}
pub trait CicdPolicy {
fn name(&self) -> &'static str;
fn evaluate(&self, config: &PolicyConfig) -> PolicyVerdict;
}
#[allow(dead_code)]
mod layout_assertions {
use std::mem::{align_of, size_of};
use super::{Admitted, Blake3Hash, Evidence, PolicyVerdict, ProfileId, Raw, Validated};
const _BLAKE3_HASH_SIZE: () = {
assert!(
size_of::<Blake3Hash>() == 24,
"Blake3Hash size changed — check wire compatibility"
);
};
const _PROFILE_ID_SIZE: () = {
assert!(
size_of::<ProfileId>() == 1,
"ProfileId size changed — may break OCEL serialization"
);
};
const _POLICY_VERDICT_SIZE: () = {
assert!(
size_of::<PolicyVerdict>() == 1,
"PolicyVerdict size changed"
);
};
const _RAW_IS_ZST: () = {
assert!(size_of::<Raw>() == 0, "Raw marker must stay ZST");
};
const _VALIDATED_IS_ZST: () = {
assert!(
size_of::<Validated>() == 0,
"Validated marker must stay ZST"
);
};
const _ADMITTED_IS_ZST: () = {
assert!(size_of::<Admitted>() == 0, "Admitted marker must stay ZST");
};
const _EVIDENCE_NO_OVERHEAD: () = {
assert!(
size_of::<Evidence<u64, Raw, Raw>>() == size_of::<u64>(),
"Evidence<T,S,W> must be zero-overhead over T when S and W are ZSTs"
);
};
const _BLAKE3_HASH_ALIGN: () = {
assert!(
align_of::<Blake3Hash>() == align_of::<String>(),
"Blake3Hash alignment changed"
);
};
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StageOutcome {
pub stage: String,
pub passed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl StageOutcome {
pub fn pass(stage: impl Into<String>) -> Self {
StageOutcome {
stage: stage.into(),
passed: true,
reason: None,
}
}
pub fn fail(stage: impl Into<String>, reason: impl Into<String>) -> Self {
StageOutcome {
stage: stage.into(),
passed: false,
reason: Some(reason.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Verdict {
pub accepted: bool,
pub stage_outcomes: Vec<StageOutcome>,
}
impl Verdict {
#[must_use]
pub fn accept(stages: Vec<StageOutcome>) -> Self {
Verdict {
accepted: true,
stage_outcomes: stages,
}
}
#[must_use]
pub fn reject(stages: Vec<StageOutcome>) -> Self {
Verdict {
accepted: false,
stage_outcomes: stages,
}
}
#[must_use]
pub fn first_failure(&self) -> Option<&StageOutcome> {
self.stage_outcomes.iter().find(|s| !s.passed)
}
#[must_use]
pub fn summary(&self) -> String {
if self.accepted {
let n = self.stage_outcomes.len();
format!("ACCEPT ({n}/{n} stages passed)")
} else {
match self.first_failure() {
Some(s) => {
let reason = s.reason.as_deref().unwrap_or("no reason recorded");
format!("REJECT at stage {}: {reason}", s.stage)
}
None => "REJECT (no stage failure recorded)".to_string(),
}
}
}
#[must_use]
pub fn is_accepted(&self) -> bool {
self.accepted
}
}
impl fmt::Display for Verdict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.summary())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod verdict_tests {
use super::*;
#[test]
fn accept_verdict_summary() {
let v = Verdict::accept(vec![
StageOutcome::pass("decode"),
StageOutcome::pass("chain_integrity"),
]);
assert!(v.is_accepted());
assert_eq!(v.summary(), "ACCEPT (2/2 stages passed)");
assert!(v.first_failure().is_none());
}
#[test]
fn reject_verdict_first_failure() {
let v = Verdict::reject(vec![
StageOutcome::pass("decode"),
StageOutcome::fail("chain_integrity", "chain hash mismatch"),
StageOutcome::fail("continuity", "seq gap at 3"),
]);
assert!(!v.is_accepted());
let f = v.first_failure().unwrap();
assert_eq!(f.stage, "chain_integrity");
assert_eq!(
v.summary(),
"REJECT at stage chain_integrity: chain hash mismatch"
);
}
#[test]
fn verdict_display_matches_summary() {
let v = Verdict::accept(vec![StageOutcome::pass("decode")]);
assert_eq!(format!("{v}"), v.summary());
}
}
impl PolicyConfig {
#[must_use]
pub fn with_behind_threshold(mut self, n: usize) -> Self {
self.behind_threshold = n;
self
}
#[must_use]
pub fn with_dirty_threshold(mut self, n: usize) -> Self {
self.dirty_threshold = n;
self
}
#[must_use]
pub fn with_evidence_staleness_secs(mut self, secs: u64) -> Self {
self.evidence_staleness_secs = secs;
self
}
}
impl PolicyVerdict {
#[must_use]
pub fn worse(self, other: PolicyVerdict) -> PolicyVerdict {
use PolicyVerdict::{Pass, Suggest, Warn};
match (self, other) {
(Warn, _) | (_, Warn) => Warn,
(Suggest, _) | (_, Suggest) => Suggest,
_ => Pass,
}
}
#[must_use]
pub fn at_least(&self, threshold: &PolicyVerdict) -> bool {
use PolicyVerdict::{Pass, Suggest, Warn};
matches!(
(self, threshold),
(Warn, Warn | Suggest | Pass) | (Suggest, Suggest | Pass) | (Pass, Pass)
)
}
}
pub struct CicdPolicyRunner {
policies: Vec<Box<dyn CicdPolicy>>,
}
#[derive(Debug, Clone)]
pub struct PolicyFinding {
pub policy_name: &'static str,
pub verdict: PolicyVerdict,
}
impl CicdPolicyRunner {
#[must_use]
pub fn new(policies: Vec<Box<dyn CicdPolicy>>) -> Self {
CicdPolicyRunner { policies }
}
#[must_use]
pub fn run(&self, config: &PolicyConfig) -> (PolicyVerdict, Vec<PolicyFinding>) {
let mut aggregate = PolicyVerdict::Pass;
let mut findings = Vec::with_capacity(self.policies.len());
for policy in &self.policies {
let v = policy.evaluate(config);
aggregate = aggregate.worse(v.clone());
findings.push(PolicyFinding {
policy_name: policy.name(),
verdict: v,
});
}
(aggregate, findings)
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod policy_runner_tests {
use super::*;
struct Fixed(PolicyVerdict);
impl CicdPolicy for Fixed {
fn name(&self) -> &'static str {
"fixed"
}
fn evaluate(&self, _: &PolicyConfig) -> PolicyVerdict {
self.0.clone()
}
}
#[test]
fn runner_returns_worst_verdict() {
let runner = CicdPolicyRunner::new(vec![
Box::new(Fixed(PolicyVerdict::Pass)),
Box::new(Fixed(PolicyVerdict::Suggest)),
Box::new(Fixed(PolicyVerdict::Pass)),
]);
let (v, findings) = runner.run(&PolicyConfig::default());
assert_eq!(v, PolicyVerdict::Suggest);
assert_eq!(findings.len(), 3);
}
#[test]
fn policy_config_builder_chain() {
let cfg = PolicyConfig::default()
.with_behind_threshold(10)
.with_dirty_threshold(2)
.with_evidence_staleness_secs(7200);
assert_eq!(cfg.behind_threshold, 10);
assert_eq!(cfg.dirty_threshold, 2);
assert_eq!(cfg.evidence_staleness_secs, 7200);
}
#[test]
fn verdict_at_least() {
assert!(PolicyVerdict::Warn.at_least(&PolicyVerdict::Pass));
assert!(PolicyVerdict::Warn.at_least(&PolicyVerdict::Warn));
assert!(!PolicyVerdict::Pass.at_least(&PolicyVerdict::Warn));
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn blake3_hash_round_trips_through_hex() {
let h = Blake3Hash::content_address(b"affidavit");
assert_eq!(h.as_hex().len(), 64);
let rt = Blake3Hash::from_hex(h.as_hex());
assert_eq!(h, rt);
}
#[test]
fn canonical_bytes_sorts_keys() {
use serde::Serialize;
#[derive(Serialize)]
struct Payload {
z: i32,
a: i32,
m: i32,
}
let bytes = canonical_bytes(&Payload { z: 3, a: 1, m: 2 }).unwrap();
let s = std::str::from_utf8(&bytes).unwrap();
let a_pos = s.find("\"a\"").unwrap();
let m_pos = s.find("\"m\"").unwrap();
let z_pos = s.find("\"z\"").unwrap();
assert!(a_pos < m_pos && m_pos < z_pos, "keys not sorted: {s}");
}
#[test]
fn canonical_bytes_same_value_same_bytes() {
use serde::Serialize;
#[derive(Serialize)]
struct Inner {
b: &'static str,
a: &'static str,
}
let b1 = canonical_bytes(&Inner { b: "two", a: "one" }).unwrap();
let b2 = canonical_bytes(&Inner { b: "two", a: "one" }).unwrap();
assert_eq!(b1, b2);
}
#[test]
fn object_ref_display_with_qualifier() {
let obj = ObjectRef {
id: "artifact-1".to_string(),
type_: "artifact".to_string(),
qualifier: Some("input".to_string()),
};
assert_eq!(obj.to_string(), "artifact-1:artifact:input");
}
#[test]
fn object_ref_display_without_qualifier() {
let obj = ObjectRef {
id: "repo:main".to_string(),
type_: "git".to_string(),
qualifier: None,
};
assert_eq!(obj.to_string(), "repo:main:git");
}
#[test]
fn object_ref_qualifier_skipped_in_json() {
let obj = ObjectRef {
id: "x".to_string(),
type_: "t".to_string(),
qualifier: None,
};
let json = serde_json::to_string(&obj).unwrap();
assert!(
!json.contains("qualifier"),
"None qualifier should be omitted: {json}"
);
}
#[test]
fn profile_id_as_str() {
assert_eq!(ProfileId::CoreV1.as_str(), "core/v1");
}
#[test]
fn policy_config_defaults() {
let cfg = PolicyConfig::default();
assert_eq!(cfg.behind_threshold, 3);
assert_eq!(cfg.dirty_threshold, 5);
assert_eq!(cfg.evidence_staleness_secs, 3_600);
}
#[test]
fn policy_verdict_equality() {
assert_eq!(PolicyVerdict::Pass, PolicyVerdict::Pass);
assert_ne!(PolicyVerdict::Pass, PolicyVerdict::Warn);
assert_ne!(PolicyVerdict::Warn, PolicyVerdict::Suggest);
}
#[test]
fn cicd_policy_trait_dispatch() {
struct AlwaysWarn;
impl CicdPolicy for AlwaysWarn {
fn name(&self) -> &'static str {
"always-warn"
}
fn evaluate(&self, _config: &PolicyConfig) -> PolicyVerdict {
PolicyVerdict::Warn
}
}
let policy = AlwaysWarn;
assert_eq!(policy.name(), "always-warn");
assert_eq!(
policy.evaluate(&PolicyConfig::default()),
PolicyVerdict::Warn
);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod canonical_determinism_tests {
use super::*;
#[test]
fn canonical_bytes_is_deterministic_across_100_calls() {
use serde::Serialize;
#[derive(Serialize)]
struct Payload {
z: &'static str,
a: &'static str,
m: u64,
}
let p = Payload {
z: "last",
a: "first",
m: 42,
};
let first = canonical_bytes(&p).unwrap();
for _ in 0..99 {
assert_eq!(
canonical_bytes(&p).unwrap(),
first,
"canonical_bytes must be stable across repeated calls"
);
}
}
#[test]
fn blake3_of_canonical_bytes_is_stable() {
use serde::Serialize;
#[derive(Serialize)]
struct Event {
seq: u64,
event_type: &'static str,
}
let e = Event {
seq: 0,
event_type: "build",
};
let bytes = canonical_bytes(&e).unwrap();
let h1 = Blake3Hash::content_address(&bytes);
let h2 = Blake3Hash::content_address(&bytes);
assert_eq!(h1, h2);
assert_eq!(h1.as_hex().len(), 64);
}
#[test]
fn field_order_does_not_affect_canonical_hash() {
use serde::Serialize;
#[derive(Serialize)]
struct OrderA {
a: i32,
b: i32,
c: i32,
}
#[derive(Serialize)]
struct OrderB {
c: i32,
a: i32,
b: i32,
}
let a = canonical_bytes(&OrderA { a: 1, b: 2, c: 3 }).unwrap();
let b = canonical_bytes(&OrderB { c: 3, a: 1, b: 2 }).unwrap();
assert_eq!(a, b, "canonical_bytes must normalize field order");
assert_eq!(
Blake3Hash::content_address(&a),
Blake3Hash::content_address(&b),
"canonical hashes must match regardless of struct field order"
);
}
#[test]
fn tampered_field_changes_hash() {
use serde::Serialize;
#[derive(Serialize)]
struct Event {
seq: u64,
payload: &'static str,
}
let honest = canonical_bytes(&Event {
seq: 1,
payload: "build",
})
.unwrap();
let tampered = canonical_bytes(&Event {
seq: 1,
payload: "build-TAMPERED",
})
.unwrap();
assert_ne!(
Blake3Hash::content_address(&honest),
Blake3Hash::content_address(&tampered),
"tampered payload must produce a different hash"
);
}
}
pub struct HashWitness;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum InvalidHash {
#[error("hash must be exactly 64 hex characters, got {0}")]
WrongLength(usize),
#[error("hash contains non-hex character at position {0}: '{1}'")]
NonHexChar(usize, char),
}
pub struct HashAdmit;
impl Admit for HashAdmit {
type Input = Blake3Hash;
type Witness = HashWitness;
type Error = InvalidHash;
fn admit(
input: Evidence<Blake3Hash, Raw, HashWitness>,
) -> Result<Evidence<Blake3Hash, Admitted, HashWitness>, InvalidHash> {
let hex = input.inner().as_hex();
if hex.len() != 64 {
return Err(InvalidHash::WrongLength(hex.len()));
}
for (i, ch) in hex.chars().enumerate() {
if !ch.is_ascii_hexdigit() {
return Err(InvalidHash::NonHexChar(i, ch));
}
}
Ok(Evidence::admit_unchecked(input.inner().clone()))
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod hash_admit_tests {
use super::*;
#[test]
fn admit_valid_hash() {
let hash = Blake3Hash::content_address(b"hello");
let raw: RawEvidence<Blake3Hash, HashWitness> = Evidence::raw(hash);
assert!(HashAdmit::admit(raw).is_ok());
}
#[test]
fn reject_wrong_length() {
let short = Blake3Hash::from_hex("abc");
let raw: RawEvidence<Blake3Hash, HashWitness> = Evidence::raw(short);
assert!(matches!(
HashAdmit::admit(raw),
Err(InvalidHash::WrongLength(3))
));
}
#[test]
fn reject_non_hex_char() {
let bad = Blake3Hash::from_hex(
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
);
let raw: RawEvidence<Blake3Hash, HashWitness> = Evidence::raw(bad);
assert!(matches!(
HashAdmit::admit(raw),
Err(InvalidHash::NonHexChar(0, 'z'))
));
}
}