use ciborium::value::{CanonicalValue, Value};
use sha2::{Digest, Sha256};
use super::portable_spec::PortableMemberSpec;
#[derive(Debug, thiserror::Error)]
pub enum SpecDigestError {
#[error("portable member spec could not be represented as CBOR: {detail}")]
Represent { detail: String },
#[error("portable member spec could not be CBOR-encoded: {detail}")]
Encode { detail: String },
}
fn canonicalize(value: &mut Value) {
match value {
Value::Array(items) => {
for item in items {
canonicalize(item);
}
}
Value::Map(entries) => {
for (key, val) in entries.iter_mut() {
canonicalize(key);
canonicalize(val);
}
entries.sort_by(|(k1, _), (k2, _)| match (k1, k2) {
(Value::Text(a), Value::Text(b)) => match a.len().cmp(&b.len()) {
std::cmp::Ordering::Equal => a.cmp(b),
ord => ord,
},
_ => CanonicalValue::from(k1.clone()).cmp(&CanonicalValue::from(k2.clone())),
});
}
Value::Tag(_, inner) => canonicalize(inner),
_ => {}
}
}
pub fn portable_member_spec_digest(spec: &PortableMemberSpec) -> Result<String, SpecDigestError> {
let mut value = Value::serialized(spec).map_err(|error| SpecDigestError::Represent {
detail: error.to_string(),
})?;
canonicalize(&mut value);
let mut buf = Vec::new();
ciborium::into_writer(&value, &mut buf).map_err(|error| SpecDigestError::Encode {
detail: error.to_string(),
})?;
let digest = Sha256::digest(&buf);
let mut hex = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
let _ = write!(hex, "{byte:02x}");
}
Ok(hex)
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic)]
mod tests {
use super::super::portable_spec::sample_portable_member_spec;
use super::*;
const FIXTURE_DIGEST_PIN: &str =
"54c78bdb185b66b218d38e2f23f08055ceb9db8e6dd79dcb0dc0f7966b089e6b";
#[test]
fn digest_is_deterministic_across_calls() {
let spec = sample_portable_member_spec();
let first = portable_member_spec_digest(&spec).expect("digest");
let second = portable_member_spec_digest(&spec).expect("digest");
assert_eq!(first, second);
}
#[test]
fn digest_is_lowercase_hex_sha256() {
let digest = portable_member_spec_digest(&sample_portable_member_spec()).expect("digest");
assert_eq!(digest.len(), 64, "SHA-256 hex digest must be 64 chars");
assert!(
digest
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"digest must be lowercase hex: {digest}"
);
}
#[test]
fn digest_is_stable_across_serialize_deserialize_round_trip() {
let spec = sample_portable_member_spec();
let original = portable_member_spec_digest(&spec).expect("digest");
let json = serde_json::to_value(&spec).expect("serialize spec");
let rehydrated: super::super::portable_spec::PortableMemberSpec =
serde_json::from_value(json).expect("decode spec");
let redigest = portable_member_spec_digest(&rehydrated).expect("digest");
assert_eq!(
original, redigest,
"a wire round-trip must not change the canonical digest"
);
}
#[test]
fn digest_changes_when_any_field_changes() {
let baseline = portable_member_spec_digest(&sample_portable_member_spec()).expect("digest");
let mut identity_flip = sample_portable_member_spec();
identity_flip.agent_identity = "worker-2".to_string();
assert_ne!(
baseline,
portable_member_spec_digest(&identity_flip).expect("digest"),
"identity flip must change the digest"
);
let mut nested_flip = sample_portable_member_spec();
nested_flip.profile.tools.shell = true;
assert_ne!(
baseline,
portable_member_spec_digest(&nested_flip).expect("digest"),
"nested tool-toggle flip must change the digest"
);
}
#[test]
fn digest_matches_frozen_fixture_pin() {
let digest = portable_member_spec_digest(&sample_portable_member_spec()).expect("digest");
assert_eq!(
digest, FIXTURE_DIGEST_PIN,
"seed FIXTURE_DIGEST_PIN with this verified value on first run: {digest}"
);
}
#[test]
fn canonicalization_known_answer_vector() {
use ciborium::value::Value;
let mut value = Value::Map(vec![
(
Value::Text("bb".into()),
Value::Map(vec![
(Value::Text("z".into()), Value::Integer(1.into())),
(Value::Text("a".into()), Value::Integer(2.into())),
]),
),
(Value::Text("a".into()), Value::Integer(3.into())),
(
Value::Text("ab".into()),
Value::Array(vec![Value::Map(vec![
(Value::Text("longer".into()), Value::Integer(4.into())),
(Value::Text("xy".into()), Value::Integer(5.into())),
])]),
),
]);
super::canonicalize(&mut value);
let mut buf = Vec::new();
ciborium::into_writer(&value, &mut buf).expect("encode");
let expected: &[u8] = &[
0xa3, 0x61, b'a', 0x03, 0x62, b'a', b'b', 0x81, 0xa2, 0x62, b'x', b'y', 0x05, 0x66, b'l', b'o', b'n', b'g', b'e', b'r', 0x04, 0x62, b'b', b'b', 0xa2, 0x61, b'a', 0x02, 0x61, b'z', 0x01, ];
assert_eq!(
buf, expected,
"canonical CBOR byte-domain drifted — fix BOTH canonicalizer twins"
);
}
}