1use std::path::{Component, Path, PathBuf};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use anyhow::Result;
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23
24use super::db::Store;
25
26pub const SCHEMA_VERSION: u8 = 1;
34
35pub const HASH_ALGORITHM: &str = "sha256";
38
39const SEQ_KEY: &str = "enforcement:seq";
41
42pub const INSTALLATION_ID_KEY: &str = "system:installation_id";
44
45pub const EVENT_PREFIX: &str = "enforcement:event:";
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct EnforcementEvent {
59 pub event_id: String,
61
62 pub schema_version: u8,
64
65 pub seq_no: u64,
69
70 pub recorded_at_ms: u64,
72
73 pub event_type: EnforcementEventType,
75
76 pub event_hash: String,
79
80 pub prev_hash: String,
83
84 pub installation_id: String,
87
88 pub actor_local: Option<ActorLocal>,
91
92 pub agent_type: String,
94
95 pub subject_kind: SubjectKind,
97
98 pub subject_key: String,
102
103 pub canonical_subject_hash: Option<String>,
106
107 pub receipt_id: Option<String>,
109
110 pub decision_reason_code: String,
114
115 pub decision_basis_hash: Option<String>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct ActorLocal {
122 pub username: String,
124 pub uid: Option<u32>,
126 pub verified: bool, }
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(rename_all = "snake_case")]
132pub enum SubjectKind {
133 File,
134 Control,
135 Config,
136 System,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(tag = "type", rename_all = "snake_case")]
141pub enum EnforcementEventType {
142 Deny,
143 AllowAfterReceipt,
144 ReceiptMinted,
145 BypassDetected,
146 ControlChanged {
147 change_kind: ControlChangeKind,
148 },
149 EnforcementConfigChanged {
150 setting: String,
151 old_value: String,
152 new_value: String,
153 },
154 RecordingGap {
155 gap_start_ms: u64,
156 gap_end_ms: u64,
157 cause: GapCause,
158 enforcement_mode_during_gap: EnforcementMode,
159 missed_event_count: MissedEventCount,
160 certainty: GapCertainty,
161 },
162 RetentionPruned {
163 pruned_count: u64,
164 oldest_pruned_seq: u64,
165 newest_pruned_seq: u64,
166 },
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum ControlChangeKind {
172 Created,
173 Confirmed,
174 Updated,
175 Deleted,
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180pub enum GapCause {
181 DaemonUnreachable,
182 StoreWriteFailure,
183 StoreLocked,
184 CorruptionRecovery,
185 Unknown,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
189#[serde(rename_all = "snake_case")]
190pub enum EnforcementMode {
191 Advisory,
192 Strict,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "snake_case")]
197pub enum MissedEventCount {
198 Known(u64),
199 Zero,
200 Unknown,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum GapCertainty {
206 Exact,
207 Inferred,
208}
209
210#[derive(Serialize)]
221struct CanonicalEvent<'a> {
222 event_id: &'a str,
223 schema_version: u8,
224 seq_no: u64,
225 recorded_at_ms: u64,
226 event_type: &'a EnforcementEventType,
227 prev_hash: &'a str,
228 installation_id: &'a str,
229 actor_local: &'a Option<ActorLocal>,
230 agent_type: &'a str,
231 subject_kind: SubjectKind,
232 subject_key: &'a str,
233 canonical_subject_hash: Option<&'a str>,
234 receipt_id: Option<&'a str>,
235 decision_reason_code: &'a str,
236 decision_basis_hash: Option<&'a str>,
237}
238
239impl EnforcementEvent {
240 pub fn compute_hash(&self) -> String {
246 let canonical = CanonicalEvent {
247 event_id: &self.event_id,
248 schema_version: self.schema_version,
249 seq_no: self.seq_no,
250 recorded_at_ms: self.recorded_at_ms,
251 event_type: &self.event_type,
252 prev_hash: &self.prev_hash,
253 installation_id: &self.installation_id,
254 actor_local: &self.actor_local,
255 agent_type: &self.agent_type,
256 subject_kind: self.subject_kind,
257 subject_key: &self.subject_key,
258 canonical_subject_hash: self.canonical_subject_hash.as_deref(),
259 receipt_id: self.receipt_id.as_deref(),
260 decision_reason_code: &self.decision_reason_code,
261 decision_basis_hash: self.decision_basis_hash.as_deref(),
262 };
263
264 let json =
265 serde_json::to_string(&canonical).expect("canonical serialization must not fail");
266
267 let mut hasher = Sha256::new();
268 hasher.update(json.as_bytes());
269 format!("{:x}", hasher.finalize())
270 }
271}
272
273pub struct SeqAllocator {
283 current: u64,
284}
285
286impl SeqAllocator {
287 pub async fn load(store: &Store) -> Self {
289 let current = match store.get_raw_bytes(SEQ_KEY).await {
290 Ok(Some(bytes)) if bytes.len() == 8 => {
291 u64::from_be_bytes(bytes[..8].try_into().unwrap_or([0; 8]))
292 }
293 _ => 0,
294 };
295 Self { current }
296 }
297
298 pub async fn next(&mut self, store: &Store) -> Result<u64> {
303 self.current += 1;
304 store.put_raw(SEQ_KEY, &self.current.to_be_bytes()).await?;
305 Ok(self.current)
306 }
307
308 pub fn current(&self) -> u64 {
310 self.current
311 }
312}
313
314pub async fn get_or_create_installation_id(store: &Store) -> Result<String> {
323 if let Ok(Some(bytes)) = store.get_raw_bytes(INSTALLATION_ID_KEY).await {
324 if let Ok(id) = std::str::from_utf8(&bytes) {
325 if !id.is_empty() {
326 return Ok(id.to_string());
327 }
328 }
329 }
330 let id = uuid::Uuid::new_v4().to_string();
331 store.put_raw(INSTALLATION_ID_KEY, id.as_bytes()).await?;
332 Ok(id)
333}
334
335pub fn get_local_actor() -> Option<ActorLocal> {
341 let username = std::env::var("USER")
342 .or_else(|_| std::env::var("USERNAME"))
343 .ok()?;
344
345 #[cfg(unix)]
346 let uid = Some(unsafe { libc::getuid() } as u32);
347 #[cfg(not(unix))]
348 let uid = None;
349
350 Some(ActorLocal {
351 username,
352 uid,
353 verified: false,
354 })
355}
356
357pub fn canonicalize_file_key(path: &str, repo_root: &Path) -> String {
379 let abs_path = if Path::new(path).is_relative() {
381 repo_root.join(path)
382 } else {
383 PathBuf::from(path)
384 };
385
386 let normalized = normalize_components(&abs_path);
388
389 let resolved = std::fs::canonicalize(&normalized).unwrap_or(normalized);
391
392 let repo_root_canonical =
394 std::fs::canonicalize(repo_root).unwrap_or_else(|_| repo_root.to_path_buf());
395 let relative = resolved
396 .strip_prefix(&repo_root_canonical)
397 .unwrap_or(&resolved);
398
399 let mut key = relative
401 .components()
402 .map(|c| c.as_os_str().to_string_lossy().to_string())
403 .collect::<Vec<_>>()
404 .join("/");
405
406 if is_case_insensitive() {
408 key = key.to_lowercase();
409 }
410
411 key
412}
413
414fn normalize_components(path: &Path) -> PathBuf {
417 let mut components = Vec::new();
418 for component in path.components() {
419 match component {
420 Component::CurDir => {} Component::ParentDir => {
422 if matches!(components.last(), Some(Component::Normal(_))) {
424 components.pop();
425 } else {
426 components.push(component);
427 }
428 }
429 _ => components.push(component),
430 }
431 }
432 components.iter().collect()
433}
434
435fn is_case_insensitive() -> bool {
440 cfg!(target_os = "macos") || cfg!(target_os = "windows")
441}
442
443pub fn canonical_subject_hash(canonical_key: &str) -> String {
447 let mut hasher = Sha256::new();
448 hasher.update(canonical_key.as_bytes());
449 format!("{:x}", hasher.finalize())
450}
451
452fn uuid7_string() -> String {
461 uuid::Uuid::now_v7().to_string()
462}
463
464fn now_ms() -> u64 {
466 SystemTime::now()
467 .duration_since(UNIX_EPOCH)
468 .unwrap_or_default()
469 .as_millis() as u64
470}
471
472pub struct EnforcementEventWriter {
482 seq: SeqAllocator,
483 installation_id: String,
484 prev_hash: String,
485}
486
487impl EnforcementEventWriter {
488 pub async fn new(store: &Store) -> Result<Self> {
493 let seq = SeqAllocator::load(store).await;
494 let installation_id = get_or_create_installation_id(store).await?;
495 let prev_hash = Self::load_last_hash(store).await;
496
497 Ok(Self {
498 seq,
499 installation_id,
500 prev_hash,
501 })
502 }
503
504 async fn load_last_hash(store: &Store) -> String {
509 let keys = match store.scan_keys(EVENT_PREFIX).await {
512 Ok(k) => k,
513 Err(_) => return String::new(),
514 };
515
516 if keys.is_empty() {
517 return String::new();
518 }
519
520 let last_key = keys
522 .iter()
523 .max_by_key(|k| {
524 k.strip_prefix(EVENT_PREFIX)
525 .and_then(|s| s.parse::<u64>().ok())
526 .unwrap_or(0)
527 })
528 .cloned();
529
530 if let Some(key) = last_key {
531 if let Ok(Some(bytes)) = store.get_raw_bytes(&key).await {
532 if let Ok(event) = serde_json::from_slice::<EnforcementEvent>(&bytes) {
533 return event.event_hash;
534 }
535 }
536 }
537
538 String::new()
539 }
540
541 #[allow(clippy::too_many_arguments)]
548 pub async fn write(
549 &mut self,
550 store: &Store,
551 event_type: EnforcementEventType,
552 subject_kind: SubjectKind,
553 subject_key: String,
554 agent_type: String,
555 receipt_id: Option<String>,
556 decision_reason_code: String,
557 decision_basis_hash: Option<String>,
558 ) -> Result<EnforcementEvent> {
559 let seq_no = self.seq.next(store).await?;
560
561 let canonical_subject_hash_value = if subject_kind == SubjectKind::File {
562 Some(canonical_subject_hash(&subject_key))
563 } else {
564 None
565 };
566
567 let mut event = EnforcementEvent {
568 event_id: uuid7_string(),
569 schema_version: SCHEMA_VERSION,
570 seq_no,
571 recorded_at_ms: now_ms(),
572 event_type,
573 event_hash: String::new(), prev_hash: self.prev_hash.clone(),
575 installation_id: self.installation_id.clone(),
576 actor_local: get_local_actor(),
577 agent_type,
578 subject_kind,
579 subject_key,
580 canonical_subject_hash: canonical_subject_hash_value,
581 receipt_id,
582 decision_reason_code,
583 decision_basis_hash,
584 };
585
586 event.event_hash = event.compute_hash();
588
589 let key = format!("{EVENT_PREFIX}{:020}", seq_no);
591 let json = serde_json::to_vec(&event)?;
592 store.put_raw(&key, &json).await?;
593
594 self.prev_hash = event.event_hash.clone();
596
597 Ok(event)
598 }
599
600 pub fn installation_id(&self) -> &str {
602 &self.installation_id
603 }
604
605 pub fn current_seq(&self) -> u64 {
607 self.seq.current()
608 }
609
610 pub fn prev_hash(&self) -> &str {
612 &self.prev_hash
613 }
614
615 pub async fn detect_and_record_gap(
621 &mut self,
622 store: &Store,
623 gap_start_ms: u64,
624 gap_end_ms: u64,
625 cause: GapCause,
626 ) -> Result<EnforcementEvent> {
627 self.write(
628 store,
629 EnforcementEventType::RecordingGap {
630 gap_start_ms,
631 gap_end_ms,
632 cause,
633 enforcement_mode_during_gap: EnforcementMode::Advisory,
634 missed_event_count: MissedEventCount::Unknown,
635 certainty: GapCertainty::Inferred,
636 },
637 SubjectKind::System,
638 "enforcement:stream".to_string(),
639 "system".to_string(),
640 None,
641 "recording_gap_detected".to_string(),
642 None,
643 )
644 .await
645 }
646}
647
648pub async fn scan_enforcement_events(
657 store: &Store,
658 since_seq: u64,
659 until_seq: u64,
660) -> Result<Vec<EnforcementEvent>> {
661 let keys = store.scan_keys(EVENT_PREFIX).await?;
662 let mut events = Vec::new();
663
664 for key in &keys {
665 let seq = match key
666 .strip_prefix(EVENT_PREFIX)
667 .and_then(|s| s.parse::<u64>().ok())
668 {
669 Some(s) => s,
670 None => continue,
671 };
672 if seq < since_seq || seq > until_seq {
673 continue;
674 }
675 if let Ok(Some(bytes)) = store.get_raw_bytes(key).await {
676 match serde_json::from_slice::<EnforcementEvent>(&bytes) {
677 Ok(event) => events.push(event),
678 Err(e) => {
679 tracing::warn!(key, "skipping corrupt enforcement event: {e}");
680 }
681 }
682 }
683 }
684
685 events.sort_by_key(|e| e.seq_no);
686 Ok(events)
687}
688
689const ENFORCEMENT_MODE_KEY: &str = "enforcement:mode";
695
696const DEFAULT_RETENTION_DAYS: u64 = 365;
698
699const RETENTION_DAYS_KEY: &str = "enforcement:retention_days";
701
702pub async fn get_enforcement_mode(store: &Store) -> EnforcementMode {
705 match store.get_raw_bytes(ENFORCEMENT_MODE_KEY).await {
706 Ok(Some(bytes)) => match std::str::from_utf8(&bytes) {
707 Ok("strict") => EnforcementMode::Strict,
708 _ => EnforcementMode::Advisory,
709 },
710 _ => EnforcementMode::Advisory,
711 }
712}
713
714pub async fn set_enforcement_mode(store: &Store, mode: EnforcementMode) -> Result<EnforcementMode> {
717 let old = get_enforcement_mode(store).await;
718 let value = match mode {
719 EnforcementMode::Advisory => "advisory",
720 EnforcementMode::Strict => "strict",
721 };
722 store
723 .put_raw(ENFORCEMENT_MODE_KEY, value.as_bytes())
724 .await?;
725
726 if old != mode {
728 let old_str = match old {
729 EnforcementMode::Advisory => "advisory",
730 EnforcementMode::Strict => "strict",
731 };
732 let _ = record_event(
734 store,
735 EnforcementEventType::EnforcementConfigChanged {
736 setting: "enforcement.mode".to_string(),
737 old_value: old_str.to_string(),
738 new_value: value.to_string(),
739 },
740 SubjectKind::Config,
741 "enforcement:mode".to_string(),
742 "developer".to_string(),
743 None,
744 "config_changed".to_string(),
745 None,
746 )
747 .await;
748 }
749 Ok(old)
750}
751
752pub async fn get_retention_days(store: &Store) -> u64 {
754 match store.get_raw_bytes(RETENTION_DAYS_KEY).await {
755 Ok(Some(bytes)) => std::str::from_utf8(&bytes)
756 .ok()
757 .and_then(|s| s.parse::<u64>().ok())
758 .unwrap_or(DEFAULT_RETENTION_DAYS),
759 _ => DEFAULT_RETENTION_DAYS,
760 }
761}
762
763pub async fn set_retention_days(store: &Store, days: u64) -> Result<()> {
765 store
766 .put_raw(RETENTION_DAYS_KEY, days.to_string().as_bytes())
767 .await
768}
769
770pub fn compute_decision_basis_hash(gotchas: &[(String, serde_json::Value)]) -> String {
779 let mut hasher = Sha256::new();
780 for (key, record_json) in gotchas {
781 hasher.update(key.as_bytes());
782 let rule = record_json
783 .pointer("/value")
784 .and_then(|v| v.as_str())
785 .unwrap_or("");
786 hasher.update(rule.as_bytes());
787 let conf = record_json
788 .pointer("/confidence/value")
789 .and_then(|v| v.as_f64())
790 .unwrap_or(0.0);
791 hasher.update(format!("{conf}").as_bytes());
792 }
793 format!("{:x}", hasher.finalize())
794}
795
796#[allow(clippy::too_many_arguments)]
809pub async fn record_event(
810 store: &Store,
811 event_type: EnforcementEventType,
812 subject_kind: SubjectKind,
813 subject_key: String,
814 agent_type: String,
815 receipt_id: Option<String>,
816 decision_reason_code: String,
817 decision_basis_hash: Option<String>,
818) -> Result<Option<EnforcementEvent>> {
819 let mode = get_enforcement_mode(store).await;
820
821 let result = async {
822 let mut writer = EnforcementEventWriter::new(store).await?;
823 writer
824 .write(
825 store,
826 event_type,
827 subject_kind,
828 subject_key,
829 agent_type,
830 receipt_id,
831 decision_reason_code,
832 decision_basis_hash,
833 )
834 .await
835 }
836 .await;
837
838 match result {
839 Ok(event) => Ok(Some(event)),
840 Err(e) => match mode {
841 EnforcementMode::Advisory => {
842 tracing::warn!("enforcement event write failed (advisory mode): {e}");
843 Ok(None)
844 }
845 EnforcementMode::Strict => Err(e),
846 },
847 }
848}
849
850#[derive(Debug)]
856pub enum PruneResult {
857 NothingToPrune,
858 Pruned {
859 count: u64,
860 oldest_seq: u64,
861 newest_seq: u64,
862 },
863}
864
865pub async fn enforce_retention(store: &Store) -> Result<PruneResult> {
870 let retention_days = get_retention_days(store).await;
871 let cutoff_ms = now_ms().saturating_sub(retention_days * 86_400_000);
872
873 let all_events = scan_enforcement_events(store, 0, u64::MAX).await?;
874 let old_events: Vec<&EnforcementEvent> = all_events
875 .iter()
876 .filter(|e| e.recorded_at_ms < cutoff_ms)
877 .collect();
878
879 if old_events.is_empty() {
880 return Ok(PruneResult::NothingToPrune);
881 }
882
883 let count = old_events.len() as u64;
884 let oldest_seq = old_events.first().expect("checked non-empty above").seq_no;
885 let newest_seq = old_events.last().expect("checked non-empty above").seq_no;
886
887 for event in &old_events {
889 let key = format!("{EVENT_PREFIX}{:020}", event.seq_no);
890 store.delete(&key).await?;
891 }
892
893 record_event(
895 store,
896 EnforcementEventType::RetentionPruned {
897 pruned_count: count,
898 oldest_pruned_seq: oldest_seq,
899 newest_pruned_seq: newest_seq,
900 },
901 SubjectKind::System,
902 "enforcement:retention".to_string(),
903 "system".to_string(),
904 None,
905 "retention_policy_enforced".to_string(),
906 None,
907 )
908 .await?;
909
910 Ok(PruneResult::Pruned {
911 count,
912 oldest_seq,
913 newest_seq,
914 })
915}
916
917pub async fn detect_startup_gap(store: &Store, gap_threshold_ms: u64) -> Result<()> {
928 let events = scan_enforcement_events(store, 0, u64::MAX).await?;
929 if events.is_empty() {
930 return Ok(());
931 }
932 let last = events.last().expect("checked non-empty above");
933 let current = now_ms();
934 let age = current.saturating_sub(last.recorded_at_ms);
935
936 if age > gap_threshold_ms {
937 let mut writer = EnforcementEventWriter::new(store).await?;
938 writer
939 .detect_and_record_gap(store, last.recorded_at_ms, current, GapCause::Unknown)
940 .await?;
941 }
942 Ok(())
943}
944
945pub async fn scan_events_since(store: &Store, since_ms: u64) -> Result<Vec<EnforcementEvent>> {
951 let all = scan_enforcement_events(store, 0, u64::MAX).await?;
952 Ok(all
953 .into_iter()
954 .filter(|e| e.recorded_at_ms >= since_ms)
955 .collect())
956}
957
958pub async fn count_events_by_type(store: &Store, since_ms: u64) -> Result<EnforcementEventCounts> {
960 let events = scan_events_since(store, since_ms).await?;
961 let mut counts = EnforcementEventCounts {
962 total: events.len() as u64,
963 ..Default::default()
964 };
965 for e in &events {
966 match &e.event_type {
967 EnforcementEventType::Deny => counts.denials += 1,
968 EnforcementEventType::AllowAfterReceipt => counts.allowed_after_receipt += 1,
969 EnforcementEventType::ReceiptMinted => counts.receipts_minted += 1,
970 EnforcementEventType::BypassDetected => counts.bypasses += 1,
971 EnforcementEventType::ControlChanged { .. } => counts.controls_changed += 1,
972 EnforcementEventType::EnforcementConfigChanged { .. } => counts.config_changes += 1,
973 EnforcementEventType::RecordingGap { .. } => counts.gaps += 1,
974 EnforcementEventType::RetentionPruned { .. } => counts.retention_prunes += 1,
975 }
976 }
977 Ok(counts)
978}
979
980#[derive(Debug, Default)]
982pub struct EnforcementEventCounts {
983 pub total: u64,
984 pub denials: u64,
985 pub allowed_after_receipt: u64,
986 pub receipts_minted: u64,
987 pub bypasses: u64,
988 pub controls_changed: u64,
989 pub config_changes: u64,
990 pub gaps: u64,
991 pub retention_prunes: u64,
992}
993
994pub fn event_type_label(event_type: &EnforcementEventType) -> &'static str {
996 match event_type {
997 EnforcementEventType::Deny => "deny",
998 EnforcementEventType::AllowAfterReceipt => "allow_receipt",
999 EnforcementEventType::ReceiptMinted => "receipt_minted",
1000 EnforcementEventType::BypassDetected => "bypass",
1001 EnforcementEventType::ControlChanged { .. } => "control_changed",
1002 EnforcementEventType::EnforcementConfigChanged { .. } => "config_changed",
1003 EnforcementEventType::RecordingGap { .. } => "gap",
1004 EnforcementEventType::RetentionPruned { .. } => "retention_pruned",
1005 }
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010 use super::*;
1011
1012 fn frozen_test_event() -> EnforcementEvent {
1014 EnforcementEvent {
1015 event_id: "01900000-0000-7000-8000-000000000001".to_string(),
1016 schema_version: 1,
1017 seq_no: 1,
1018 recorded_at_ms: 1700000000000,
1019 event_type: EnforcementEventType::Deny,
1020 event_hash: String::new(),
1021 prev_hash: String::new(),
1022 installation_id: "test-install-id".to_string(),
1023 actor_local: Some(ActorLocal {
1024 username: "testuser".to_string(),
1025 uid: Some(1000),
1026 verified: false,
1027 }),
1028 agent_type: "claude".to_string(),
1029 subject_kind: SubjectKind::File,
1030 subject_key: "file:src/billing/charges.rs".to_string(),
1031 canonical_subject_hash: Some("abc123".to_string()),
1032 receipt_id: None,
1033 decision_reason_code: "gotcha_above_threshold".to_string(),
1034 decision_basis_hash: Some("def456".to_string()),
1035 }
1036 }
1037
1038 #[test]
1039 fn canonical_hash_is_deterministic_and_frozen() {
1040 let event = frozen_test_event();
1041 let hash = event.compute_hash();
1042
1043 assert_eq!(
1048 hash,
1049 "e8a42cb3c1c4dde12f807f46678c5d4393466a831007540a85ff84a003203e37"
1050 );
1051
1052 assert_eq!(hash, event.compute_hash());
1054 assert_eq!(hash, event.compute_hash());
1055 }
1056
1057 #[test]
1058 fn hash_changes_when_field_changes() {
1059 let mut event = frozen_test_event();
1060 let hash1 = event.compute_hash();
1061
1062 event.seq_no = 2;
1063 let hash2 = event.compute_hash();
1064
1065 assert_ne!(hash1, hash2, "changing seq_no must change the hash");
1066 }
1067
1068 #[test]
1069 fn hash_excludes_event_hash_field() {
1070 let mut event = frozen_test_event();
1071 let hash1 = event.compute_hash();
1072
1073 event.event_hash = "something_completely_different".to_string();
1075 let hash2 = event.compute_hash();
1076
1077 assert_eq!(
1078 hash1, hash2,
1079 "event_hash field must be excluded from canonical form"
1080 );
1081 }
1082
1083 #[test]
1084 fn canonical_path_aliasing_produces_same_key() {
1085 let repo_root = PathBuf::from("/tmp/test-repo");
1086
1087 let paths = [
1089 "src/billing/charges.rs",
1090 "./src/billing/charges.rs",
1091 "src/billing/../billing/charges.rs",
1092 "src/./billing/charges.rs",
1093 ];
1094
1095 let canonical_keys: Vec<String> = paths
1097 .iter()
1098 .map(|p| {
1099 let abs = repo_root.join(p);
1100 let normalized = normalize_components(&abs);
1101 let relative = normalized
1102 .strip_prefix(&repo_root)
1103 .unwrap_or(&normalized)
1104 .to_string_lossy()
1105 .replace('\\', "/");
1106 if is_case_insensitive() {
1107 relative.to_lowercase()
1108 } else {
1109 relative
1110 }
1111 })
1112 .collect();
1113
1114 for key in &canonical_keys {
1115 assert_eq!(
1116 key, &canonical_keys[0],
1117 "Path aliasing produced different keys"
1118 );
1119 }
1120
1121 assert_eq!(canonical_keys[0], "src/billing/charges.rs");
1122 }
1123
1124 #[test]
1125 fn canonical_subject_hash_is_deterministic() {
1126 let hash1 = canonical_subject_hash("src/billing/charges.rs");
1127 let hash2 = canonical_subject_hash("src/billing/charges.rs");
1128 assert_eq!(hash1, hash2);
1129
1130 let hash3 = canonical_subject_hash("src/billing/other.rs");
1131 assert_ne!(hash1, hash3);
1132 }
1133
1134 #[test]
1135 fn schema_version_is_one() {
1136 assert_eq!(SCHEMA_VERSION, 1);
1137 assert_eq!(HASH_ALGORITHM, "sha256");
1138 }
1139}