1use crate::skill::manifest::SkillManifest;
2use crate::skill::serialize_canonical;
3use sha2::{Digest, Sha256};
4use subtle::ConstantTimeEq;
5
6pub 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
16pub 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
30pub 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
45pub 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 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}