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_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
31pub 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}