use std::fmt;
use serde::{Deserialize, Serialize};
use crate::digest::Digest;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Pass,
Fail(String),
Inconclusive(String),
}
impl Verdict {
pub fn is_pass(&self) -> bool {
matches!(self, Self::Pass)
}
pub fn reason(&self) -> Option<&str> {
match self {
Self::Pass => None,
Self::Fail(r) | Self::Inconclusive(r) => Some(r),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct CheckerId {
pub name: String,
pub version: u32,
}
impl CheckerId {
pub fn new(name: impl Into<String>, version: u32) -> Self {
Self {
name: name.into(),
version,
}
}
}
impl fmt::Display for CheckerId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}@{}", self.name, self.version)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SigningKeyId(pub String);
impl fmt::Display for SigningKeyId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Signature(#[serde(with = "sig_bytes")] [u8; 64]);
impl Signature {
pub const fn from_bytes(bytes: [u8; 64]) -> Self {
Self(bytes)
}
pub const fn as_bytes(&self) -> &[u8; 64] {
&self.0
}
}
impl fmt::Debug for Signature {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use fmt::Write as _;
let head = self.0[..6].iter().fold(String::new(), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
});
write!(f, "Signature({head}…)")
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceKind {
Deterministic,
Quorum {
n: u8,
k: u8,
},
Human {
identity: String,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Evidence {
pub verdict: Verdict,
pub checker: CheckerId,
pub inputs: Vec<Digest>,
pub signature: Signature,
pub key: SigningKeyId,
pub kind: EvidenceKind,
}
impl Evidence {
pub fn new(
verdict: Verdict,
checker: CheckerId,
inputs: impl IntoIterator<Item = Digest>,
signature: Signature,
key: SigningKeyId,
kind: EvidenceKind,
) -> Self {
let mut inputs: Vec<Digest> = inputs.into_iter().collect();
inputs.sort_unstable();
inputs.dedup();
Self {
verdict,
checker,
inputs,
signature,
key,
kind,
}
}
pub fn permits_commit(&self) -> bool {
self.verdict.is_pass()
}
pub fn covers(&self, digest: &Digest) -> bool {
self.inputs.binary_search(digest).is_ok()
}
pub fn signing_payload(&self) -> Result<Vec<u8>, crate::CanonicalError> {
crate::to_canonical_cbor(&(
&self.verdict,
&self.checker,
&self.inputs,
&self.key,
&self.kind,
))
}
}
mod sig_bytes {
use serde::de::{Error as _, Unexpected};
use serde::{Deserializer, Serializer};
pub(super) fn serialize<S: Serializer>(bytes: &[u8; 64], s: S) -> Result<S::Ok, S::Error> {
s.serialize_bytes(bytes)
}
pub(super) fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 64], D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = [u8; 64];
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a 64-byte signature")
}
fn visit_bytes<E: serde::de::Error>(self, v: &[u8]) -> Result<[u8; 64], E> {
<[u8; 64]>::try_from(v)
.map_err(|_| E::invalid_value(Unexpected::Bytes(v), &"exactly 64 bytes"))
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut seq: A,
) -> Result<[u8; 64], A::Error> {
let mut out = Vec::with_capacity(64);
while let Some(b) = seq.next_element::<u8>()? {
out.push(b);
}
<[u8; 64]>::try_from(out.as_slice())
.map_err(|_| A::Error::invalid_length(out.len(), &"exactly 64 bytes"))
}
}
d.deserialize_bytes(V)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::canonical::Canonical;
fn evidence(verdict: Verdict) -> Evidence {
Evidence::new(
verdict,
CheckerId::new("cargo-test", 1),
[Digest::of(b"patch")],
Signature::from_bytes([7u8; 64]),
SigningKeyId("host-key-1".into()),
EvidenceKind::Deterministic,
)
}
#[test]
fn carries_verdict_checker_inputs_signature_and_kind() {
let e = evidence(Verdict::Pass);
assert_eq!(e.verdict, Verdict::Pass);
assert_eq!(e.checker, CheckerId::new("cargo-test", 1));
assert_eq!(e.inputs, vec![Digest::of(b"patch")]);
assert_eq!(e.signature, Signature::from_bytes([7u8; 64]));
assert_eq!(e.kind, EvidenceKind::Deterministic);
}
#[test]
fn only_pass_permits_a_commit() {
assert!(evidence(Verdict::Pass).permits_commit());
assert!(!evidence(Verdict::Fail("2 tests failed".into())).permits_commit());
assert!(!evidence(Verdict::Inconclusive("timed out".into())).permits_commit());
}
#[test]
fn inconclusive_is_not_an_implicit_pass() {
let e = evidence(Verdict::Inconclusive("checker crashed".into()));
assert!(!e.permits_commit());
assert_eq!(e.verdict.reason(), Some("checker crashed"));
}
#[test]
fn evidence_cannot_be_repointed_at_other_data() {
let e = evidence(Verdict::Pass);
assert!(e.covers(&Digest::of(b"patch")));
assert!(
!e.covers(&Digest::of(b"a different patch")),
"passing evidence must not vouch for something it never saw"
);
}
#[test]
fn input_order_does_not_change_identity() {
let a = Evidence::new(
Verdict::Pass,
CheckerId::new("c", 1),
[Digest::of(b"x"), Digest::of(b"y")],
Signature::from_bytes([0u8; 64]),
SigningKeyId("k".into()),
EvidenceKind::Deterministic,
);
let b = Evidence::new(
Verdict::Pass,
CheckerId::new("c", 1),
[Digest::of(b"y"), Digest::of(b"x")],
Signature::from_bytes([0u8; 64]),
SigningKeyId("k".into()),
EvidenceKind::Deterministic,
);
assert_eq!(a.digest().unwrap(), b.digest().unwrap());
assert!(a.covers(&Digest::of(b"x")));
assert!(a.covers(&Digest::of(b"y")));
}
#[test]
fn ids_render_for_a_diagnostic() {
assert_eq!(
CheckerId::new("json-schema", 2).to_string(),
"json-schema@2"
);
assert_eq!(SigningKeyId("host-key-1".into()).to_string(), "host-key-1");
}
#[test]
fn kinds_are_distinguishable() {
assert_ne!(
EvidenceKind::Deterministic,
EvidenceKind::Quorum { n: 3, k: 2 }
);
assert_ne!(
EvidenceKind::Quorum { n: 3, k: 2 },
EvidenceKind::Human {
identity: "alex".into()
}
);
}
#[test]
fn signing_payload_excludes_the_signature_but_covers_everything_else() {
let base = evidence(Verdict::Pass);
let mut resigned = base.clone();
resigned.signature = Signature::from_bytes([9u8; 64]);
assert_eq!(
base.signing_payload().unwrap(),
resigned.signing_payload().unwrap()
);
let flipped = evidence(Verdict::Fail("no".into()));
assert_ne!(
base.signing_payload().unwrap(),
flipped.signing_payload().unwrap()
);
let mut rekeyed = base.clone();
rekeyed.key = SigningKeyId("other-key".into());
assert_ne!(
base.signing_payload().unwrap(),
rekeyed.signing_payload().unwrap()
);
let mut recovered = base.clone();
recovered.inputs = vec![Digest::of(b"other")];
assert_ne!(
base.signing_payload().unwrap(),
recovered.signing_payload().unwrap()
);
}
#[test]
fn signature_debug_does_not_dump_all_64_bytes() {
let d = format!("{:?}", Signature::from_bytes([0xab; 64]));
assert!(d.len() < 40, "debug output should be abbreviated: {d}");
}
#[test]
fn round_trips_through_canonical_cbor() {
let e = evidence(Verdict::Fail("2 tests failed".into()));
let bytes = crate::to_canonical_cbor(&e).unwrap();
let back: Evidence = crate::from_canonical_cbor(&bytes).unwrap();
assert_eq!(e, back);
}
}