Skip to main content

mur_common/skill/
hash.rs

1use crate::skill::manifest::SkillManifest;
2use crate::skill::serialize_canonical;
3use sha2::{Digest, Sha256};
4use subtle::ConstantTimeEq;
5
6/// Compute content hash for trust verification — excludes `transfer_chain`
7/// and `evolution_log` so registry lookup and trust-store keys remain stable
8/// across transfers and across generation increments.
9pub fn content_hash_for_trust(m: &SkillManifest) -> Result<String, crate::skill::ParseError> {
10    let mut clone = m.clone();
11    clone.transfer_chain = vec![];
12    clone.evolution_log = vec![];
13    content_sha256(&clone)
14}
15
16/// Hex-encoded SHA-256 of the canonical YAML form. Lowercase.
17pub fn content_sha256(m: &SkillManifest) -> Result<String, crate::skill::ParseError> {
18    let yaml = serialize_canonical(m)?;
19    Ok(sha256_hex(yaml.as_bytes()))
20}
21
22pub fn sha256_hex(bytes: &[u8]) -> String {
23    let hash = Sha256::digest(bytes);
24    let mut s = String::with_capacity(64);
25    for b in hash {
26        s.push_str(&format!("{:02x}", b));
27    }
28    s
29}
30
31/// Constant-time hex-string comparison. Used by drift detection.
32pub fn ct_eq_hex(a: &str, b: &str) -> bool {
33    if a.len() != b.len() {
34        return false;
35    }
36    a.as_bytes().ct_eq(b.as_bytes()).into()
37}
38
39#[derive(Debug, PartialEq, Eq)]
40pub enum DriftStatus {
41    Pinned,
42    Drift { expected: String, actual: String },
43    Unpinned,
44}
45
46pub fn drift_status(
47    m: &SkillManifest,
48    expected: Option<&str>,
49) -> Result<DriftStatus, crate::skill::ParseError> {
50    let actual = content_sha256(m)?;
51    Ok(match expected {
52        None => DriftStatus::Unpinned,
53        Some(exp) if ct_eq_hex(exp, &actual) => DriftStatus::Pinned,
54        Some(exp) => DriftStatus::Drift {
55            expected: exp.to_string(),
56            actual,
57        },
58    })
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use crate::skill::parse_canonical;
65
66    const SAMPLE: &str = r#"
67name: hashable
68version: 1.0.0
69publisher: human:t
70description: d
71category: context
72content:
73  abstract: a
74  context: body
75"#;
76
77    #[test]
78    fn deterministic_hash() {
79        let m = parse_canonical(SAMPLE).unwrap();
80        let h1 = content_sha256(&m).unwrap();
81        let h2 = content_sha256(&m).unwrap();
82        assert_eq!(h1, h2);
83        assert_eq!(h1.len(), 64);
84    }
85
86    #[test]
87    fn drift_detected_when_field_changed() {
88        let m1 = parse_canonical(SAMPLE).unwrap();
89        let h1 = content_sha256(&m1).unwrap();
90        let mut m2 = m1.clone();
91        m2.description = "tampered".into();
92        let s = drift_status(&m2, Some(&h1)).unwrap();
93        assert!(matches!(s, DriftStatus::Drift { .. }));
94    }
95
96    #[test]
97    fn pinned_matches() {
98        let m = parse_canonical(SAMPLE).unwrap();
99        let h = content_sha256(&m).unwrap();
100        assert_eq!(drift_status(&m, Some(&h)).unwrap(), DriftStatus::Pinned);
101    }
102
103    #[test]
104    fn ct_eq_hex_rejects_unequal_length() {
105        assert!(!ct_eq_hex("aa", "aaa"));
106    }
107
108    #[test]
109    fn trust_hash_is_stable_across_transfers() {
110        let m = parse_canonical(SAMPLE).unwrap();
111        let h1 = content_hash_for_trust(&m).unwrap();
112        let mut m2 = m.clone();
113        m2.transfer_chain = vec!["agent://alice".into(), "agent://bob".into()];
114        let h2 = content_hash_for_trust(&m2).unwrap();
115        assert_eq!(h1, h2);
116    }
117
118    #[test]
119    fn trust_hash_is_stable_across_evolution() {
120        use crate::skill::EvolutionEvent;
121        let m = parse_canonical(SAMPLE).unwrap();
122        let h1 = content_hash_for_trust(&m).unwrap();
123        let mut m2 = m.clone();
124        m2.evolution_log = vec![EvolutionEvent {
125            version: "0.2.0".into(),
126            generation: 0,
127            source: "agent:self".into(),
128            changes: "tweak".into(),
129            quality_score: None,
130            timestamp: "2026-05-25T00:00:00Z".into(),
131        }];
132        let h2 = content_hash_for_trust(&m2).unwrap();
133        assert_eq!(h1, h2);
134    }
135
136    #[test]
137    fn trust_hash_differs_when_content_changes() {
138        let m = parse_canonical(SAMPLE).unwrap();
139        let mut m2 = m.clone();
140        m2.description = "changed".into();
141        assert_ne!(
142            content_hash_for_trust(&m).unwrap(),
143            content_hash_for_trust(&m2).unwrap()
144        );
145    }
146}