1use serde::{Deserialize, Serialize};
13use std::sync::Mutex;
14
15#[cfg(feature = "data-lineage")]
16pub mod lineage;
17
18#[cfg(feature = "data-quality")]
19pub mod data_quality;
20
21#[cfg(feature = "lineage-viz")]
22pub use lineage::{downstream_impact, upstream_trace, ImpactEdge};
23#[cfg(feature = "data-lineage")]
24pub use lineage::{
25 EdgeType, LineageDialect, LineageEdge, LineageError, LineageExportFormat, LineageGraph,
26 LineageNode, LineageNodeId, LineageTracker, LineageUpdate, NodeType,
27};
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct SqlAuditContext {
31 pub sql: String,
32 pub user: String,
33 pub timestamp: i64,
34}
35
36const SENSITIVE_KEYWORDS: &[&str] = &[
39 "password",
40 "pwd",
41 "passwd",
42 "secret",
43 "token",
44 "api_key",
45 "apikey",
46 "access_key",
47 "accesskey",
48 "session",
49 "credit_card",
50 "creditcard",
51 "cvv",
52 "ssn",
53];
54
55pub struct SqlAuditor {
56 logs: Mutex<Vec<SqlAuditContext>>,
57}
58
59impl SqlAuditor {
60 pub fn new() -> Self {
61 Self {
62 logs: Mutex::new(vec![]),
63 }
64 }
65
66 pub fn log(&self, ctx: &SqlAuditContext) {
69 let masked_sql = mask_sensitive(&ctx.sql);
70 let entry = SqlAuditContext {
71 sql: masked_sql,
72 user: ctx.user.clone(),
73 timestamp: ctx.timestamp,
74 };
75 let mut logs = self
76 .logs
77 .lock()
78 .expect("SqlAuditor logs lock poisoned (log)");
79 logs.push(entry);
80 }
81
82 pub fn get_logs(&self) -> Vec<SqlAuditContext> {
84 let logs = self
85 .logs
86 .lock()
87 .expect("SqlAuditor logs lock poisoned (get_logs)");
88 logs.iter().cloned().collect()
89 }
90
91 pub fn flush(&self, path: &str) -> Result<usize, String> {
94 let logs = self
95 .logs
96 .lock()
97 .expect("SqlAuditor logs lock poisoned (flush)");
98 let snapshot: Vec<&SqlAuditContext> = logs.iter().collect();
99 let json = serde_json::to_string_pretty(&snapshot).map_err(|e| e.to_string())?;
100 std::fs::write(path, json).map_err(|e| e.to_string())?;
101 Ok(logs.len())
102 }
103
104 pub fn mask_sensitive(&self, sql: &str) -> String {
107 mask_sensitive(sql)
108 }
109}
110
111impl Default for SqlAuditor {
112 fn default() -> Self {
113 Self::new()
114 }
115}
116
117fn mask_sensitive(sql: &str) -> String {
120 let lower = sql.to_ascii_lowercase();
121 let mut result = String::with_capacity(sql.len());
122 let mut i = 0;
123 let bytes = sql.as_bytes();
124 let lower_bytes = lower.as_bytes();
125 while i < bytes.len() {
126 let mut matched_len: Option<usize> = None;
127 for keyword in SENSITIVE_KEYWORDS {
128 let kw_bytes = keyword.as_bytes();
129 if i + kw_bytes.len() <= bytes.len() && &lower_bytes[i..i + kw_bytes.len()] == kw_bytes
130 {
131 let prev_ok = i == 0 || !is_ident_char(bytes[i - 1]);
134 let next_idx = i + kw_bytes.len();
135 let next_ok = next_idx >= bytes.len() || !is_ident_char(bytes[next_idx]);
136 if prev_ok && next_ok {
137 matched_len = Some(kw_bytes.len());
138 break;
139 }
140 }
141 }
142 if let Some(kw_len) = matched_len {
143 result.push_str("******");
144 i += kw_len;
145 } else {
146 let ch = sql[i..]
148 .chars()
149 .next()
150 .expect("i < bytes.len() guarantees non-empty slice");
151 result.push(ch);
152 i += ch.len_utf8();
153 }
154 }
155 result
156}
157
158fn is_ident_char(b: u8) -> bool {
160 b.is_ascii_alphanumeric() || b == b'_'
161}
162
163#[derive(Debug, Clone, Default)]
175pub struct AuditRules {
176 allow_patterns: Vec<String>,
178 deny_patterns: Vec<String>,
180}
181
182impl AuditRules {
183 pub fn new() -> Self {
184 Self::default()
185 }
186
187 pub fn allow(mut self, pattern: impl Into<String>) -> Self {
189 self.allow_patterns
190 .push(pattern.into().to_ascii_lowercase());
191 self
192 }
193
194 pub fn deny(mut self, pattern: impl Into<String>) -> Self {
196 self.deny_patterns.push(pattern.into().to_ascii_lowercase());
197 self
198 }
199
200 pub fn should_audit(&self, sql: &str) -> bool {
202 let lower = sql.to_ascii_lowercase();
203 for pat in &self.deny_patterns {
205 if lower.contains(pat) {
206 return false;
207 }
208 }
209 if self.allow_patterns.is_empty() {
211 return true;
212 }
213 self.allow_patterns.iter().any(|pat| lower.contains(pat))
215 }
216
217 pub fn allow_count(&self) -> usize {
219 self.allow_patterns.len()
220 }
221
222 pub fn deny_count(&self) -> usize {
224 self.deny_patterns.len()
225 }
226}
227
228#[derive(Debug, Clone)]
240pub struct RotationPolicy {
241 pub max_entries: usize,
243 pub max_age_ms: i64,
245}
246
247impl RotationPolicy {
248 pub fn none() -> Self {
250 Self {
251 max_entries: 0,
252 max_age_ms: 0,
253 }
254 }
255
256 pub fn by_size(max_entries: usize) -> Self {
258 Self {
259 max_entries,
260 max_age_ms: 0,
261 }
262 }
263
264 pub fn by_age(max_age_ms: i64) -> Self {
266 Self {
267 max_entries: 0,
268 max_age_ms,
269 }
270 }
271
272 pub fn by_size_and_age(max_entries: usize, max_age_ms: i64) -> Self {
274 Self {
275 max_entries,
276 max_age_ms,
277 }
278 }
279
280 fn needs_rotation(&self, entry_count: usize, oldest_ts: i64, now_ts: i64) -> bool {
282 if self.max_entries > 0 && entry_count >= self.max_entries {
283 return true;
284 }
285 if self.max_age_ms > 0 && oldest_ts > 0 && (now_ts - oldest_ts) > self.max_age_ms {
286 return true;
287 }
288 false
289 }
290}
291
292impl Default for RotationPolicy {
293 fn default() -> Self {
294 Self::none()
295 }
296}
297
298pub struct RotatingAuditor {
308 logs: Mutex<Vec<SqlAuditContext>>,
309 rules: AuditRules,
310 policy: RotationPolicy,
311 rotations: Mutex<usize>,
313}
314
315impl RotatingAuditor {
316 pub fn new(policy: RotationPolicy, rules: AuditRules) -> Self {
317 Self {
318 logs: Mutex::new(vec![]),
319 rules,
320 policy,
321 rotations: Mutex::new(0),
322 }
323 }
324
325 pub fn with_max_entries(max_entries: usize) -> Self {
327 Self::new(RotationPolicy::by_size(max_entries), AuditRules::new())
328 }
329
330 pub fn with_max_age(max_age_ms: i64) -> Self {
332 Self::new(RotationPolicy::by_age(max_age_ms), AuditRules::new())
333 }
334
335 pub fn log(&self, ctx: &SqlAuditContext) -> bool {
337 if !self.rules.should_audit(&ctx.sql) {
339 return false;
340 }
341 let masked_sql = mask_sensitive(&ctx.sql);
342 let entry = SqlAuditContext {
343 sql: masked_sql,
344 user: ctx.user.clone(),
345 timestamp: ctx.timestamp,
346 };
347 let mut logs = self
348 .logs
349 .lock()
350 .expect("RotatingAuditor logs lock poisoned (log)");
351
352 let now = ctx.timestamp;
354 let oldest = logs.first().map(|e| e.timestamp).unwrap_or(now);
355 if self.policy.needs_rotation(logs.len(), oldest, now) {
356 logs.clear();
357 *self
358 .rotations
359 .lock()
360 .expect("RotatingAuditor rotations lock poisoned (log)") += 1;
361 }
362
363 logs.push(entry);
364 true
365 }
366
367 pub fn get_logs(&self) -> Vec<SqlAuditContext> {
369 self.logs
370 .lock()
371 .expect("RotatingAuditor logs lock poisoned (get_logs)")
372 .clone()
373 }
374
375 pub fn rotation_count(&self) -> usize {
377 *self
378 .rotations
379 .lock()
380 .expect("RotatingAuditor rotations lock poisoned (rotation_count)")
381 }
382
383 pub fn rotate(&self) -> usize {
385 let mut logs = self
386 .logs
387 .lock()
388 .expect("RotatingAuditor logs lock poisoned (rotate)");
389 let count = logs.len();
390 logs.clear();
391 *self
392 .rotations
393 .lock()
394 .expect("RotatingAuditor rotations lock poisoned (rotate)") += 1;
395 count
396 }
397
398 pub fn len(&self) -> usize {
400 self.logs
401 .lock()
402 .expect("RotatingAuditor logs lock poisoned (len)")
403 .len()
404 }
405
406 pub fn is_empty(&self) -> bool {
408 self.logs
409 .lock()
410 .expect("RotatingAuditor logs lock poisoned (is_empty)")
411 .is_empty()
412 }
413}
414
415pub struct AsyncAuditWriter {
426 sender: std::sync::mpsc::Sender<AsyncCommand>,
427 handle: Mutex<Option<std::thread::JoinHandle<Vec<SqlAuditContext>>>>,
428}
429
430enum AsyncCommand {
431 Log(SqlAuditContext),
432 Shutdown,
433}
434
435impl AsyncAuditWriter {
436 pub fn new() -> Self {
438 let (sender, receiver) = std::sync::mpsc::channel::<AsyncCommand>();
439 let handle = std::thread::spawn(move || {
440 let mut logs: Vec<SqlAuditContext> = Vec::new();
441 for cmd in receiver {
442 match cmd {
443 AsyncCommand::Log(ctx) => {
444 let masked_sql = mask_sensitive(&ctx.sql);
445 logs.push(SqlAuditContext {
446 sql: masked_sql,
447 user: ctx.user,
448 timestamp: ctx.timestamp,
449 });
450 }
451 AsyncCommand::Shutdown => break,
452 }
453 }
454 logs
455 });
456 Self {
457 sender,
458 handle: Mutex::new(Some(handle)),
459 }
460 }
461
462 pub fn log(&self, ctx: &SqlAuditContext) -> Result<(), String> {
464 self.sender
465 .send(AsyncCommand::Log(ctx.clone()))
466 .map_err(|e| format!("AsyncAuditWriter channel closed: {}", e))
467 }
468
469 pub fn shutdown(&self) -> Result<Vec<SqlAuditContext>, String> {
471 let _ = self.sender.send(AsyncCommand::Shutdown);
472 let mut handle_guard = self
473 .handle
474 .lock()
475 .expect("AsyncAuditWriter handle lock poisoned (shutdown)");
476 if let Some(handle) = handle_guard.take() {
477 handle
478 .join()
479 .map_err(|e| format!("Thread panicked: {:?}", e))
480 } else {
481 Err("Already shut down".to_string())
482 }
483 }
484}
485
486impl Default for AsyncAuditWriter {
487 fn default() -> Self {
488 Self::new()
489 }
490}
491
492#[derive(Debug, Clone, Default)]
498pub struct AuditQuery {
499 pub user: Option<String>,
501 pub from_ts: Option<i64>,
503 pub to_ts: Option<i64>,
505 pub sql_contains: Option<String>,
507 pub limit: usize,
509}
510
511impl AuditQuery {
512 pub fn new() -> Self {
513 Self::default()
514 }
515
516 pub fn by_user(mut self, user: impl Into<String>) -> Self {
518 self.user = Some(user.into());
519 self
520 }
521
522 pub fn by_time_range(mut self, from: i64, to: i64) -> Self {
524 self.from_ts = Some(from);
525 self.to_ts = Some(to);
526 self
527 }
528
529 pub fn by_sql_contains(mut self, keyword: impl Into<String>) -> Self {
531 self.sql_contains = Some(keyword.into());
532 self
533 }
534
535 pub fn with_limit(mut self, limit: usize) -> Self {
537 self.limit = limit;
538 self
539 }
540
541 pub fn filter(&self, logs: &[SqlAuditContext]) -> Vec<SqlAuditContext> {
543 let keyword_lower = self.sql_contains.as_ref().map(|s| s.to_ascii_lowercase());
544 let mut result: Vec<SqlAuditContext> = logs
545 .iter()
546 .filter(|entry| {
547 if let Some(u) = &self.user {
548 if entry.user != *u {
549 return false;
550 }
551 }
552 if let Some(from) = self.from_ts {
553 if entry.timestamp < from {
554 return false;
555 }
556 }
557 if let Some(to) = self.to_ts {
558 if entry.timestamp > to {
559 return false;
560 }
561 }
562 if let Some(kw) = &keyword_lower {
563 if !entry.sql.to_ascii_lowercase().contains(kw) {
564 return false;
565 }
566 }
567 true
568 })
569 .cloned()
570 .collect();
571 if self.limit > 0 && result.len() > self.limit {
572 result.truncate(self.limit);
573 }
574 result
575 }
576}
577
578pub fn query_logs(auditor: &SqlAuditor, query: &AuditQuery) -> Vec<SqlAuditContext> {
580 let logs = auditor.get_logs();
581 query.filter(&logs)
582}
583
584pub trait AuditLogStore: Send + Sync {
595 fn append(&self, entry: &SqlAuditContext) -> Result<(), String>;
597 fn read_all(&self) -> Result<Vec<SqlAuditContext>, String>;
599 fn clear(&self) -> Result<(), String>;
601}
602
603pub struct FileAuditLogStore {
610 path: String,
611 write_lock: Mutex<()>,
612}
613
614impl FileAuditLogStore {
615 pub fn new(path: impl Into<String>) -> Self {
618 Self {
619 path: path.into(),
620 write_lock: Mutex::new(()),
621 }
622 }
623
624 pub fn path(&self) -> &str {
626 &self.path
627 }
628}
629
630impl AuditLogStore for FileAuditLogStore {
631 fn append(&self, entry: &SqlAuditContext) -> Result<(), String> {
633 let _guard = self
634 .write_lock
635 .lock()
636 .map_err(|e| format!("write_lock poisoned: {}", e))?;
637 let masked_sql = mask_sensitive(&entry.sql);
639 let stored = SqlAuditContext {
640 sql: masked_sql,
641 user: entry.user.clone(),
642 timestamp: entry.timestamp,
643 };
644 let line = serde_json::to_string(&stored).map_err(|e| e.to_string())?;
645 use std::io::Write;
647 let mut file = std::fs::OpenOptions::new()
648 .create(true)
649 .append(true)
650 .open(&self.path)
651 .map_err(|e| format!("open '{}' failed: {}", self.path, e))?;
652 writeln!(file, "{}", line).map_err(|e| e.to_string())
653 }
654
655 fn read_all(&self) -> Result<Vec<SqlAuditContext>, String> {
660 let content = match std::fs::read_to_string(&self.path) {
661 Ok(c) => c,
662 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
663 return Ok(Vec::new());
664 }
665 Err(e) => return Err(format!("read failed: {}", e)),
666 };
667 let mut result = Vec::new();
668 for (lineno, line) in content.lines().enumerate() {
669 let line = line.trim();
670 if line.is_empty() {
671 continue;
672 }
673 let entry: SqlAuditContext = serde_json::from_str(line)
674 .map_err(|e| format!("parse line {} failed: {}", lineno + 1, e))?;
675 result.push(entry);
676 }
677 Ok(result)
678 }
679
680 fn clear(&self) -> Result<(), String> {
682 let _guard = self
683 .write_lock
684 .lock()
685 .map_err(|e| format!("write_lock poisoned: {}", e))?;
686 std::fs::remove_file(&self.path).or_else(|e| {
687 if e.kind() == std::io::ErrorKind::NotFound {
689 Ok(())
690 } else {
691 Err(format!("clear failed: {}", e))
692 }
693 })
694 }
695}
696
697pub const GENESIS_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000";
707
708#[derive(Debug, Clone, Serialize, Deserialize)]
720pub struct HashChainEntry {
721 pub prev_hash: String,
723 pub current_hash: String,
725 pub entry: SqlAuditContext,
727}
728
729impl HashChainEntry {
730 fn compute_hash(prev_hash: &str, entry: &SqlAuditContext) -> String {
738 use sha2::{Digest, Sha256};
739 let mut hasher = Sha256::new();
740 hasher.update(prev_hash.as_bytes());
741 hasher.update(entry.sql.as_bytes());
742 hasher.update(entry.user.as_bytes());
743 hasher.update(entry.timestamp.to_le_bytes());
745 let result = hasher.finalize();
746 hex_encode(&result)
748 }
749
750 pub fn genesis(entry: SqlAuditContext) -> Self {
752 let prev_hash = GENESIS_HASH.to_string();
753 let current_hash = Self::compute_hash(&prev_hash, &entry);
754 Self {
755 prev_hash,
756 current_hash,
757 entry,
758 }
759 }
760
761 pub fn append(prev_hash: &str, entry: SqlAuditContext) -> Self {
763 let current_hash = Self::compute_hash(prev_hash, &entry);
764 Self {
765 prev_hash: prev_hash.to_string(),
766 current_hash,
767 entry,
768 }
769 }
770}
771
772fn hex_encode(bytes: &[u8]) -> String {
777 const HEX_CHARS: &[u8] = b"0123456789abcdef";
778 let mut s = String::with_capacity(bytes.len() * 2);
779 for &b in bytes {
780 s.push(HEX_CHARS[(b >> 4) as usize] as char);
781 s.push(HEX_CHARS[(b & 0x0f) as usize] as char);
782 }
783 s
784}
785
786pub struct HashChainAuditor {
814 entries: Mutex<Vec<HashChainEntry>>,
816}
817
818impl Default for HashChainAuditor {
819 fn default() -> Self {
820 Self::new()
821 }
822}
823
824impl HashChainAuditor {
825 pub fn new() -> Self {
827 Self {
828 entries: Mutex::new(Vec::new()),
829 }
830 }
831
832 pub fn log(&self, ctx: &SqlAuditContext) {
841 let masked_sql = mask_sensitive(&ctx.sql);
842 let entry = SqlAuditContext {
843 sql: masked_sql,
844 user: ctx.user.clone(),
845 timestamp: ctx.timestamp,
846 };
847 let mut entries = self
848 .entries
849 .lock()
850 .expect("HashChainAuditor entries lock poisoned (log)");
851 let prev_hash = entries
852 .last()
853 .map(|e| e.current_hash.as_str())
854 .unwrap_or(GENESIS_HASH);
855 let chain_entry = if entries.is_empty() {
856 HashChainEntry::genesis(entry)
857 } else {
858 HashChainEntry::append(prev_hash, entry)
859 };
860 entries.push(chain_entry);
861 }
862
863 pub fn get_entries(&self) -> Vec<HashChainEntry> {
865 self.entries
866 .lock()
867 .expect("HashChainAuditor entries lock poisoned (get_entries)")
868 .clone()
869 }
870
871 pub fn len(&self) -> usize {
873 self.entries
874 .lock()
875 .expect("HashChainAuditor entries lock poisoned (len)")
876 .len()
877 }
878
879 pub fn is_empty(&self) -> bool {
881 self.entries
882 .lock()
883 .expect("HashChainAuditor entries lock poisoned (is_empty)")
884 .is_empty()
885 }
886
887 pub fn verify(&self) -> Result<(), String> {
902 let entries = self
903 .entries
904 .lock()
905 .expect("HashChainAuditor entries lock poisoned (verify)");
906 for (i, entry) in entries.iter().enumerate() {
907 if i == 0 {
909 if entry.prev_hash != GENESIS_HASH {
910 return Err(format!(
911 "chain genesis prev_hash mismatch at index 0: expected '{}', got '{}'",
912 GENESIS_HASH, entry.prev_hash
913 ));
914 }
915 } else {
916 let prev = &entries[i - 1];
918 if entry.prev_hash != prev.current_hash {
919 return Err(format!(
920 "chain broken at index {}: prev_hash '{}' != previous current_hash '{}'",
921 i, entry.prev_hash, prev.current_hash
922 ));
923 }
924 }
925 let recomputed = HashChainEntry::compute_hash(&entry.prev_hash, &entry.entry);
927 if entry.current_hash != recomputed {
928 return Err(format!(
929 "hash mismatch at index {}: stored '{}' != recomputed '{}'",
930 i, entry.current_hash, recomputed
931 ));
932 }
933 }
934 Ok(())
935 }
936
937 pub fn flush(&self, path: &str) -> Result<usize, String> {
941 let entries = self
942 .entries
943 .lock()
944 .expect("HashChainAuditor entries lock poisoned (flush)");
945 let snapshot: Vec<&HashChainEntry> = entries.iter().collect();
946 let json = serde_json::to_string_pretty(&snapshot).map_err(|e| e.to_string())?;
947 std::fs::write(path, json).map_err(|e| e.to_string())?;
948 Ok(entries.len())
949 }
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955
956 fn test_data_dir() -> std::path::PathBuf {
964 let f_drive = std::path::Path::new("F:\\test\\data");
965 if is_dir_writable(f_drive) {
966 return f_drive.to_path_buf();
967 }
968 if let Ok(dir) = std::env::var("SZ_ORM_TEST_DATA_DIR") {
969 let p = std::path::PathBuf::from(&dir);
970 if is_dir_writable(&p) {
971 return p;
972 }
973 }
974 std::env::temp_dir()
975 }
976
977 fn is_dir_writable(dir: &std::path::Path) -> bool {
980 if !dir.exists() {
981 return false;
982 }
983 let probe = dir.join(format!(".probe_{}", std::process::id()));
984 match std::fs::File::create(&probe) {
985 Ok(_) => {
986 let _ = std::fs::remove_file(&probe);
987 true
988 }
989 Err(_) => false,
990 }
991 }
992
993 fn ctx(sql: &str, user: &str, ts: i64) -> SqlAuditContext {
994 SqlAuditContext {
995 sql: sql.to_string(),
996 user: user.to_string(),
997 timestamp: ts,
998 }
999 }
1000
1001 #[test]
1002 fn test_log_stores_in_memory() {
1003 let a = SqlAuditor::new();
1004 a.log(&ctx("SELECT * FROM users", "admin", 1000));
1005 a.log(&ctx("INSERT INTO logs VALUES(1)", "admin", 1001));
1006 let logs = a.get_logs();
1007 assert_eq!(logs.len(), 2);
1008 assert_eq!(logs[0].sql, "SELECT * FROM users");
1009 assert_eq!(logs[0].user, "admin");
1010 assert_eq!(logs[0].timestamp, 1000);
1011 assert_eq!(logs[1].timestamp, 1001);
1012 }
1013
1014 #[test]
1015 fn test_log_masks_sensitive_in_storage() {
1016 let a = SqlAuditor::new();
1017 a.log(&ctx(
1018 "SELECT * FROM users WHERE password='secret'",
1019 "admin",
1020 1000,
1021 ));
1022 let logs = a.get_logs();
1023 assert_eq!(logs.len(), 1);
1024 let stored_sql = &logs[0].sql;
1025 assert!(!stored_sql.contains("password"));
1026 assert!(!stored_sql.contains("secret"));
1027 assert!(stored_sql.contains("******"));
1028 }
1029
1030 #[test]
1031 fn test_mask_sensitive_password() {
1032 let a = SqlAuditor::new();
1033 let masked = a.mask_sensitive("SELECT * FROM users WHERE password='secret'");
1034 assert!(!masked.contains("password"));
1035 assert!(!masked.contains("secret"));
1036 assert!(masked.contains("******"));
1037 }
1038
1039 #[test]
1040 fn test_mask_sensitive_case_insensitive() {
1041 let a = SqlAuditor::new();
1042 let masked = a.mask_sensitive("UPDATE users SET PASSWORD='abc', Token='x'");
1043 let lower = masked.to_lowercase();
1044 assert!(!lower.contains("password"));
1045 assert!(!lower.contains("token"));
1046 assert!(masked.contains("******"));
1047 }
1048
1049 #[test]
1050 fn test_mask_sensitive_extended_keywords() {
1051 let a = SqlAuditor::new();
1052 let inputs = [
1053 "pwd",
1054 "passwd",
1055 "secret",
1056 "api_key",
1057 "access_key",
1058 "session",
1059 "credit_card",
1060 "cvv",
1061 "ssn",
1062 ];
1063 for kw in inputs {
1064 let sql = format!("SELECT * FROM t WHERE k = '{}'", kw);
1065 let masked = a.mask_sensitive(&sql);
1066 let lower = masked.to_lowercase();
1067 assert!(
1068 !lower.contains(kw),
1069 "keyword '{}' should be masked in: {}",
1070 kw,
1071 masked
1072 );
1073 assert!(masked.contains("******"));
1074 }
1075 }
1076
1077 #[test]
1078 fn test_mask_sensitive_preserves_non_sensitive() {
1079 let a = SqlAuditor::new();
1080 let masked = a.mask_sensitive("SELECT id, name FROM users WHERE active = 1");
1081 assert_eq!(masked, "SELECT id, name FROM users WHERE active = 1");
1082 }
1083
1084 #[test]
1085 fn test_mask_sensitive_does_not_match_substrings() {
1086 let a = SqlAuditor::new();
1089 let masked = a.mask_sensitive("SELECT * FROM users WHERE note='passworded'");
1090 assert!(masked.contains("passworded"));
1092 assert_eq!(masked, "SELECT * FROM users WHERE note='passworded'");
1098 }
1099
1100 #[test]
1101 fn test_mask_sensitive_multiple_occurrences() {
1102 let a = SqlAuditor::new();
1103 let masked = a.mask_sensitive("INSERT INTO t (password, token) VALUES ('p1', 't1')");
1104 let lower = masked.to_lowercase();
1106 assert!(!lower.contains("password"));
1107 assert!(!lower.contains("token"));
1108 let count = masked.matches("******").count();
1110 assert!(count >= 2, "expected at least 2 masks, got: {}", masked);
1111 }
1112
1113 #[test]
1114 fn test_get_logs_empty_initially() {
1115 let a = SqlAuditor::new();
1116 assert!(a.get_logs().is_empty());
1117 }
1118
1119 #[test]
1120 fn test_get_logs_returns_snapshot_independent_of_changes() {
1121 let a = SqlAuditor::new();
1122 a.log(&ctx("SELECT 1", "u", 1));
1123 let snap = a.get_logs();
1124 a.log(&ctx("SELECT 2", "u", 2));
1125 assert_eq!(snap.len(), 1, "snapshot should not change after new log");
1126 assert_eq!(a.get_logs().len(), 2);
1127 }
1128
1129 #[test]
1130 fn test_flush_writes_json_file() {
1131 let a = SqlAuditor::new();
1132 a.log(&ctx("SELECT * FROM users WHERE password='p'", "admin", 123));
1133 a.log(&ctx("INSERT INTO logs VALUES(1)", "user2", 456));
1134 let path = test_data_dir().join("sz_orm_audit_flush_test.json");
1135 let path_str = path.to_str().unwrap();
1136 let count = a.flush(path_str).expect("flush should succeed");
1137 assert_eq!(count, 2);
1138 let content = std::fs::read_to_string(path_str).expect("file should be readable");
1140 let parsed: Vec<SqlAuditContext> =
1141 serde_json::from_str(&content).expect("should parse as JSON array");
1142 assert_eq!(parsed.len(), 2);
1143 assert_eq!(parsed[0].user, "admin");
1144 assert_eq!(parsed[1].timestamp, 456);
1145 assert!(!parsed[0].sql.contains("password"));
1147 let _ = std::fs::remove_file(path_str);
1149 }
1150
1151 #[test]
1152 fn test_flush_empty_writes_empty_array() {
1153 let a = SqlAuditor::new();
1154 let path = test_data_dir().join("sz_orm_audit_flush_empty_test.json");
1155 let path_str = path.to_str().unwrap();
1156 let count = a.flush(path_str).expect("flush should succeed");
1157 assert_eq!(count, 0);
1158 let content = std::fs::read_to_string(path_str).expect("file should be readable");
1159 assert_eq!(content.trim(), "[]");
1160 let _ = std::fs::remove_file(path_str);
1161 }
1162
1163 #[test]
1164 fn test_default_creates_new_auditor() {
1165 let a = SqlAuditor::default();
1166 assert!(a.get_logs().is_empty());
1167 }
1168
1169 #[test]
1170 fn test_original_test_compatibility() {
1171 let a = SqlAuditor::new();
1174 let masked = a.mask_sensitive("SELECT * FROM users WHERE password='secret'");
1175 assert!(!masked.contains("password"));
1176 }
1177
1178 #[test]
1181 fn test_audit_rules_empty_allows_all() {
1182 let rules = AuditRules::new();
1183 assert!(rules.should_audit("SELECT * FROM users"));
1184 assert!(rules.should_audit("DELETE FROM orders"));
1185 assert_eq!(rules.allow_count(), 0);
1186 assert_eq!(rules.deny_count(), 0);
1187 }
1188
1189 #[test]
1190 fn test_audit_rules_deny_blocks() {
1191 let rules = AuditRules::new().deny("pg_catalog");
1192 assert!(!rules.should_audit("SELECT * FROM pg_catalog.tables"));
1193 assert!(rules.should_audit("SELECT * FROM users"));
1194 }
1195
1196 #[test]
1197 fn test_audit_rules_allow_filters() {
1198 let rules = AuditRules::new().allow("select").allow("insert");
1199 assert!(rules.should_audit("SELECT * FROM users"));
1200 assert!(rules.should_audit("INSERT INTO logs VALUES(1)"));
1201 assert!(!rules.should_audit("DELETE FROM users"));
1202 }
1203
1204 #[test]
1205 fn test_audit_rules_deny_overrides_allow() {
1206 let rules = AuditRules::new().allow("select").deny("password");
1207 assert!(!rules.should_audit("SELECT * FROM users WHERE password='x'"));
1209 assert!(rules.should_audit("SELECT * FROM users"));
1211 }
1212
1213 #[test]
1214 fn test_audit_rules_case_insensitive() {
1215 let rules = AuditRules::new().deny("DROP");
1216 assert!(!rules.should_audit("drop table users"));
1217 assert!(!rules.should_audit("DROP TABLE users"));
1218 assert!(rules.should_audit("SELECT * FROM users"));
1219 }
1220
1221 #[test]
1222 fn test_audit_rules_multiple_deny() {
1223 let rules = AuditRules::new()
1224 .deny("drop")
1225 .deny("truncate")
1226 .deny("shutdown");
1227 assert!(!rules.should_audit("DROP TABLE x"));
1228 assert!(!rules.should_audit("TRUNCATE TABLE y"));
1229 assert!(!rules.should_audit("SHUTDOWN"));
1230 assert!(rules.should_audit("SELECT 1"));
1231 }
1232
1233 #[test]
1236 fn test_rotation_policy_none_never_rotates() {
1237 let policy = RotationPolicy::none();
1238 assert!(!policy.needs_rotation(1_000_000, 0, 1_000_000));
1239 assert!(!policy.needs_rotation(0, 0, 0));
1240 }
1241
1242 #[test]
1243 fn test_rotation_policy_by_size() {
1244 let policy = RotationPolicy::by_size(100);
1245 assert!(!policy.needs_rotation(99, 0, 1000));
1246 assert!(policy.needs_rotation(100, 0, 1000));
1247 assert!(policy.needs_rotation(200, 0, 1000));
1248 }
1249
1250 #[test]
1251 fn test_rotation_policy_by_age() {
1252 let policy = RotationPolicy::by_age(5000);
1253 assert!(!policy.needs_rotation(10, 5000, 9000));
1255 assert!(policy.needs_rotation(10, 5000, 11000));
1257 }
1258
1259 #[test]
1260 fn test_rotation_policy_by_size_and_age() {
1261 let policy = RotationPolicy::by_size_and_age(100, 5000);
1262 assert!(!policy.needs_rotation(50, 5000, 9000));
1264 assert!(policy.needs_rotation(100, 5000, 5000));
1266 assert!(policy.needs_rotation(10, 5000, 11000));
1268 }
1269
1270 #[test]
1273 fn test_rotating_auditor_no_rotation_stores_all() {
1274 let auditor = RotatingAuditor::new(RotationPolicy::none(), AuditRules::new());
1275 for i in 0..100 {
1276 auditor.log(&ctx(&format!("SELECT {}", i), "user", i));
1277 }
1278 assert_eq!(auditor.len(), 100);
1279 assert_eq!(auditor.rotation_count(), 0);
1280 }
1281
1282 #[test]
1283 fn test_rotating_auditor_rotates_by_size() {
1284 let auditor = RotatingAuditor::with_max_entries(5);
1285 for i in 0..5 {
1286 auditor.log(&ctx(&format!("SELECT {}", i), "user", i));
1287 }
1288 assert_eq!(auditor.len(), 5);
1289 assert_eq!(auditor.rotation_count(), 0);
1290 auditor.log(&ctx("SELECT 6", "user", 100));
1292 assert_eq!(auditor.len(), 1);
1293 assert_eq!(auditor.rotation_count(), 1);
1294 }
1295
1296 #[test]
1297 fn test_rotating_auditor_rotates_by_age() {
1298 let auditor = RotatingAuditor::with_max_age(1000);
1299 auditor.log(&ctx("SELECT 1", "user", 100));
1300 auditor.log(&ctx("SELECT 2", "user", 200));
1301 assert_eq!(auditor.len(), 2);
1302 assert_eq!(auditor.rotation_count(), 0);
1303 auditor.log(&ctx("SELECT 3", "user", 1500));
1305 assert_eq!(auditor.len(), 1);
1306 assert_eq!(auditor.rotation_count(), 1);
1307 }
1308
1309 #[test]
1310 fn test_rotating_auditor_rules_filter() {
1311 let rules = AuditRules::new().deny("drop").allow("select");
1312 let auditor = RotatingAuditor::new(RotationPolicy::none(), rules);
1313 let logged1 = auditor.log(&ctx("SELECT * FROM users", "u", 1));
1314 let logged2 = auditor.log(&ctx("DROP TABLE users", "u", 2));
1315 let logged3 = auditor.log(&ctx("DELETE FROM users", "u", 3));
1316 assert!(logged1);
1317 assert!(!logged2);
1318 assert!(!logged3);
1319 assert_eq!(auditor.len(), 1);
1320 }
1321
1322 #[test]
1323 fn test_rotating_auditor_manual_rotate() {
1324 let auditor = RotatingAuditor::with_max_entries(100);
1325 auditor.log(&ctx("SELECT 1", "u", 1));
1326 auditor.log(&ctx("SELECT 2", "u", 2));
1327 let cleared = auditor.rotate();
1328 assert_eq!(cleared, 2);
1329 assert!(auditor.is_empty());
1330 assert_eq!(auditor.rotation_count(), 1);
1331 }
1332
1333 #[test]
1334 fn test_rotating_auditor_masks_sensitive() {
1335 let auditor = RotatingAuditor::with_max_entries(100);
1336 auditor.log(&ctx("SELECT * FROM users WHERE password='x'", "u", 1));
1337 let logs = auditor.get_logs();
1338 assert_eq!(logs.len(), 1);
1339 assert!(!logs[0].sql.contains("password"));
1340 assert!(logs[0].sql.contains("******"));
1341 }
1342
1343 #[test]
1344 fn test_rotating_auditor_get_logs_snapshot() {
1345 let auditor = RotatingAuditor::with_max_entries(100);
1346 auditor.log(&ctx("SELECT 1", "u", 1));
1347 let snap = auditor.get_logs();
1348 auditor.log(&ctx("SELECT 2", "u", 2));
1349 assert_eq!(snap.len(), 1, "snapshot should be independent");
1350 assert_eq!(auditor.len(), 2);
1351 }
1352
1353 #[test]
1356 fn test_async_writer_log_and_shutdown() {
1357 let writer = AsyncAuditWriter::new();
1358 writer
1359 .log(&ctx("SELECT * FROM users", "admin", 1000))
1360 .unwrap();
1361 writer
1362 .log(&ctx("INSERT INTO logs VALUES(1)", "user2", 2000))
1363 .unwrap();
1364 let logs = writer.shutdown().expect("shutdown should succeed");
1365 assert_eq!(logs.len(), 2);
1366 assert_eq!(logs[0].user, "admin");
1367 assert_eq!(logs[1].timestamp, 2000);
1368 }
1369
1370 #[test]
1371 fn test_async_writer_masks_sensitive() {
1372 let writer = AsyncAuditWriter::new();
1373 writer
1374 .log(&ctx("SELECT * FROM users WHERE password='secret'", "u", 1))
1375 .unwrap();
1376 let logs = writer.shutdown().unwrap();
1377 assert_eq!(logs.len(), 1);
1378 assert!(!logs[0].sql.contains("password"));
1379 }
1380
1381 #[test]
1382 fn test_async_writer_empty_shutdown() {
1383 let writer = AsyncAuditWriter::new();
1384 let logs = writer.shutdown().expect("shutdown should succeed");
1385 assert!(logs.is_empty());
1386 }
1387
1388 #[test]
1389 fn test_async_writer_double_shutdown_errors() {
1390 let writer = AsyncAuditWriter::new();
1391 let _ = writer.shutdown().unwrap();
1392 let result = writer.shutdown();
1393 assert!(result.is_err(), "double shutdown should error");
1394 }
1395
1396 #[test]
1397 fn test_async_writer_default() {
1398 let writer = AsyncAuditWriter::default();
1399 writer.log(&ctx("SELECT 1", "u", 1)).unwrap();
1400 let logs = writer.shutdown().unwrap();
1401 assert_eq!(logs.len(), 1);
1402 }
1403
1404 #[test]
1407 fn test_audit_query_by_user() {
1408 let auditor = SqlAuditor::new();
1409 auditor.log(&ctx("SELECT 1", "alice", 100));
1410 auditor.log(&ctx("SELECT 2", "bob", 200));
1411 auditor.log(&ctx("SELECT 3", "alice", 300));
1412 let query = AuditQuery::new().by_user("alice");
1413 let results = query_logs(&auditor, &query);
1414 assert_eq!(results.len(), 2);
1415 assert!(results.iter().all(|r| r.user == "alice"));
1416 }
1417
1418 #[test]
1419 fn test_audit_query_by_time_range() {
1420 let auditor = SqlAuditor::new();
1421 auditor.log(&ctx("SELECT 1", "u", 100));
1422 auditor.log(&ctx("SELECT 2", "u", 200));
1423 auditor.log(&ctx("SELECT 3", "u", 300));
1424 auditor.log(&ctx("SELECT 4", "u", 400));
1425 let query = AuditQuery::new().by_time_range(150, 350);
1426 let results = query_logs(&auditor, &query);
1427 assert_eq!(results.len(), 2);
1428 assert!(results
1429 .iter()
1430 .all(|r| r.timestamp >= 150 && r.timestamp <= 350));
1431 }
1432
1433 #[test]
1434 fn test_audit_query_by_sql_contains() {
1435 let auditor = SqlAuditor::new();
1436 auditor.log(&ctx("SELECT * FROM users", "u", 1));
1437 auditor.log(&ctx("INSERT INTO orders", "u", 2));
1438 auditor.log(&ctx("SELECT * FROM orders", "u", 3));
1439 let query = AuditQuery::new().by_sql_contains("orders");
1440 let results = query_logs(&auditor, &query);
1441 assert_eq!(results.len(), 2);
1442 assert!(results
1443 .iter()
1444 .all(|r| r.sql.to_lowercase().contains("orders")));
1445 }
1446
1447 #[test]
1448 fn test_audit_query_sql_contains_case_insensitive() {
1449 let auditor = SqlAuditor::new();
1450 auditor.log(&ctx("select * from Users", "u", 1));
1451 let query = AuditQuery::new().by_sql_contains("USERS");
1452 let results = query_logs(&auditor, &query);
1453 assert_eq!(results.len(), 1);
1454 }
1455
1456 #[test]
1457 fn test_audit_query_with_limit() {
1458 let auditor = SqlAuditor::new();
1459 for i in 0..10 {
1460 auditor.log(&ctx(&format!("SELECT {}", i), "u", i));
1461 }
1462 let query = AuditQuery::new().with_limit(3);
1463 let results = query_logs(&auditor, &query);
1464 assert_eq!(results.len(), 3);
1465 }
1466
1467 #[test]
1468 fn test_audit_query_combined_filters() {
1469 let auditor = SqlAuditor::new();
1470 auditor.log(&ctx("SELECT * FROM users", "alice", 100));
1471 auditor.log(&ctx("INSERT INTO users", "alice", 200));
1472 auditor.log(&ctx("SELECT * FROM orders", "alice", 300));
1473 auditor.log(&ctx("SELECT * FROM users", "bob", 400));
1474 let query = AuditQuery::new()
1475 .by_user("alice")
1476 .by_sql_contains("select")
1477 .with_limit(10);
1478 let results = query_logs(&auditor, &query);
1479 assert_eq!(results.len(), 2);
1480 assert!(results.iter().all(|r| r.user == "alice"));
1481 }
1482
1483 #[test]
1484 fn test_audit_query_empty_returns_all() {
1485 let auditor = SqlAuditor::new();
1486 auditor.log(&ctx("SELECT 1", "u", 1));
1487 auditor.log(&ctx("SELECT 2", "u", 2));
1488 let query = AuditQuery::new();
1489 let results = query_logs(&auditor, &query);
1490 assert_eq!(results.len(), 2);
1491 }
1492
1493 #[test]
1494 fn test_audit_query_no_match_returns_empty() {
1495 let auditor = SqlAuditor::new();
1496 auditor.log(&ctx("SELECT 1", "u", 1));
1497 let query = AuditQuery::new().by_user("nonexistent");
1498 let results = query_logs(&auditor, &query);
1499 assert!(results.is_empty());
1500 }
1501
1502 #[test]
1503 fn test_audit_query_filter_directly() {
1504 let logs = vec![
1505 ctx("SELECT 1", "a", 10),
1506 ctx("SELECT 2", "b", 20),
1507 ctx("SELECT 3", "a", 30),
1508 ];
1509 let query = AuditQuery::new().by_user("a");
1510 let results = query.filter(&logs);
1511 assert_eq!(results.len(), 2);
1512 }
1513
1514 #[test]
1515 fn test_audit_query_limit_zero_means_no_limit() {
1516 let logs = vec![ctx("SELECT 1", "a", 10), ctx("SELECT 2", "a", 20)];
1517 let query = AuditQuery::new().with_limit(0);
1518 let results = query.filter(&logs);
1519 assert_eq!(results.len(), 2);
1520 }
1521
1522 #[test]
1525 fn test_file_audit_log_store_append_and_read_all() {
1526 let path = test_data_dir().join("sz_orm_audit_store_append.jsonl");
1527 let path_str = path.to_str().unwrap();
1528 let store = FileAuditLogStore::new(path_str);
1529 let _ = store.clear();
1531
1532 store
1533 .append(&ctx("SELECT * FROM users", "alice", 1000))
1534 .unwrap();
1535 store
1536 .append(&ctx("INSERT INTO logs VALUES(1)", "bob", 2000))
1537 .unwrap();
1538
1539 let logs = store.read_all().unwrap();
1540 assert_eq!(logs.len(), 2);
1541 assert_eq!(logs[0].user, "alice");
1542 assert_eq!(logs[0].sql, "SELECT * FROM users");
1543 assert_eq!(logs[1].user, "bob");
1544 assert_eq!(logs[1].timestamp, 2000);
1545
1546 let _ = store.clear();
1547 }
1548
1549 #[test]
1550 fn test_file_audit_log_store_masks_sensitive() {
1551 let path = test_data_dir().join("sz_orm_audit_store_mask.jsonl");
1552 let path_str = path.to_str().unwrap();
1553 let store = FileAuditLogStore::new(path_str);
1554 let _ = store.clear();
1555
1556 store
1557 .append(&ctx(
1558 "SELECT * FROM users WHERE password='secret'",
1559 "admin",
1560 1000,
1561 ))
1562 .unwrap();
1563
1564 let logs = store.read_all().unwrap();
1565 assert_eq!(logs.len(), 1);
1566 assert!(!logs[0].sql.contains("password"));
1568 assert!(!logs[0].sql.contains("secret"));
1569 assert!(logs[0].sql.contains("******"));
1570
1571 let _ = store.clear();
1572 }
1573
1574 #[test]
1575 fn test_file_audit_log_store_clear_removes_entries() {
1576 let path = test_data_dir().join("sz_orm_audit_store_clear.jsonl");
1577 let path_str = path.to_str().unwrap();
1578 let store = FileAuditLogStore::new(path_str);
1579 let _ = store.clear();
1580
1581 store.append(&ctx("SELECT 1", "u", 1)).unwrap();
1582 store.append(&ctx("SELECT 2", "u", 2)).unwrap();
1583 assert_eq!(store.read_all().unwrap().len(), 2);
1584
1585 store.clear().unwrap();
1586 assert_eq!(store.read_all().unwrap().len(), 0);
1588
1589 let _ = store.clear();
1590 }
1591
1592 #[test]
1593 fn test_file_audit_log_store_clear_nonexistent_is_ok() {
1594 let path = test_data_dir().join("sz_orm_audit_store_nonexistent.jsonl");
1596 let path_str = path.to_str().unwrap();
1597 let store = FileAuditLogStore::new(path_str);
1598 let _ = std::fs::remove_file(path_str);
1600 assert!(store.clear().is_ok());
1601 }
1602
1603 #[test]
1604 fn test_file_audit_log_store_read_all_empty_file() {
1605 let path = test_data_dir().join("sz_orm_audit_store_empty_read.jsonl");
1606 let path_str = path.to_str().unwrap();
1607 let store = FileAuditLogStore::new(path_str);
1608 let _ = store.clear();
1609
1610 let logs = store.read_all().unwrap();
1612 assert!(logs.is_empty());
1613
1614 let _ = store.clear();
1615 }
1616
1617 #[test]
1618 fn test_file_audit_log_store_skips_blank_lines() {
1619 let path = test_data_dir().join("sz_orm_audit_store_blank_lines.jsonl");
1620 let path_str = path.to_str().unwrap();
1621 let store = FileAuditLogStore::new(path_str);
1622 let _ = store.clear();
1623
1624 store.append(&ctx("SELECT 1", "u", 1)).unwrap();
1625 let mut file = std::fs::OpenOptions::new()
1628 .append(true)
1629 .open(path_str)
1630 .unwrap();
1631 use std::io::Write;
1632 writeln!(file).unwrap();
1633 writeln!(file, " ").unwrap();
1634 drop(file);
1635
1636 store.append(&ctx("SELECT 2", "u", 2)).unwrap();
1637
1638 let logs = store.read_all().unwrap();
1639 assert_eq!(logs.len(), 2);
1641
1642 let _ = store.clear();
1643 }
1644
1645 #[test]
1646 fn test_file_audit_log_store_path_accessor() {
1647 let store = FileAuditLogStore::new("/tmp/sz_orm_audit_path_test.jsonl");
1648 assert_eq!(store.path(), "/tmp/sz_orm_audit_path_test.jsonl");
1649 }
1650
1651 #[test]
1652 fn test_file_audit_log_store_concurrent_append() {
1653 use std::sync::Arc;
1654 let path = test_data_dir().join("sz_orm_audit_store_concurrent.jsonl");
1655 let path_str = path.to_str().unwrap();
1656 let store = Arc::new(FileAuditLogStore::new(path_str));
1657 let _ = store.clear();
1658
1659 let mut handles = vec![];
1660 for i in 0..4 {
1661 let s = Arc::clone(&store);
1662 handles.push(std::thread::spawn(move || {
1663 for j in 0..10 {
1664 s.append(&ctx(&format!("SELECT {}_{}", i, j), "u", j as i64))
1665 .unwrap();
1666 }
1667 }));
1668 }
1669 for h in handles {
1670 h.join().unwrap();
1671 }
1672
1673 let logs = store.read_all().unwrap();
1675 assert_eq!(logs.len(), 40);
1676
1677 let _ = store.clear();
1678 }
1679
1680 #[test]
1681 fn test_audit_log_store_trait_object() {
1682 let path = test_data_dir().join("sz_orm_audit_store_trait.jsonl");
1684 let path_str = path.to_str().unwrap();
1685 let store: Box<dyn AuditLogStore> = Box::new(FileAuditLogStore::new(path_str));
1686 let _ = store.clear();
1687
1688 store.append(&ctx("SELECT 1", "u", 1)).unwrap();
1689 let logs = store.read_all().unwrap();
1690 assert_eq!(logs.len(), 1);
1691
1692 let _ = store.clear();
1693 }
1694
1695 #[test]
1698 fn test_hash_chain_empty_auditor_verify_ok() {
1699 let auditor = HashChainAuditor::new();
1700 assert!(auditor.is_empty());
1701 assert_eq!(auditor.len(), 0);
1702 assert!(auditor.verify().is_ok());
1704 }
1705
1706 #[test]
1707 fn test_hash_chain_single_entry_genesis() {
1708 let auditor = HashChainAuditor::new();
1709 auditor.log(&ctx("SELECT 1", "admin", 1000));
1710 assert_eq!(auditor.len(), 1);
1711
1712 let entries = auditor.get_entries();
1713 assert_eq!(entries[0].prev_hash, GENESIS_HASH);
1715 assert_eq!(entries[0].current_hash.len(), 64);
1717 assert!(auditor.verify().is_ok());
1719 }
1720
1721 #[test]
1722 fn test_hash_chain_multiple_entries_linked() {
1723 let auditor = HashChainAuditor::new();
1724 auditor.log(&ctx("SELECT 1", "admin", 1000));
1725 auditor.log(&ctx("SELECT 2", "admin", 1001));
1726 auditor.log(&ctx("SELECT 3", "admin", 1002));
1727 assert_eq!(auditor.len(), 3);
1728
1729 let entries = auditor.get_entries();
1730 assert_eq!(entries[1].prev_hash, entries[0].current_hash);
1732 assert_eq!(entries[2].prev_hash, entries[1].current_hash);
1733 assert!(auditor.verify().is_ok());
1735 }
1736
1737 #[test]
1738 fn test_hash_chain_detects_tampered_sql() {
1739 let auditor = HashChainAuditor::new();
1740 auditor.log(&ctx("SELECT 1", "admin", 1000));
1741 auditor.log(&ctx("SELECT 2", "admin", 1001));
1742
1743 {
1745 let mut entries = auditor.entries.lock().unwrap();
1746 entries[0].entry.sql = "DROP TABLE users".to_string();
1747 }
1748
1749 let result = auditor.verify();
1751 assert!(result.is_err());
1752 let err = result.unwrap_err();
1753 assert!(err.contains("index 0"), "error: {}", err);
1755 assert!(err.contains("hash mismatch"), "error: {}", err);
1756 }
1757
1758 #[test]
1759 fn test_hash_chain_detects_broken_link() {
1760 let auditor = HashChainAuditor::new();
1761 auditor.log(&ctx("SELECT 1", "admin", 1000));
1762 auditor.log(&ctx("SELECT 2", "admin", 1001));
1763
1764 {
1766 let mut entries = auditor.entries.lock().unwrap();
1767 entries[1].prev_hash = "deadbeef".to_string();
1768 }
1769
1770 let result = auditor.verify();
1771 assert!(result.is_err());
1772 let err = result.unwrap_err();
1773 assert!(err.contains("chain broken at index 1"), "error: {}", err);
1774 }
1775
1776 #[test]
1777 fn test_hash_chain_detects_genesis_tamper() {
1778 let auditor = HashChainAuditor::new();
1779 auditor.log(&ctx("SELECT 1", "admin", 1000));
1780
1781 {
1783 let mut entries = auditor.entries.lock().unwrap();
1784 entries[0].prev_hash = "deadbeef".to_string();
1785 }
1786
1787 let result = auditor.verify();
1788 assert!(result.is_err());
1789 let err = result.unwrap_err();
1790 assert!(err.contains("genesis prev_hash mismatch"), "error: {}", err);
1791 }
1792
1793 #[test]
1794 fn test_hash_chain_masks_sensitive_data() {
1795 let auditor = HashChainAuditor::new();
1796 auditor.log(&ctx(
1797 "SELECT * FROM users WHERE password='secret'",
1798 "admin",
1799 1000,
1800 ));
1801
1802 let entries = auditor.get_entries();
1803 assert!(!entries[0].entry.sql.contains("password"));
1805 assert!(!entries[0].entry.sql.contains("secret"));
1806 assert!(entries[0].entry.sql.contains("******"));
1807 assert!(auditor.verify().is_ok());
1809 }
1810
1811 #[test]
1812 fn test_hash_chain_deterministic_hashes() {
1813 let entry = ctx("SELECT 1", "admin", 1000);
1815 let e1 = HashChainEntry::genesis(entry.clone());
1816 let e2 = HashChainEntry::genesis(entry);
1817 assert_eq!(e1.current_hash, e2.current_hash);
1818 assert_eq!(e1.prev_hash, e2.prev_hash);
1819 }
1820
1821 #[test]
1822 fn test_hash_chain_different_inputs_different_hashes() {
1823 let e1 = HashChainEntry::genesis(ctx("SELECT 1", "admin", 1000));
1824 let e2 = HashChainEntry::genesis(ctx("SELECT 2", "admin", 1000));
1825 assert_ne!(e1.current_hash, e2.current_hash);
1826 }
1827
1828 #[test]
1829 fn test_hash_chain_flush_and_persist() {
1830 let auditor = HashChainAuditor::new();
1831 auditor.log(&ctx("SELECT 1", "admin", 1000));
1832 auditor.log(&ctx("SELECT 2", "admin", 1001));
1833
1834 let path = test_data_dir().join("sz_orm_audit_hash_chain.json");
1835 let path_str = path.to_str().unwrap();
1836 let count = auditor.flush(path_str).unwrap();
1837 assert_eq!(count, 2);
1838
1839 let content = std::fs::read_to_string(path_str).unwrap();
1841 assert!(!content.is_empty());
1842 assert!(content.contains("current_hash"));
1843
1844 let _ = std::fs::remove_file(path_str);
1845 }
1846
1847 #[test]
1848 fn test_hash_chain_concurrent_log_thread_safe() {
1849 use std::sync::Arc;
1850 use std::thread;
1851
1852 let auditor = Arc::new(HashChainAuditor::new());
1853 let mut handles = vec![];
1854 for i in 0..4 {
1855 let a = Arc::clone(&auditor);
1856 handles.push(thread::spawn(move || {
1857 for j in 0..25 {
1858 a.log(&ctx(&format!("SELECT {}_{}", i, j), "u", j as i64));
1859 }
1860 }));
1861 }
1862 for h in handles {
1863 h.join().unwrap();
1864 }
1865
1866 assert_eq!(auditor.len(), 100);
1868 assert!(auditor.verify().is_ok());
1870 }
1871
1872 #[test]
1873 fn test_genesis_hash_constant_is_64_zeros() {
1874 assert_eq!(GENESIS_HASH.len(), 64);
1876 assert!(GENESIS_HASH.chars().all(|c| c == '0'));
1877 }
1878}