1use serde::{Deserialize, Serialize};
32
33use super::sanctuary_audit::{chain_hash, GENESIS_PARENT};
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum AuditAction {
40 OpenSession,
42 AddNote,
44 EditNote,
46 DeleteNote,
48 Other(String),
50}
51
52impl AuditAction {
53 fn tag(&self) -> u8 {
57 match self {
58 AuditAction::OpenSession => 0,
59 AuditAction::AddNote => 1,
60 AuditAction::EditNote => 2,
61 AuditAction::DeleteNote => 3,
62 AuditAction::Other(_) => 4,
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum RetentionMode {
71 AutoArchive,
73 ManualTriage,
75}
76
77impl Default for RetentionMode {
78 fn default() -> Self {
79 RetentionMode::AutoArchive
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89pub struct AuditRecord {
90 pub id: [u8; 32],
92 pub parent: [u8; 32],
94 pub branch_ref: String,
96 pub actor_did: String,
98 pub role: Option<String>,
100 pub stated_purpose: Option<String>,
102 pub action: AuditAction,
104 pub unix: u32,
106 pub sealed: Vec<u8>,
108}
109
110fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
113 out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
114 out.extend_from_slice(bytes);
115}
116
117fn push_opt(out: &mut Vec<u8>, value: Option<&str>) {
120 match value {
121 None => out.push(0),
122 Some(s) => {
123 out.push(1);
124 push_lp(out, s.as_bytes());
125 }
126 }
127}
128
129pub fn canonical_bytes(record: &AuditRecord) -> Vec<u8> {
137 let mut out = Vec::new();
138 push_lp(&mut out, record.branch_ref.as_bytes());
139 push_lp(&mut out, record.actor_did.as_bytes());
140 push_opt(&mut out, record.role.as_deref());
141 push_opt(&mut out, record.stated_purpose.as_deref());
142 out.push(record.action.tag());
144 if let AuditAction::Other(label) = &record.action {
145 push_lp(&mut out, label.as_bytes());
146 }
147 out.extend_from_slice(&record.unix.to_le_bytes());
148 push_lp(&mut out, &record.sealed);
149 out
150}
151
152impl AuditRecord {
153 #[allow(clippy::too_many_arguments)]
155 pub fn new(
156 parent: [u8; 32],
157 branch_ref: impl Into<String>,
158 actor_did: impl Into<String>,
159 role: Option<String>,
160 stated_purpose: Option<String>,
161 action: AuditAction,
162 unix: u32,
163 sealed: Vec<u8>,
164 ) -> Self {
165 let mut record = AuditRecord {
166 id: [0u8; 32],
167 parent,
168 branch_ref: branch_ref.into(),
169 actor_did: actor_did.into(),
170 role,
171 stated_purpose,
172 action,
173 unix,
174 sealed,
175 };
176 record.id = chain_hash(&record.parent, &canonical_bytes(&record));
177 record
178 }
179
180 pub fn recomputed_id(&self) -> [u8; 32] {
183 chain_hash(&self.parent, &canonical_bytes(self))
184 }
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "snake_case")]
190pub enum ChainStatus {
191 Ok,
193 Tampered { at_index: usize },
195 BrokenLink { at_index: usize },
198}
199
200pub fn verify_chain(branch: &[AuditRecord]) -> ChainStatus {
210 let mut prev_id: Option<[u8; 32]> = None;
211 for (i, record) in branch.iter().enumerate() {
212 if record.id != record.recomputed_id() {
214 return ChainStatus::Tampered { at_index: i };
215 }
216 match prev_id {
218 None => {
219 if record.parent != GENESIS_PARENT {
220 return ChainStatus::BrokenLink { at_index: i };
221 }
222 }
223 Some(expected_parent) => {
224 if record.parent != expected_parent {
225 return ChainStatus::BrokenLink { at_index: i };
226 }
227 }
228 }
229 prev_id = Some(record.id);
230 }
231 ChainStatus::Ok
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237pub struct Session {
238 pub branch_ref: String,
240 pub opened_unix: u32,
242 pub records: Vec<AuditRecord>,
244 pub action_count: usize,
246}
247
248fn order_branch(mut records: Vec<AuditRecord>) -> Vec<AuditRecord> {
253 use std::collections::HashMap;
254
255 let mut by_parent: HashMap<[u8; 32], usize> = HashMap::with_capacity(records.len());
258 let mut duplicate_parent = false;
259 for (i, r) in records.iter().enumerate() {
260 if by_parent.insert(r.parent, i).is_some() {
261 duplicate_parent = true;
262 }
263 }
264
265 let can_chain = !duplicate_parent && by_parent.contains_key(&GENESIS_PARENT);
266 if can_chain {
267 let mut ordered = Vec::with_capacity(records.len());
268 let mut visited = vec![false; records.len()];
269 let mut cursor = GENESIS_PARENT;
270 while let Some(&idx) = by_parent.get(&cursor) {
271 if visited[idx] {
272 break; }
274 visited[idx] = true;
275 cursor = records[idx].id;
276 ordered.push(idx);
277 }
278 if ordered.len() == records.len() {
279 let mut slots: Vec<Option<AuditRecord>> = records.into_iter().map(Some).collect();
281 return ordered
282 .into_iter()
283 .map(|i| slots[i].take().expect("each index visited once"))
284 .collect();
285 }
286 }
287
288 records.sort_by(|a, b| a.unix.cmp(&b.unix).then_with(|| a.id.cmp(&b.id)));
290 records
291}
292
293pub fn derive_sessions(records: &[AuditRecord]) -> Vec<Session> {
303 use std::collections::HashMap;
304
305 let mut order: Vec<String> = Vec::new();
307 let mut groups: HashMap<String, Vec<AuditRecord>> = HashMap::new();
308 for r in records {
309 if !groups.contains_key(&r.branch_ref) {
310 order.push(r.branch_ref.clone());
311 }
312 groups
313 .entry(r.branch_ref.clone())
314 .or_default()
315 .push(r.clone());
316 }
317
318 order
319 .into_iter()
320 .map(|branch_ref| {
321 let branch = groups.remove(&branch_ref).unwrap_or_default();
322 let ordered = order_branch(branch);
323 let opened_unix = ordered.iter().map(|r| r.unix).min().unwrap_or(0);
324 let action_count = ordered.len();
325 Session {
326 branch_ref,
327 opened_unix,
328 records: ordered,
329 action_count,
330 }
331 })
332 .collect()
333}
334
335#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
337pub struct Routing {
338 pub archived: Vec<AuditRecord>,
340 pub inbox: Vec<AuditRecord>,
342}
343
344pub fn route(records: Vec<AuditRecord>, mode: RetentionMode) -> Routing {
353 match mode {
354 RetentionMode::AutoArchive => Routing {
355 archived: records,
356 inbox: Vec::new(),
357 },
358 RetentionMode::ManualTriage => Routing {
359 archived: Vec::new(),
360 inbox: records,
361 },
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::crypto::sanctuary_audit::{open_sealed, seal_to, AuditKeypair};
369
370 fn rec(parent: [u8; 32], branch: &str, action: AuditAction, unix: u32) -> AuditRecord {
371 AuditRecord::new(
372 parent,
373 branch,
374 "did:example:actor",
375 Some("editor".into()),
376 Some("logging".into()),
377 action,
378 unix,
379 vec![1, 2, 3],
380 )
381 }
382
383 fn build_branch(branch: &str, n: u32) -> Vec<AuditRecord> {
385 let mut out = Vec::new();
386 let mut parent = GENESIS_PARENT;
387 for i in 0..n {
388 let action = if i == 0 {
389 AuditAction::OpenSession
390 } else {
391 AuditAction::AddNote
392 };
393 let r = rec(parent, branch, action, 1000 + i);
394 parent = r.id;
395 out.push(r);
396 }
397 out
398 }
399
400 #[test]
401 fn new_computes_stable_id() {
402 let a = rec(GENESIS_PARENT, "b1", AuditAction::OpenSession, 1000);
403 assert_eq!(a.id, a.recomputed_id());
405 assert_eq!(a.parent, GENESIS_PARENT);
407 assert_ne!(a.id, [0u8; 32]);
408 }
409
410 #[test]
411 fn identical_inputs_yield_identical_id() {
412 let a = rec(GENESIS_PARENT, "b1", AuditAction::AddNote, 1234);
413 let b = rec(GENESIS_PARENT, "b1", AuditAction::AddNote, 1234);
414 assert_eq!(a.id, b.id);
415 }
416
417 #[test]
418 fn any_field_change_changes_id() {
419 let base = rec(GENESIS_PARENT, "b1", AuditAction::AddNote, 1234);
420
421 let diff_branch = rec(GENESIS_PARENT, "b2", AuditAction::AddNote, 1234);
422 assert_ne!(base.id, diff_branch.id);
423
424 let diff_action = rec(GENESIS_PARENT, "b1", AuditAction::EditNote, 1234);
425 assert_ne!(base.id, diff_action.id);
426
427 let diff_unix = rec(GENESIS_PARENT, "b1", AuditAction::AddNote, 1235);
428 assert_ne!(base.id, diff_unix.id);
429
430 let diff_parent = rec([9u8; 32], "b1", AuditAction::AddNote, 1234);
431 assert_ne!(base.id, diff_parent.id);
432
433 let diff_role = AuditRecord::new(
435 GENESIS_PARENT,
436 "b1",
437 "did:example:actor",
438 Some("viewer".into()),
439 Some("logging".into()),
440 AuditAction::AddNote,
441 1234,
442 vec![1, 2, 3],
443 );
444 assert_ne!(base.id, diff_role.id);
445
446 let diff_sealed = AuditRecord::new(
447 GENESIS_PARENT,
448 "b1",
449 "did:example:actor",
450 Some("editor".into()),
451 Some("logging".into()),
452 AuditAction::AddNote,
453 1234,
454 vec![9, 9, 9],
455 );
456 assert_ne!(base.id, diff_sealed.id);
457 }
458
459 #[test]
460 fn canonical_bytes_is_unambiguous_across_field_boundaries() {
461 let x = AuditRecord::new(
463 GENESIS_PARENT,
464 "ab",
465 "c",
466 None,
467 None,
468 AuditAction::AddNote,
469 1,
470 vec![],
471 );
472 let y = AuditRecord::new(
473 GENESIS_PARENT,
474 "a",
475 "bc",
476 None,
477 None,
478 AuditAction::AddNote,
479 1,
480 vec![],
481 );
482 assert_ne!(x.id, y.id);
483 }
484
485 #[test]
486 fn none_and_empty_option_are_distinct() {
487 let none = AuditRecord::new(
488 GENESIS_PARENT,
489 "b",
490 "a",
491 None,
492 None,
493 AuditAction::AddNote,
494 1,
495 vec![],
496 );
497 let empty = AuditRecord::new(
498 GENESIS_PARENT,
499 "b",
500 "a",
501 Some(String::new()),
502 None,
503 AuditAction::AddNote,
504 1,
505 vec![],
506 );
507 assert_ne!(none.id, empty.id);
508 }
509
510 #[test]
511 fn well_formed_branch_verifies_ok() {
512 let branch = build_branch("session-1", 3);
513 assert_eq!(verify_chain(&branch), ChainStatus::Ok);
514 }
515
516 #[test]
517 fn empty_branch_is_ok() {
518 assert_eq!(verify_chain(&[]), ChainStatus::Ok);
519 }
520
521 #[test]
522 fn first_record_not_from_genesis_is_broken_link() {
523 let mut branch = build_branch("s", 2);
524 branch[0].parent = [5u8; 32];
527 branch[0].id = branch[0].recomputed_id();
528 assert_eq!(
529 verify_chain(&branch),
530 ChainStatus::BrokenLink { at_index: 0 }
531 );
532 }
533
534 #[test]
535 fn tampering_payload_is_detected_as_tampered() {
536 let mut branch = build_branch("session-1", 3);
537 branch[1].sealed = vec![0xFF, 0xEE];
539 assert_eq!(verify_chain(&branch), ChainStatus::Tampered { at_index: 1 });
540 }
541
542 #[test]
543 fn tampering_and_resealing_id_surfaces_as_broken_link() {
544 let mut branch = build_branch("session-1", 3);
547 branch[1].sealed = vec![0xFF, 0xEE];
548 branch[1].id = branch[1].recomputed_id();
549 assert_eq!(
550 verify_chain(&branch),
551 ChainStatus::BrokenLink { at_index: 2 }
552 );
553 }
554
555 #[test]
556 fn swapping_parent_of_record_2_is_broken_link() {
557 let mut branch = build_branch("session-1", 3);
558 branch[2].parent = [7u8; 32];
561 branch[2].id = branch[2].recomputed_id();
562 assert_eq!(
563 verify_chain(&branch),
564 ChainStatus::BrokenLink { at_index: 2 }
565 );
566 }
567
568 #[test]
569 fn dropping_the_middle_record_is_detected() {
570 let branch = build_branch("session-1", 3);
571 let dropped = vec![branch[0].clone(), branch[2].clone()];
572 assert_eq!(
574 verify_chain(&dropped),
575 ChainStatus::BrokenLink { at_index: 1 }
576 );
577 }
578
579 #[test]
580 fn derive_sessions_two_branches_correct_counts() {
581 let mut all = build_branch("branch-A", 3);
582 all.extend(build_branch("branch-B", 2));
583
584 let sessions = derive_sessions(&all);
585 assert_eq!(sessions.len(), 2);
586
587 let a = sessions
588 .iter()
589 .find(|s| s.branch_ref == "branch-A")
590 .unwrap();
591 let b = sessions
592 .iter()
593 .find(|s| s.branch_ref == "branch-B")
594 .unwrap();
595 assert_eq!(a.action_count, 3);
596 assert_eq!(a.records.len(), 3);
597 assert_eq!(b.action_count, 2);
598 assert_eq!(b.opened_unix, 1000);
599 assert_eq!(sessions[0].branch_ref, "branch-A");
601 }
602
603 #[test]
604 fn derive_sessions_single_branch_ordered_by_linkage() {
605 let branch = build_branch("only", 4);
607 let shuffled = vec![
608 branch[2].clone(),
609 branch[0].clone(),
610 branch[3].clone(),
611 branch[1].clone(),
612 ];
613 let sessions = derive_sessions(&shuffled);
614 assert_eq!(sessions.len(), 1);
615 let s = &sessions[0];
616 assert_eq!(s.action_count, 4);
617 assert_eq!(verify_chain(&s.records), ChainStatus::Ok);
619 assert_eq!(s.opened_unix, 1000);
620 }
621
622 #[test]
623 fn derive_sessions_empty_input() {
624 assert!(derive_sessions(&[]).is_empty());
625 }
626
627 #[test]
628 fn route_auto_archive_puts_all_in_archived() {
629 let recs = build_branch("s", 3);
630 let routed = route(recs.clone(), RetentionMode::AutoArchive);
631 assert_eq!(routed.archived.len(), 3);
632 assert!(routed.inbox.is_empty());
633 let again = route(
635 routed
636 .archived
637 .iter()
638 .chain(routed.inbox.iter())
639 .cloned()
640 .collect(),
641 RetentionMode::AutoArchive,
642 );
643 assert_eq!(again, routed);
644 }
645
646 #[test]
647 fn route_manual_triage_puts_all_in_inbox() {
648 let recs = build_branch("s", 3);
649 let routed = route(recs.clone(), RetentionMode::ManualTriage);
650 assert_eq!(routed.inbox.len(), 3);
651 assert!(routed.archived.is_empty());
652 let again = route(
654 routed
655 .archived
656 .iter()
657 .chain(routed.inbox.iter())
658 .cloned()
659 .collect(),
660 RetentionMode::ManualTriage,
661 );
662 assert_eq!(again, routed);
663 }
664
665 #[test]
666 fn default_retention_mode_is_auto_archive() {
667 assert_eq!(RetentionMode::default(), RetentionMode::AutoArchive);
668 }
669
670 #[test]
671 fn action_and_mode_serde_snake_case() {
672 assert_eq!(
674 serde_json::to_string(&AuditAction::OpenSession).unwrap(),
675 "\"open_session\""
676 );
677 assert_eq!(
678 serde_json::to_string(&AuditAction::Other("x".into())).unwrap(),
679 "{\"other\":\"x\"}"
680 );
681 assert_eq!(
682 serde_json::to_string(&RetentionMode::ManualTriage).unwrap(),
683 "\"manual_triage\""
684 );
685 }
686
687 #[test]
688 fn record_round_trips_through_serde() {
689 let r = rec(GENESIS_PARENT, "b1", AuditAction::AddNote, 42);
690 let json = serde_json::to_string(&r).unwrap();
691 let back: AuditRecord = serde_json::from_str(&json).unwrap();
692 assert_eq!(r, back);
693 assert_eq!(back.id, back.recomputed_id());
694 }
695
696 #[test]
697 fn dag_stores_real_sealed_blob_opaquely_and_real_lane_recovers_it() {
698 let kp = AuditKeypair::generate().unwrap();
702 let aad = b"branch:duress-1";
703 let plaintext = b"coercer added note at 12:04 under duress";
704 let sealed = seal_to(&kp.public, plaintext, aad).unwrap();
705
706 let record = AuditRecord::new(
707 GENESIS_PARENT,
708 "duress-1",
709 "did:example:coercer",
710 Some("guest".into()),
711 None,
712 AuditAction::AddNote,
713 1717171717,
714 sealed.clone(),
715 );
716
717 assert_eq!(record.sealed, sealed);
719 assert_eq!(record.id, record.recomputed_id());
720 assert_eq!(verify_chain(std::slice::from_ref(&record)), ChainStatus::Ok);
721
722 let opened = open_sealed(kp.secret_bytes(), &record.sealed, aad).unwrap();
724 assert_eq!(opened, plaintext);
725
726 assert!(open_sealed(&kp.public, &record.sealed, aad).is_err());
728 }
729}