1use macp_pb::pb::CommitmentPayload;
20use sha2::{Digest, Sha256};
21use std::fmt::Write as _;
22
23const LABEL: &str = "macp-commitment-hash/1";
34
35pub fn commitment_hash(payload: &CommitmentPayload) -> String {
45 let canonical = canonicalize(payload);
46
47 let mut preimage = Vec::with_capacity(LABEL.len() + 1 + canonical.len());
48 preimage.extend_from_slice(LABEL.as_bytes());
49 preimage.push(b':');
50 preimage.extend_from_slice(&canonical);
51
52 let digest = Sha256::digest(&preimage);
53
54 let mut out = String::with_capacity("sha256:".len() + digest.len() * 2);
55 out.push_str("sha256:");
56 for byte in digest {
57 write!(out, "{byte:02x}").expect("write! to String never fails");
58 }
59 out
60}
61
62fn canonicalize(payload: &CommitmentPayload) -> Vec<u8> {
94 let mut out = String::new();
95 out.push('{');
96
97 out.push_str("\"action\":");
98 push_json_string(&mut out, &payload.action);
99
100 out.push_str(",\"authority_scope\":");
101 push_json_string(&mut out, &payload.authority_scope);
102
103 out.push_str(",\"commitment_id\":");
104 push_json_string(&mut out, &payload.commitment_id);
105
106 out.push_str(",\"configuration_version\":");
107 push_json_string(&mut out, &payload.configuration_version);
108
109 out.push_str(",\"mode_version\":");
110 push_json_string(&mut out, &payload.mode_version);
111
112 out.push_str(",\"outcome_positive\":");
113 out.push_str(if payload.outcome_positive {
114 "true"
115 } else {
116 "false"
117 });
118
119 out.push_str(",\"policy_version\":");
120 push_json_string(&mut out, &payload.policy_version);
121
122 out.push_str(",\"reason\":");
123 push_json_string(&mut out, &payload.reason);
124
125 if let Some(ref supersedes) = payload.supersedes {
126 out.push_str(",\"supersedes\":{\"commitment_hash\":");
127 push_json_string(&mut out, &supersedes.commitment_hash);
128 out.push_str(",\"session_id\":");
129 push_json_string(&mut out, &supersedes.session_id);
130 out.push('}');
131 }
132
133 out.push('}');
134 out.into_bytes()
135}
136
137fn push_json_string(out: &mut String, value: &str) {
151 out.push('"');
152 for c in value.chars() {
153 match c {
154 '"' => out.push_str("\\\""),
155 '\\' => out.push_str("\\\\"),
156 '\u{08}' => out.push_str("\\b"),
157 '\t' => out.push_str("\\t"),
158 '\n' => out.push_str("\\n"),
159 '\u{0C}' => out.push_str("\\f"),
160 '\r' => out.push_str("\\r"),
161 c if (c as u32) < 0x20 => {
162 write!(out, "\\u{:04x}", c as u32).expect("write! to String never fails");
163 }
164 c => out.push(c),
165 }
166 }
167 out.push('"');
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use macp_pb::pb::CommitmentRef;
174
175 fn decode_hex(s: &str) -> Vec<u8> {
178 assert_eq!(s.len() % 2, 0, "hex string must have even length");
179 (0..s.len())
180 .step_by(2)
181 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex byte"))
182 .collect()
183 }
184
185 fn base_payload() -> CommitmentPayload {
186 CommitmentPayload {
187 commitment_id: String::new(),
188 action: String::new(),
189 authority_scope: String::new(),
190 reason: String::new(),
191 mode_version: String::new(),
192 policy_version: String::new(),
193 configuration_version: String::new(),
194 outcome_positive: false,
195 supersedes: None,
196 }
197 }
198
199 #[test]
205 fn cmt_hash_001_minimal() {
206 let payload = CommitmentPayload {
207 commitment_id: "c1".into(),
208 action: "decision.approved".into(),
209 authority_scope: "seam".into(),
210 reason: "sealed by seam".into(),
211 mode_version: "macp.mode.decision.v1".into(),
212 policy_version: "1.0.0".into(),
213 configuration_version: "1.0.0".into(),
214 outcome_positive: true,
215 supersedes: None,
216 };
217
218 let jcs_hex = "7b22616374696f6e223a226465636973696f6e2e617070726f766564222c22617574686f726974795f73636f7065223a227365616d222c22636f6d6d69746d656e745f6964223a226331222c22636f6e66696775726174696f6e5f76657273696f6e223a22312e302e30222c226d6f64655f76657273696f6e223a226d6163702e6d6f64652e6465636973696f6e2e7631222c226f7574636f6d655f706f736974697665223a747275652c22706f6c6963795f76657273696f6e223a22312e302e30222c22726561736f6e223a227365616c6564206279207365616d227d";
219 assert_eq!(
220 canonicalize(&payload),
221 decode_hex(jcs_hex),
222 "cmt_hash_001_minimal: JCS bytes mismatch"
223 );
224
225 assert_eq!(
226 commitment_hash(&payload),
227 "sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41",
228 "cmt_hash_001_minimal: hash mismatch"
229 );
230 }
231
232 #[test]
233 fn cmt_hash_002_supersedes() {
234 let payload = CommitmentPayload {
235 commitment_id: "c2".into(),
236 action: "decision.approved".into(),
237 authority_scope: "seam".into(),
238 reason: "sealed by seam".into(),
239 mode_version: "macp.mode.decision.v1".into(),
240 policy_version: "1.0.0".into(),
241 configuration_version: "1.0.0".into(),
242 outcome_positive: true,
243 supersedes: Some(CommitmentRef {
244 session_id: "prior-sess".into(),
245 commitment_hash:
246 "sha256:9f58e9d114d11860d48aa2bcb8cda458b9618b1cc8560595a802b68c4af85d41".into(),
247 }),
248 };
249
250 assert_eq!(
251 commitment_hash(&payload),
252 "sha256:7cc490432ad6b25e9c19fc7c3a84f1e33abe497fca1fd5266ff0275db3650f9d",
253 "cmt_hash_002_supersedes: hash mismatch"
254 );
255 }
256
257 #[test]
258 fn cmt_hash_003_all_empty() {
259 let payload = base_payload();
260
261 assert_eq!(
262 commitment_hash(&payload),
263 "sha256:3240d1a7adb7bd9420ad5490182227ce699c9e4e465f7934885fe2ded939f32e",
264 "cmt_hash_003_all_empty: hash mismatch"
265 );
266 }
267
268 #[test]
269 fn cmt_hash_004_empty_supersedes() {
270 let mut payload = base_payload();
271 payload.supersedes = Some(CommitmentRef {
272 session_id: String::new(),
273 commitment_hash: String::new(),
274 });
275
276 assert_eq!(
277 commitment_hash(&payload),
278 "sha256:9776c22ef165f26817f89bb456cf6bc56a659eb1561a576f6ea9a435bd3291d7",
279 "cmt_hash_004_empty_supersedes: hash mismatch"
280 );
281 }
282
283 #[test]
284 fn cmt_hash_003_and_004_hashes_differ() {
285 let without = commitment_hash(&base_payload());
290 let mut with_empty_supersedes = base_payload();
291 with_empty_supersedes.supersedes = Some(CommitmentRef {
292 session_id: String::new(),
293 commitment_hash: String::new(),
294 });
295 let with = commitment_hash(&with_empty_supersedes);
296
297 assert_ne!(
298 without, with,
299 "supersedes:None and supersedes:Some(empty) must hash differently"
300 );
301 }
302
303 #[test]
304 fn cmt_hash_005_escapes() {
305 let payload = CommitmentPayload {
306 commitment_id: "c5".into(),
307 action: "decision.\"appro\\ved\"".into(),
308 authority_scope: "café".into(),
309 reason: "ré\tsumé\n— naïve \u{1F702}".into(),
310 mode_version: "macp.mode.decision.v1".into(),
311 policy_version: "1.0.0".into(),
312 configuration_version: "1.0.0".into(),
313 outcome_positive: false,
314 supersedes: None,
315 };
316
317 let jcs_hex = "7b22616374696f6e223a226465636973696f6e2e5c22617070726f5c5c7665645c22222c22617574686f726974795f73636f7065223a22636166c3a9222c22636f6d6d69746d656e745f6964223a226335222c22636f6e66696775726174696f6e5f76657273696f6e223a22312e302e30222c226d6f64655f76657273696f6e223a226d6163702e6d6f64652e6465636973696f6e2e7631222c226f7574636f6d655f706f736974697665223a66616c73652c22706f6c6963795f76657273696f6e223a22312e302e30222c22726561736f6e223a2272c3a95c7473756dc3a95c6ee28094206e61c3af766520f09f9c82227d";
318 assert_eq!(
319 canonicalize(&payload),
320 decode_hex(jcs_hex),
321 "cmt_hash_005_escapes: JCS bytes mismatch"
322 );
323
324 assert_eq!(
325 commitment_hash(&payload),
326 "sha256:03f8ac2b8172958504092ce9fe5154dbcfe300fd30a350453d4e4bd715822ab2",
327 "cmt_hash_005_escapes: hash mismatch"
328 );
329 }
330
331 fn escape(value: &str) -> String {
334 let mut out = String::new();
335 push_json_string(&mut out, value);
336 out
337 }
338
339 #[test]
340 fn escapes_embedded_quote() {
341 assert_eq!(escape("a\"b"), "\"a\\\"b\"");
342 }
343
344 #[test]
345 fn escapes_embedded_backslash() {
346 assert_eq!(escape("a\\b"), "\"a\\\\b\"");
347 }
348
349 #[test]
350 fn escapes_tab() {
351 assert_eq!(escape("a\tb"), "\"a\\tb\"");
352 }
353
354 #[test]
355 fn escapes_newline() {
356 assert_eq!(escape("a\nb"), "\"a\\nb\"");
357 }
358
359 #[test]
360 fn escapes_other_c0_control_as_u00xx() {
361 assert_eq!(escape("a\u{01}b"), "\"a\\u0001b\"");
363 }
364
365 #[test]
366 fn non_ascii_bmp_char_is_emitted_literally() {
367 assert_eq!(escape("café"), "\"café\"");
368 }
369
370 #[test]
371 fn astral_plane_codepoint_is_emitted_literally_not_as_surrogate_pair() {
372 let value = "\u{1F702}";
376 let escaped = escape(value);
377 assert_eq!(escaped, "\"\u{1F702}\"");
378 assert!(!escaped.contains("\\u"));
379 }
380}