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/// Origin-stable content hash — excludes `origin`, `origin_version`,
17/// `origin_hash` (the stamp itself) plus `transfer_chain`/`evolution_log`
18/// (trust-excluded already) so restamping a skill never changes its own
19/// hash, but a real content edit always does.
20pub fn content_hash_for_origin(m: &SkillManifest) -> Result<String, crate::skill::ParseError> {
21    let mut clone = m.clone();
22    clone.transfer_chain = vec![];
23    clone.evolution_log = vec![];
24    clone.origin = None;
25    clone.origin_version = None;
26    clone.origin_hash = None;
27    content_sha256(&clone)
28}
29
30/// Hex-encoded SHA-256 of the canonical YAML form. Lowercase.
31pub fn content_sha256(m: &SkillManifest) -> Result<String, crate::skill::ParseError> {
32    let yaml = serialize_canonical(m)?;
33    Ok(sha256_hex(yaml.as_bytes()))
34}
35
36pub fn sha256_hex(bytes: &[u8]) -> String {
37    let hash = Sha256::digest(bytes);
38    let mut s = String::with_capacity(64);
39    for b in hash {
40        s.push_str(&format!("{:02x}", b));
41    }
42    s
43}
44
45/// Constant-time hex-string comparison. Used by drift detection.
46pub fn ct_eq_hex(a: &str, b: &str) -> bool {
47    if a.len() != b.len() {
48        return false;
49    }
50    a.as_bytes().ct_eq(b.as_bytes()).into()
51}
52
53#[derive(Debug, PartialEq, Eq)]
54pub enum DriftStatus {
55    Pinned,
56    Drift { expected: String, actual: String },
57    Unpinned,
58}
59
60pub fn drift_status(
61    m: &SkillManifest,
62    expected: Option<&str>,
63) -> Result<DriftStatus, crate::skill::ParseError> {
64    let actual = content_sha256(m)?;
65    Ok(match expected {
66        None => DriftStatus::Unpinned,
67        Some(exp) if ct_eq_hex(exp, &actual) => DriftStatus::Pinned,
68        Some(exp) => DriftStatus::Drift {
69            expected: exp.to_string(),
70            actual,
71        },
72    })
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::skill::parse_canonical;
79
80    const SAMPLE: &str = r#"
81name: hashable
82version: 1.0.0
83publisher: human:t
84description: d
85category: context
86content:
87  abstract: a
88  context: body
89"#;
90
91    #[test]
92    fn deterministic_hash() {
93        let m = parse_canonical(SAMPLE).unwrap();
94        let h1 = content_sha256(&m).unwrap();
95        let h2 = content_sha256(&m).unwrap();
96        assert_eq!(h1, h2);
97        assert_eq!(h1.len(), 64);
98    }
99
100    #[test]
101    fn drift_detected_when_field_changed() {
102        let m1 = parse_canonical(SAMPLE).unwrap();
103        let h1 = content_sha256(&m1).unwrap();
104        let mut m2 = m1.clone();
105        m2.description = "tampered".into();
106        let s = drift_status(&m2, Some(&h1)).unwrap();
107        assert!(matches!(s, DriftStatus::Drift { .. }));
108    }
109
110    #[test]
111    fn pinned_matches() {
112        let m = parse_canonical(SAMPLE).unwrap();
113        let h = content_sha256(&m).unwrap();
114        assert_eq!(drift_status(&m, Some(&h)).unwrap(), DriftStatus::Pinned);
115    }
116
117    #[test]
118    fn ct_eq_hex_rejects_unequal_length() {
119        assert!(!ct_eq_hex("aa", "aaa"));
120    }
121
122    #[test]
123    fn trust_hash_is_stable_across_transfers() {
124        let m = parse_canonical(SAMPLE).unwrap();
125        let h1 = content_hash_for_trust(&m).unwrap();
126        let mut m2 = m.clone();
127        m2.transfer_chain = vec!["agent://alice".into(), "agent://bob".into()];
128        let h2 = content_hash_for_trust(&m2).unwrap();
129        assert_eq!(h1, h2);
130    }
131
132    #[test]
133    fn trust_hash_is_stable_across_evolution() {
134        use crate::skill::EvolutionEvent;
135        let m = parse_canonical(SAMPLE).unwrap();
136        let h1 = content_hash_for_trust(&m).unwrap();
137        let mut m2 = m.clone();
138        m2.evolution_log = vec![EvolutionEvent {
139            version: "0.2.0".into(),
140            generation: 0,
141            source: "agent:self".into(),
142            changes: "tweak".into(),
143            quality_score: None,
144            timestamp: "2026-05-25T00:00:00Z".into(),
145        }];
146        let h2 = content_hash_for_trust(&m2).unwrap();
147        assert_eq!(h1, h2);
148    }
149
150    #[test]
151    fn origin_hash_stable_across_restamp_but_not_content_edit() {
152        let m = parse_canonical(SAMPLE).unwrap();
153        let before = content_hash_for_origin(&m).unwrap();
154        let mut m2 = m.clone();
155        m2.origin = Some("registry:mur-official/hashable".into());
156        m2.origin_version = Some("1.1.0".into());
157        m2.origin_hash = Some(before.clone());
158        assert_eq!(content_hash_for_origin(&m2).unwrap(), before);
159
160        // but real content changes DO change it
161        m2.description = "changed".into();
162        assert_ne!(content_hash_for_origin(&m2).unwrap(), before);
163    }
164
165    #[test]
166    fn trust_hash_differs_when_content_changes() {
167        let m = parse_canonical(SAMPLE).unwrap();
168        let mut m2 = m.clone();
169        m2.description = "changed".into();
170        assert_ne!(
171            content_hash_for_trust(&m).unwrap(),
172            content_hash_for_trust(&m2).unwrap()
173        );
174    }
175}