1use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19
20use crate::object::{ContentHash, Principal, StateId, StateSignature};
21
22pub const REDACTION_SIGNING_PAYLOAD_VERSION_TAG: &[u8] = b"hd-redact-v3\x00";
27
28pub const PURGE_SIGNING_PAYLOAD_VERSION_TAG: &[u8] = b"hd-purge-v1\x00";
30
31#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
33pub struct PurgeEvidence {
34 pub purger: Principal,
36 pub purged_at: DateTime<Utc>,
38 pub signature: StateSignature,
40}
41
42#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
44pub struct Redaction {
45 pub redacted_blob: ContentHash,
47 pub state: StateId,
51 pub path: String,
53 pub reason: String,
55 pub redactor: Principal,
57 pub redacted_at: DateTime<Utc>,
60 #[serde(default)]
65 pub signature: Option<StateSignature>,
66 #[serde(default)]
68 pub purge: Option<PurgeEvidence>,
69 #[serde(default)]
73 pub supersedes: Option<ContentHash>,
74}
75
76impl Redaction {
77 pub fn canonical_signing_payload(&self) -> Vec<u8> {
82 let mut buf = Vec::with_capacity(256);
83 buf.extend_from_slice(REDACTION_SIGNING_PAYLOAD_VERSION_TAG);
84 buf.extend_from_slice(self.redacted_blob.as_bytes());
85 buf.extend_from_slice(self.state.as_bytes());
86 buf.extend_from_slice(self.path.as_bytes());
87 buf.push(0);
88 buf.extend_from_slice(self.reason.as_bytes());
89 buf.push(0);
90 buf.extend_from_slice(&self.redactor.name);
91 buf.push(0);
92 buf.extend_from_slice(&self.redactor.email);
93 buf.push(0);
94 buf.extend_from_slice(self.redacted_at.to_rfc3339().as_bytes());
95 buf.push(0);
96 if let Some(supersedes) = &self.supersedes {
97 buf.extend_from_slice(supersedes.as_bytes());
98 }
99 buf
100 }
101
102 pub fn canonical_purge_signing_payload(
104 &self,
105 purger: &Principal,
106 purged_at: DateTime<Utc>,
107 ) -> Vec<u8> {
108 let declaration = ContentHash::compute_typed(
109 "redaction-declaration-v3",
110 &self.canonical_signing_payload(),
111 );
112 let mut buf = Vec::with_capacity(192);
113 buf.extend_from_slice(PURGE_SIGNING_PAYLOAD_VERSION_TAG);
114 buf.extend_from_slice(declaration.as_bytes());
115 buf.extend_from_slice(&purger.name);
116 buf.push(0);
117 buf.extend_from_slice(&purger.email);
118 buf.push(0);
119 buf.extend_from_slice(purged_at.to_rfc3339().as_bytes());
120 buf
121 }
122
123 pub fn mark_purged(&mut self, evidence: PurgeEvidence) -> bool {
126 if self.purge.is_some() {
127 false
128 } else {
129 self.purge = Some(evidence);
130 true
131 }
132 }
133
134 pub fn is_purged(&self) -> bool {
136 self.purge.is_some()
137 }
138
139 pub fn stub_text(&self, redaction_id: &ContentHash) -> String {
143 let mut out = String::with_capacity(256);
144 out.push_str("# This file was redacted by Heddle.\n");
145 out.push_str(&format!(
146 "# redacted-at: {}\n",
147 self.redacted_at.to_rfc3339()
148 ));
149 out.push_str(&format!(
150 "# redactor: {} <{}>\n",
151 self.redactor.name_lossy(),
152 self.redactor.email_lossy()
153 ));
154 out.push_str(&format!("# reason: {}\n", self.reason));
155 out.push_str(&format!("# redaction: {}\n", redaction_id.short()));
156 if let Some(purge) = &self.purge {
157 out.push_str(&format!(
158 "# purged-at: {}\n",
159 purge.purged_at.to_rfc3339()
160 ));
161 out.push_str(&format!("# purger: {}\n", purge.purger));
162 out.push_str("# The original bytes have been purged from local storage.\n");
163 } else {
164 out.push_str("# The original bytes remain on disk pending purge.\n");
165 }
166 out
167 }
168}
169
170#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
174pub struct RedactionsBlob {
175 pub format_version: u8,
176 pub redactions: Vec<Redaction>,
177}
178
179impl RedactionsBlob {
180 pub const FORMAT_VERSION: u8 = 2;
181
182 pub fn new(redactions: Vec<Redaction>) -> Self {
183 Self {
184 format_version: Self::FORMAT_VERSION,
185 redactions,
186 }
187 }
188
189 pub fn empty() -> Self {
190 Self::new(Vec::new())
191 }
192
193 pub fn encode(&self) -> Result<Vec<u8>, RedactionError> {
194 self.validate()?;
195 rmp_serde::to_vec(self).map_err(|err| RedactionError::Encoding(err.to_string()))
196 }
197
198 pub fn decode(bytes: &[u8]) -> Result<Self, RedactionError> {
199 let blob: Self = rmp_serde::from_slice(bytes)
200 .map_err(|err| RedactionError::Decoding(err.to_string()))?;
201 blob.validate()?;
202 Ok(blob)
203 }
204
205 pub fn validate(&self) -> Result<(), RedactionError> {
206 if self.format_version != Self::FORMAT_VERSION {
207 return Err(RedactionError::UnsupportedVersion(self.format_version));
208 }
209 Ok(())
210 }
211
212 pub fn push(&mut self, redaction: Redaction) {
213 self.redactions.push(redaction);
214 }
215
216 pub fn has_active(&self) -> bool {
220 !self.redactions.is_empty()
221 }
222
223 pub fn latest(&self) -> Option<&Redaction> {
227 self.redactions.iter().max_by_key(|r| r.redacted_at)
228 }
229
230 pub fn mark_all_purged(
233 &mut self,
234 evidence: Vec<PurgeEvidence>,
235 ) -> Result<usize, RedactionError> {
236 if self.redactions.len() != evidence.len() {
237 return Err(RedactionError::PurgeEvidenceCountMismatch);
238 }
239 let mut transitioned = 0;
240 for (redaction, evidence) in self.redactions.iter_mut().zip(evidence) {
241 if redaction.mark_purged(evidence) {
242 transitioned += 1;
243 }
244 }
245 Ok(transitioned)
246 }
247}
248
249#[derive(Debug, thiserror::Error)]
251pub enum RedactionError {
252 #[error("unsupported redactions blob version {0}; run the redaction migration")]
253 UnsupportedVersion(u8),
254 #[error("purge evidence count does not match redaction count")]
255 PurgeEvidenceCountMismatch,
256 #[error("encoding redaction: {0}")]
257 Encoding(String),
258 #[error("decoding redaction: {0}")]
259 Decoding(String),
260}
261
262#[cfg(test)]
263mod tests {
264 use chrono::TimeZone;
265
266 use super::*;
267
268 fn principal() -> Principal {
269 Principal {
270 name: "Grace Hopper".into(),
271 email: "grace@example.com".into(),
272 }
273 }
274
275 fn blob_hash() -> ContentHash {
276 ContentHash::from_bytes([7u8; 32])
277 }
278
279 fn redaction(blob: ContentHash, reason: &str) -> Redaction {
280 Redaction {
281 redacted_blob: blob,
282 state: StateId::from_bytes([1u8; 32]),
283 path: "config/secrets.toml".into(),
284 reason: reason.into(),
285 redactor: principal(),
286 redacted_at: Utc.with_ymd_and_hms(2026, 5, 10, 14, 33, 0).unwrap(),
287 signature: None,
288 purge: None,
289 supersedes: None,
290 }
291 }
292
293 fn purge_evidence(at: DateTime<Utc>) -> PurgeEvidence {
294 PurgeEvidence {
295 purger: Principal::new("Repository Owner", "owner@example.com"),
296 purged_at: at,
297 signature: StateSignature {
298 algorithm: "ed25519".to_string(),
299 public_key: "11".repeat(32),
300 signature: "22".repeat(64),
301 },
302 }
303 }
304
305 #[test]
306 fn round_trips_through_msgpack() {
307 let blob = blob_hash();
308 let original = RedactionsBlob::new(vec![redaction(blob, "leaked credential")]);
309 let encoded = original.encode().expect("encode");
310 let decoded = RedactionsBlob::decode(&encoded).expect("decode");
311 assert_eq!(decoded, original);
312 assert_eq!(decoded.format_version, RedactionsBlob::FORMAT_VERSION);
314 }
315
316 #[test]
317 fn prior_blob_format_is_rejected_instead_of_dual_read() {
318 let legacy = RedactionsBlob {
319 format_version: 1,
320 redactions: vec![redaction(blob_hash(), "legacy declaration")],
321 };
322 let bytes = rmp_serde::to_vec(&legacy).expect("encode unsupported fixture");
323 assert!(matches!(
324 RedactionsBlob::decode(&bytes),
325 Err(RedactionError::UnsupportedVersion(1))
326 ));
327 }
328
329 #[test]
330 fn canonical_payload_stable_across_field_reordering() {
331 let r = redaction(blob_hash(), "leaked credential");
337 let payload = r.canonical_signing_payload();
338 assert!(payload.starts_with(REDACTION_SIGNING_PAYLOAD_VERSION_TAG));
340 let payload_text = String::from_utf8_lossy(&payload);
343 assert!(payload_text.contains("leaked credential"));
344 assert!(payload_text.contains("config/secrets.toml"));
345 assert!(payload_text.contains("2026-05-10T14:33:00+00:00"));
348 }
349
350 #[test]
351 fn mark_purged_is_idempotent_and_observable() {
352 let mut r = redaction(blob_hash(), "leaked credential");
353 let before = r.canonical_signing_payload();
354 let at = Utc.with_ymd_and_hms(2026, 5, 11, 0, 0, 0).unwrap();
355 assert!(!r.is_purged());
356 assert!(r.mark_purged(purge_evidence(at)));
357 assert!(r.is_purged());
358 assert!(!r.mark_purged(purge_evidence(
361 Utc.with_ymd_and_hms(2026, 5, 12, 0, 0, 0).unwrap()
362 )));
363 assert_eq!(
364 r.purge.as_ref().map(|evidence| evidence.purged_at),
365 Some(at)
366 );
367 assert_eq!(
368 before,
369 r.canonical_signing_payload(),
370 "purge authority must not mutate the redaction signing payload"
371 );
372 }
373
374 #[test]
375 fn stub_text_mentions_redactor_reason_and_purge_state() {
376 let r = redaction(blob_hash(), "leaked credential");
377 let stub = r.stub_text(&blob_hash());
378 assert!(stub.contains("Grace Hopper"));
382 assert!(stub.contains("grace@example.com"));
383 assert!(stub.contains("leaked credential"));
384 assert!(stub.contains("# redacted-at:"));
385 assert!(stub.contains("# redaction:"));
386 assert!(stub.contains("remain on disk pending purge"));
388
389 let mut purged = r.clone();
390 purged.mark_purged(purge_evidence(
391 Utc.with_ymd_and_hms(2026, 5, 11, 0, 0, 0).unwrap(),
392 ));
393 let purged_stub = purged.stub_text(&blob_hash());
394 assert!(purged_stub.contains("# purged-at:"));
395 assert!(purged_stub.contains("purged from local storage"));
396 }
397
398 #[test]
399 fn latest_picks_the_most_recent() {
400 let early = redaction(blob_hash(), "first pass");
401 let late = Redaction {
402 redacted_at: Utc.with_ymd_and_hms(2026, 5, 12, 9, 0, 0).unwrap(),
403 reason: "tighter scope".into(),
404 ..redaction(blob_hash(), "tighter scope")
405 };
406 let blob = RedactionsBlob::new(vec![early, late.clone()]);
407 assert_eq!(blob.latest().unwrap(), &late);
408 }
409}
410
411#[cfg(test)]
412mod proptests {
413 use proptest::prelude::*;
431
432 use super::*;
433
434 fn arb_principal() -> impl Strategy<Value = Principal> {
435 let name = "[A-Za-z][A-Za-z0-9 _-]{0,30}";
440 let email = "[a-z][a-z0-9_-]{0,15}@[a-z0-9.-]{1,30}\\.[a-z]{2,4}";
441 (name, email).prop_map(|(name, email)| Principal::new(name, email))
442 }
443
444 fn arb_blob_hash() -> impl Strategy<Value = ContentHash> {
445 any::<[u8; 32]>().prop_map(ContentHash::from_bytes)
446 }
447
448 fn arb_state_id() -> impl Strategy<Value = StateId> {
449 any::<[u8; 32]>().prop_map(StateId::from_bytes)
450 }
451
452 fn arb_redaction() -> impl Strategy<Value = Redaction> {
453 let secs = 946_684_800i64..4_102_444_800i64;
457 (
458 arb_blob_hash(),
459 arb_state_id(),
460 "[A-Za-z0-9._/-]{1,40}",
461 "[A-Za-z0-9 ._:'-]{0,80}",
462 arb_principal(),
463 secs,
464 prop::option::of(arb_blob_hash()),
465 )
466 .prop_map(|(blob, state, path, reason, redactor, secs, supersedes)| {
467 Redaction {
468 redacted_blob: blob,
469 state,
470 path,
471 reason,
472 redactor,
473 redacted_at: chrono::DateTime::<Utc>::from_timestamp(secs, 0)
474 .expect("in-range timestamp"),
475 signature: None,
476 purge: None,
477 supersedes,
478 }
479 })
480 }
481
482 proptest! {
483 #[test]
487 fn encode_decode_roundtrip(r in arb_redaction()) {
488 let blob = RedactionsBlob::new(vec![r.clone()]);
489 let bytes = blob.encode().expect("encode");
490 let decoded = RedactionsBlob::decode(&bytes).expect("decode");
491 prop_assert_eq!(decoded.redactions.len(), 1);
492 prop_assert_eq!(&decoded.redactions[0], &r);
493 }
494
495 #[test]
500 fn canonical_payload_is_deterministic(r in arb_redaction()) {
501 let payload1 = r.canonical_signing_payload();
502 let payload2 = r.clone().canonical_signing_payload();
503 prop_assert_eq!(payload1, payload2);
504 }
505
506 #[test]
512 fn mark_purged_is_idempotent(
513 mut r in arb_redaction(),
514 t1_secs in 946_684_800i64..4_000_000_000i64,
515 t2_offset in 0i64..1_000_000_000i64,
516 ) {
517 let t1 = chrono::DateTime::<Utc>::from_timestamp(t1_secs, 0).unwrap();
518 let t2 = chrono::DateTime::<Utc>::from_timestamp(t1_secs + t2_offset, 0).unwrap();
519 let evidence = |purged_at| PurgeEvidence {
520 purger: Principal::new("Owner", "owner@example.com"),
521 purged_at,
522 signature: StateSignature {
523 algorithm: "ed25519".to_string(),
524 public_key: "11".repeat(32),
525 signature: "22".repeat(64),
526 },
527 };
528 prop_assert!(r.mark_purged(evidence(t1)));
529 prop_assert!(r.is_purged());
530 prop_assert_eq!(r.purge.as_ref().map(|purge| purge.purged_at), Some(t1));
531 prop_assert!(!r.mark_purged(evidence(t2)));
533 prop_assert_eq!(r.purge.as_ref().map(|purge| purge.purged_at), Some(t1));
534 }
535
536 #[test]
540 fn stub_always_carries_id_and_reason(r in arb_redaction()) {
541 let id = ContentHash::from_bytes([0xAB; 32]);
542 let stub = r.stub_text(&id);
543 prop_assert!(
546 stub.contains(&id.short()),
547 "stub must contain redaction id; got: {stub}"
548 );
549 if !r.reason.is_empty() {
552 prop_assert!(
553 stub.contains(&r.reason),
554 "stub must carry reason '{}'; got: {stub}",
555 r.reason
556 );
557 }
558 prop_assert!(
562 stub.contains(r.redactor.email_lossy().as_ref()),
563 "stub must carry redactor email '{}'; got: {stub}",
564 r.redactor.email_lossy()
565 );
566 }
567
568 #[test]
574 fn empty_blob_is_inert(seed in any::<u8>()) {
575 let _ = seed; let blob = RedactionsBlob::empty();
577 prop_assert!(!blob.has_active());
578 prop_assert!(blob.latest().is_none());
579 }
580
581 #[test]
585 fn single_redaction_makes_blob_active(r in arb_redaction()) {
586 let blob = RedactionsBlob::new(vec![r]);
587 prop_assert!(blob.has_active());
588 prop_assert!(blob.latest().is_some());
589 }
590 }
591}