1use std::fs::{File, OpenOptions};
38use std::io::{BufRead, BufReader, Read, Write};
39use std::path::{Path, PathBuf};
40use std::sync::atomic::{AtomicU64, Ordering};
41use std::sync::{Mutex, RwLock};
42
43use anyhow::{Context, Result, anyhow};
44use chrono::Utc;
45use serde::{Deserialize, Serialize};
46use sha2::{Digest, Sha256};
47
48pub const SCHEMA_VERSION: u32 = 1;
54
55pub const CHAIN_HEAD_PREV_HASH: &str =
59 "0000000000000000000000000000000000000000000000000000000000000000";
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub struct AuditEvent {
65 pub schema_version: u32,
67 pub timestamp: String,
69 pub sequence: u64,
71 pub actor: AuditActor,
72 pub action: AuditAction,
73 pub target: AuditTarget,
74 pub outcome: AuditOutcome,
75 #[serde(skip_serializing_if = "Option::is_none")]
78 pub auth: Option<AuditAuth>,
79 #[serde(skip_serializing_if = "Option::is_none")]
80 pub session_id: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub request_id: Option<String>,
83 #[serde(skip_serializing_if = "Option::is_none")]
86 pub error: Option<String>,
87 pub prev_hash: String,
90 pub self_hash: String,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
96pub struct AuditActor {
97 pub agent_id: String,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub scope: Option<String>,
103 pub synthesis_source: String,
107}
108
109#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
112#[serde(rename_all = "snake_case")]
113pub enum AuditAction {
114 Recall,
115 Store,
116 Update,
117 Delete,
118 Link,
119 Promote,
120 Forget,
121 Consolidate,
122 Export,
123 Import,
124 Approve,
125 Reject,
126 SessionBoot,
127}
128
129impl AuditAction {
130 #[must_use]
132 pub fn as_str(&self) -> &'static str {
133 match self {
134 Self::Recall => "recall",
135 Self::Store => "store",
136 Self::Update => "update",
137 Self::Delete => "delete",
138 Self::Link => "link",
139 Self::Promote => "promote",
140 Self::Forget => "forget",
141 Self::Consolidate => "consolidate",
142 Self::Export => "export",
143 Self::Import => "import",
144 Self::Approve => "approve",
145 Self::Reject => "reject",
146 Self::SessionBoot => "session_boot",
147 }
148 }
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153pub struct AuditTarget {
154 pub memory_id: String,
157 pub namespace: String,
159 #[serde(skip_serializing_if = "Option::is_none")]
164 pub title: Option<String>,
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub tier: Option<String>,
168 #[serde(skip_serializing_if = "Option::is_none")]
170 pub scope: Option<String>,
171}
172
173#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
175#[serde(rename_all = "snake_case")]
176pub enum AuditOutcome {
177 Allow,
178 Deny,
179 Error,
180 Pending,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
186pub struct AuditAuth {
187 #[serde(skip_serializing_if = "Option::is_none")]
188 pub source_ip: Option<String>,
189 #[serde(skip_serializing_if = "Option::is_none")]
190 pub mtls_fp: Option<String>,
191 #[serde(skip_serializing_if = "Option::is_none")]
194 pub api_key_id_hash: Option<String>,
195}
196
197static SINK: RwLock<Option<std::sync::Arc<AuditSink>>> = RwLock::new(None);
205static SEQUENCE: AtomicU64 = AtomicU64::new(0);
207
208pub(crate) struct AuditSink {
213 inner: Mutex<SinkInner>,
214 #[allow(dead_code)]
215 redact_content: bool,
216}
217
218struct SinkInner {
219 writer: Box<dyn Write + Send>,
220 last_hash: String,
223 #[allow(dead_code)]
226 path: Option<PathBuf>,
227}
228
229pub fn init(path: &Path, redact_content: bool, append_only_hint: bool) -> Result<()> {
238 if let Some(parent) = path.parent()
239 && !parent.as_os_str().is_empty()
240 {
241 std::fs::create_dir_all(parent)
242 .with_context(|| format!("creating audit log dir {}", parent.display()))?;
243 }
244
245 let last_hash = match read_chain_tail(path) {
248 Ok(Some(h)) => h,
249 _ => CHAIN_HEAD_PREV_HASH.to_string(),
250 };
251
252 let file = OpenOptions::new()
253 .create(true)
254 .append(true)
255 .open(path)
256 .with_context(|| format!("opening audit log {}", path.display()))?;
257
258 if append_only_hint {
259 if let Err(e) = mark_append_only(path) {
262 tracing::warn!(
263 "audit: append-only OS flag could not be set on {} ({e}); \
264 the hash chain remains the authoritative tamper-evidence",
265 path.display()
266 );
267 }
268 }
269
270 let sink = AuditSink {
271 inner: Mutex::new(SinkInner {
272 writer: Box::new(file),
273 last_hash,
274 path: Some(path.to_path_buf()),
275 }),
276 redact_content,
277 };
278
279 SEQUENCE.store(0, Ordering::SeqCst);
280 if let Ok(mut guard) = SINK.write() {
281 *guard = Some(std::sync::Arc::new(sink));
282 }
283 Ok(())
284}
285
286#[cfg(test)]
290pub fn init_for_test(buf: std::sync::Arc<Mutex<Vec<u8>>>) {
291 struct VecWriter(std::sync::Arc<Mutex<Vec<u8>>>);
292 impl Write for VecWriter {
293 fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
294 self.0
295 .lock()
296 .expect("test sink poisoned")
297 .extend_from_slice(data);
298 Ok(data.len())
299 }
300 fn flush(&mut self) -> std::io::Result<()> {
301 Ok(())
302 }
303 }
304 let sink = AuditSink {
305 inner: Mutex::new(SinkInner {
306 writer: Box::new(VecWriter(buf)),
307 last_hash: CHAIN_HEAD_PREV_HASH.to_string(),
308 path: None,
309 }),
310 redact_content: true,
311 };
312 SEQUENCE.store(0, Ordering::SeqCst);
313 if let Ok(mut guard) = SINK.write() {
314 *guard = Some(std::sync::Arc::new(sink));
315 }
316}
317
318#[cfg(test)]
321pub fn shutdown_for_test() {
322 if let Ok(mut guard) = SINK.write() {
323 *guard = None;
324 }
325 SEQUENCE.store(0, Ordering::SeqCst);
326}
327
328fn read_chain_tail(path: &Path) -> Result<Option<String>> {
334 if !path.exists() {
335 return Ok(None);
336 }
337 let file = File::open(path)?;
338 let reader = BufReader::new(file);
339 let mut last: Option<String> = None;
340 for line in reader.lines() {
341 let line = line?;
342 if line.trim().is_empty() {
343 continue;
344 }
345 if let Ok(ev) = serde_json::from_str::<AuditEvent>(&line) {
346 last = Some(ev.self_hash);
347 }
348 }
349 Ok(last)
350}
351
352#[must_use]
354pub fn is_enabled() -> bool {
355 SINK.read().map(|g| g.is_some()).unwrap_or(false)
356}
357
358fn compute_self_hash(ev: &AuditEvent) -> String {
367 let canonical = canonical_json_for_hash(ev);
368 let mut hasher = Sha256::new();
369 hasher.update(canonical.as_bytes());
370 hex_encode(&hasher.finalize())
371}
372
373fn canonical_json_for_hash(ev: &AuditEvent) -> String {
378 let mut clone = ev.clone();
379 clone.self_hash.clear();
380 serde_json::to_string(&clone).expect("AuditEvent always serializes")
381}
382
383fn hex_encode(bytes: &[u8]) -> String {
384 static HEX: &[u8; 16] = b"0123456789abcdef";
385 let mut out = String::with_capacity(bytes.len() * 2);
386 for b in bytes {
387 out.push(HEX[(b >> 4) as usize] as char);
388 out.push(HEX[(b & 0x0f) as usize] as char);
389 }
390 out
391}
392
393#[derive(Debug, Clone)]
402pub struct EventBuilder {
403 pub action: AuditAction,
404 pub actor: AuditActor,
405 pub target: AuditTarget,
406 pub outcome: AuditOutcome,
407 pub auth: Option<AuditAuth>,
408 pub session_id: Option<String>,
409 pub request_id: Option<String>,
410 pub error: Option<String>,
411}
412
413impl EventBuilder {
414 #[must_use]
417 pub fn new(action: AuditAction, actor: AuditActor, target: AuditTarget) -> Self {
418 Self {
419 action,
420 actor,
421 target,
422 outcome: AuditOutcome::Allow,
423 auth: None,
424 session_id: None,
425 request_id: None,
426 error: None,
427 }
428 }
429
430 #[must_use]
432 pub fn outcome(mut self, outcome: AuditOutcome) -> Self {
433 self.outcome = outcome;
434 self
435 }
436
437 #[must_use]
440 pub fn error(mut self, msg: impl Into<String>) -> Self {
441 self.error = Some(sanitize_field(&msg.into(), 256));
442 self.outcome = AuditOutcome::Error;
443 self
444 }
445
446 #[must_use]
447 pub fn auth(mut self, auth: AuditAuth) -> Self {
448 self.auth = Some(auth);
449 self
450 }
451
452 #[must_use]
453 pub fn request_id(mut self, id: impl Into<String>) -> Self {
454 self.request_id = Some(id.into());
455 self
456 }
457}
458
459pub fn emit(builder: EventBuilder) {
463 if let Err(e) = try_emit(builder) {
464 tracing::error!("audit: emission failed: {e}");
465 }
466}
467
468fn try_emit(builder: EventBuilder) -> Result<()> {
471 let sink = {
472 let guard = SINK
473 .read()
474 .map_err(|_| anyhow!("audit sink rwlock poisoned"))?;
475 match guard.as_ref() {
476 Some(s) => s.clone(),
477 None => return Ok(()),
478 }
479 };
480
481 let mut inner = sink
482 .inner
483 .lock()
484 .map_err(|_| anyhow!("audit sink mutex poisoned"))?;
485
486 let sequence = SEQUENCE.fetch_add(1, Ordering::SeqCst) + 1;
487
488 let mut ev = AuditEvent {
489 schema_version: SCHEMA_VERSION,
490 timestamp: Utc::now().to_rfc3339(),
491 sequence,
492 actor: builder.actor,
493 action: builder.action,
494 target: AuditTarget {
495 memory_id: sanitize_field(&builder.target.memory_id, 128),
496 namespace: sanitize_field(&builder.target.namespace, 128),
497 title: builder.target.title.map(|t| sanitize_field(&t, 200)),
498 tier: builder.target.tier,
499 scope: builder.target.scope,
500 },
501 outcome: builder.outcome,
502 auth: builder.auth,
503 session_id: builder.session_id,
504 request_id: builder.request_id,
505 error: builder.error,
506 prev_hash: inner.last_hash.clone(),
507 self_hash: String::new(),
508 };
509
510 let self_hash = compute_self_hash(&ev);
511 ev.self_hash = self_hash.clone();
512
513 let line = serde_json::to_string(&ev).context("serializing audit event")?;
514 writeln!(inner.writer, "{line}").context("appending audit line")?;
515 inner.writer.flush().ok();
516 inner.last_hash = self_hash;
517 Ok(())
518}
519
520fn sanitize_field(s: &str, max_chars: usize) -> String {
524 let cleaned: String = s
525 .chars()
526 .filter(|c| !c.is_control() || *c == '\t')
527 .collect();
528 if cleaned.chars().count() <= max_chars {
529 cleaned
530 } else {
531 cleaned.chars().take(max_chars).collect()
532 }
533}
534
535#[must_use]
543pub fn actor(
544 agent_id: impl Into<String>,
545 synthesis_source: impl Into<String>,
546 scope: Option<String>,
547) -> AuditActor {
548 AuditActor {
549 agent_id: agent_id.into(),
550 synthesis_source: synthesis_source.into(),
551 scope,
552 }
553}
554
555#[must_use]
557pub fn target_memory(
558 memory_id: impl Into<String>,
559 namespace: impl Into<String>,
560 title: Option<String>,
561 tier: Option<String>,
562 scope: Option<String>,
563) -> AuditTarget {
564 AuditTarget {
565 memory_id: memory_id.into(),
566 namespace: namespace.into(),
567 title,
568 tier,
569 scope,
570 }
571}
572
573#[must_use]
575pub fn target_sweep(namespace: impl Into<String>) -> AuditTarget {
576 AuditTarget {
577 memory_id: "*".to_string(),
578 namespace: namespace.into(),
579 title: None,
580 tier: None,
581 scope: None,
582 }
583}
584
585#[derive(Debug, Clone, PartialEq, Eq)]
591pub struct VerifyReport {
592 pub total_lines: u64,
593 pub first_failure: Option<VerifyFailure>,
594}
595
596#[derive(Debug, Clone, PartialEq, Eq)]
597pub struct VerifyFailure {
598 pub line_number: u64,
599 pub kind: VerifyFailureKind,
600 pub detail: String,
601}
602
603#[derive(Debug, Clone, PartialEq, Eq)]
604pub enum VerifyFailureKind {
605 Parse,
607 SelfHash,
609 ChainBreak,
611 Sequence,
613}
614
615impl VerifyReport {
616 pub fn into_result(self) -> Result<u64> {
618 if let Some(failure) = self.first_failure {
619 Err(anyhow!(
620 "audit chain verification failed at line {}: {:?} — {}",
621 failure.line_number,
622 failure.kind,
623 failure.detail
624 ))
625 } else {
626 Ok(self.total_lines)
627 }
628 }
629}
630
631pub fn verify_chain(path: &Path) -> Result<VerifyReport> {
638 let file = File::open(path).with_context(|| format!("opening {}", path.display()))?;
639 verify_chain_from_reader(file)
640}
641
642pub fn verify_chain_from_reader<R: Read>(reader: R) -> Result<VerifyReport> {
645 let buf = BufReader::new(reader);
646 let mut total: u64 = 0;
647 let mut prev_hash = CHAIN_HEAD_PREV_HASH.to_string();
648 let mut prev_seq: u64 = 0;
649
650 for (idx, line) in buf.lines().enumerate() {
651 let line_no = (idx as u64) + 1;
652 let line = line.with_context(|| format!("reading audit line {line_no}"))?;
653 if line.trim().is_empty() {
654 continue;
655 }
656 total += 1;
657
658 let ev: AuditEvent = match serde_json::from_str(&line) {
659 Ok(e) => e,
660 Err(e) => {
661 return Ok(VerifyReport {
662 total_lines: total,
663 first_failure: Some(VerifyFailure {
664 line_number: line_no,
665 kind: VerifyFailureKind::Parse,
666 detail: format!("malformed JSON: {e}"),
667 }),
668 });
669 }
670 };
671
672 if ev.prev_hash != prev_hash {
673 return Ok(VerifyReport {
674 total_lines: total,
675 first_failure: Some(VerifyFailure {
676 line_number: line_no,
677 kind: VerifyFailureKind::ChainBreak,
678 detail: format!(
679 "prev_hash mismatch: expected {prev_hash}, got {}",
680 ev.prev_hash
681 ),
682 }),
683 });
684 }
685
686 if ev.sequence <= prev_seq && prev_seq != 0 {
687 return Ok(VerifyReport {
688 total_lines: total,
689 first_failure: Some(VerifyFailure {
690 line_number: line_no,
691 kind: VerifyFailureKind::Sequence,
692 detail: format!(
693 "sequence not monotonic: prior={prev_seq}, this={}",
694 ev.sequence
695 ),
696 }),
697 });
698 }
699
700 let recomputed = compute_self_hash(&ev);
701 if recomputed != ev.self_hash {
702 return Ok(VerifyReport {
703 total_lines: total,
704 first_failure: Some(VerifyFailure {
705 line_number: line_no,
706 kind: VerifyFailureKind::SelfHash,
707 detail: format!(
708 "self_hash mismatch: stored={}, recomputed={}",
709 ev.self_hash, recomputed
710 ),
711 }),
712 });
713 }
714
715 prev_hash = ev.self_hash.clone();
716 prev_seq = ev.sequence;
717 }
718
719 Ok(VerifyReport {
720 total_lines: total,
721 first_failure: None,
722 })
723}
724
725pub fn init_from_config(cfg: &crate::config::AuditConfig) -> Result<()> {
736 if !cfg.enabled.unwrap_or(false) {
737 if let Ok(mut guard) = SINK.write() {
738 *guard = None;
739 }
740 return Ok(());
741 }
742 let resolved_path = resolve_audit_path(cfg);
743 init(
744 &resolved_path,
745 cfg.redact_content.unwrap_or(true),
746 cfg.append_only.unwrap_or(true),
747 )
748}
749
750#[must_use]
758pub fn resolve_audit_path(cfg: &crate::config::AuditConfig) -> PathBuf {
759 let resolved = crate::log_paths::resolve_audit_dir(None, cfg.path.as_deref())
760 .map(|r| r.path)
761 .unwrap_or_else(|_| {
762 crate::log_paths::platform_default(crate::log_paths::DirKind::Audit).path
763 });
764 finalize_audit_file(resolved, cfg.path.as_deref())
765}
766
767pub fn resolve_audit_path_with_override(
775 cli_override: Option<&Path>,
776 cfg: &crate::config::AuditConfig,
777) -> Result<(PathBuf, crate::log_paths::PathSource)> {
778 let r = crate::log_paths::resolve_audit_dir(cli_override, cfg.path.as_deref())?;
779 let final_path = finalize_audit_file(r.path, cfg.path.as_deref());
780 Ok((final_path, r.source))
781}
782
783fn finalize_audit_file(p: PathBuf, raw_config: Option<&str>) -> PathBuf {
786 if let Some(raw) = raw_config
789 && !raw.ends_with('/')
790 && std::path::Path::new(raw).extension().is_some()
791 {
792 return p;
793 }
794 if p.extension().is_none() || p.to_string_lossy().ends_with('/') {
795 p.join("audit.log")
796 } else {
797 p
798 }
799}
800
801pub(crate) fn expand_tilde(raw: &str) -> String {
802 if let Some(rest) = raw.strip_prefix("~/")
803 && let Ok(home) = std::env::var("HOME")
804 {
805 return format!("{home}/{rest}");
806 }
807 raw.to_string()
808}
809
810#[cfg(unix)]
817fn mark_append_only(path: &Path) -> Result<()> {
818 use std::ffi::CString;
819 use std::os::unix::ffi::OsStrExt;
820
821 let c_path =
822 CString::new(path.as_os_str().as_bytes()).context("path contains an interior NUL byte")?;
823 #[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd"))]
824 {
825 let rc = unsafe { libc::chflags(c_path.as_ptr(), libc::UF_APPEND.into()) };
829 if rc != 0 {
830 return Err(anyhow!(
831 "chflags(UF_APPEND) failed: errno={}",
832 std::io::Error::last_os_error()
833 ));
834 }
835 return Ok(());
836 }
837 #[cfg(target_os = "linux")]
838 {
839 const FS_APPEND_FL: libc::c_int = 0x0000_0020;
845 const FS_IOC_SETFLAGS: libc::c_ulong = 0x4008_6602;
849 let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) };
850 if fd < 0 {
851 return Err(anyhow!(
852 "open(audit log) for ioctl failed: errno={}",
853 std::io::Error::last_os_error()
854 ));
855 }
856 let mut flags: libc::c_int = 0;
857 let rc = unsafe { libc::ioctl(fd, FS_IOC_SETFLAGS, &mut flags) };
861 if rc == 0 {
862 flags |= FS_APPEND_FL;
863 let rc2 = unsafe { libc::ioctl(fd, FS_IOC_SETFLAGS, &mut flags) };
864 unsafe { libc::close(fd) };
865 if rc2 != 0 {
866 return Err(anyhow!(
867 "ioctl(FS_IOC_SETFLAGS) failed: errno={}",
868 std::io::Error::last_os_error()
869 ));
870 }
871 return Ok(());
872 }
873 unsafe { libc::close(fd) };
874 Err(anyhow!(
875 "ioctl(FS_IOC_GETFLAGS) failed: errno={}",
876 std::io::Error::last_os_error()
877 ))
878 }
879 #[cfg(not(any(
880 target_os = "macos",
881 target_os = "freebsd",
882 target_os = "openbsd",
883 target_os = "linux"
884 )))]
885 {
886 let _ = c_path;
887 Err(anyhow!(
888 "append-only flag not supported on this unix variant"
889 ))
890 }
891}
892
893#[cfg(not(unix))]
894fn mark_append_only(_path: &Path) -> Result<()> {
895 Err(anyhow!("append-only flag is unix-only"))
896}
897
898#[cfg(test)]
903mod tests {
904 use super::*;
905
906 fn sample_event(seq: u64, prev: &str) -> AuditEvent {
907 let mut ev = AuditEvent {
908 schema_version: SCHEMA_VERSION,
909 timestamp: "2026-04-30T00:00:00+00:00".to_string(),
910 sequence: seq,
911 actor: actor("ai:test@host:pid-1", "host_fallback", None),
912 action: AuditAction::Store,
913 target: target_memory(
914 format!("mem-{seq}"),
915 "ns-x",
916 Some("title".to_string()),
917 Some("mid".to_string()),
918 None,
919 ),
920 outcome: AuditOutcome::Allow,
921 auth: None,
922 session_id: None,
923 request_id: None,
924 error: None,
925 prev_hash: prev.to_string(),
926 self_hash: String::new(),
927 };
928 ev.self_hash = compute_self_hash(&ev);
929 ev
930 }
931
932 #[test]
933 fn audit_event_round_trips_through_serde() {
934 let ev = sample_event(1, CHAIN_HEAD_PREV_HASH);
935 let s = serde_json::to_string(&ev).unwrap();
936 let back: AuditEvent = serde_json::from_str(&s).unwrap();
937 assert_eq!(back, ev);
938 assert_eq!(back.schema_version, SCHEMA_VERSION);
939 }
940
941 #[test]
942 fn audit_chain_links_correctly_for_three_events() {
943 let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
944 let e2 = sample_event(2, &e1.self_hash);
945 let e3 = sample_event(3, &e2.self_hash);
946 let mut buf = String::new();
947 for ev in [&e1, &e2, &e3] {
948 buf.push_str(&serde_json::to_string(ev).unwrap());
949 buf.push('\n');
950 }
951 let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
952 assert!(report.first_failure.is_none(), "{:?}", report.first_failure);
953 assert_eq!(report.total_lines, 3);
954 }
955
956 #[test]
957 fn audit_verify_detects_tampered_line() {
958 let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
959 let mut e2 = sample_event(2, &e1.self_hash);
960 e2.target.title = Some("EVIL".to_string());
962 let e3 = sample_event(3, &e2.self_hash);
963 let mut buf = String::new();
964 for ev in [&e1, &e2, &e3] {
965 buf.push_str(&serde_json::to_string(ev).unwrap());
966 buf.push('\n');
967 }
968 let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
969 let failure = report.first_failure.expect("tampering must be detected");
970 assert_eq!(failure.line_number, 2);
971 assert!(matches!(failure.kind, VerifyFailureKind::SelfHash));
972 }
973
974 #[test]
975 fn audit_verify_detects_chain_break() {
976 let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
977 let e2 = sample_event(2, "deadbeef");
979 let mut buf = String::new();
980 for ev in [&e1, &e2] {
981 buf.push_str(&serde_json::to_string(ev).unwrap());
982 buf.push('\n');
983 }
984 let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
985 let failure = report.first_failure.expect("chain break must be detected");
986 assert!(matches!(failure.kind, VerifyFailureKind::ChainBreak));
987 }
988
989 #[test]
990 fn audit_redacts_content_by_default() {
991 let ev = sample_event(1, CHAIN_HEAD_PREV_HASH);
996 let json = serde_json::to_value(&ev).unwrap();
997 assert!(
998 json.get("content").is_none(),
999 "AuditEvent must never carry a content field"
1000 );
1001 assert!(
1002 json["target"].get("content").is_none(),
1003 "AuditTarget must never carry a content field"
1004 );
1005 }
1006
1007 #[test]
1008 fn audit_action_as_str_round_trips() {
1009 for action in [
1010 AuditAction::Recall,
1011 AuditAction::Store,
1012 AuditAction::Update,
1013 AuditAction::Delete,
1014 AuditAction::Link,
1015 AuditAction::Promote,
1016 AuditAction::Forget,
1017 AuditAction::Consolidate,
1018 AuditAction::Export,
1019 AuditAction::Import,
1020 AuditAction::Approve,
1021 AuditAction::Reject,
1022 AuditAction::SessionBoot,
1023 ] {
1024 let s = action.as_str();
1025 let v: serde_json::Value = serde_json::to_value(action).unwrap();
1028 assert_eq!(v.as_str().unwrap(), s);
1029 }
1030 }
1031
1032 #[test]
1033 fn audit_sanitize_strips_newlines() {
1034 let cleaned = sanitize_field("line1\nline2\rline3", 32);
1035 assert!(!cleaned.contains('\n'));
1036 assert!(!cleaned.contains('\r'));
1037 }
1038
1039 #[test]
1040 fn audit_sanitize_caps_length() {
1041 let s = "x".repeat(500);
1042 let cleaned = sanitize_field(&s, 100);
1043 assert_eq!(cleaned.chars().count(), 100);
1044 }
1045
1046 #[test]
1047 fn audit_resolve_path_directory_expands_to_file() {
1048 let cfg = crate::config::AuditConfig {
1049 enabled: Some(true),
1050 path: Some("/tmp/ai-memory/audit/".to_string()),
1051 ..Default::default()
1052 };
1053 let p = resolve_audit_path(&cfg);
1054 assert!(p.ends_with("audit.log"));
1055 }
1056
1057 #[test]
1058 fn audit_resolve_path_explicit_file_kept() {
1059 let cfg = crate::config::AuditConfig {
1060 enabled: Some(true),
1061 path: Some("/var/log/ai-memory/custom.log".to_string()),
1062 ..Default::default()
1063 };
1064 let p = resolve_audit_path(&cfg);
1065 assert_eq!(p, PathBuf::from("/var/log/ai-memory/custom.log"));
1066 }
1067
1068 fn sink_lock() -> std::sync::MutexGuard<'static, ()> {
1072 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
1073 LOCK.get_or_init(|| std::sync::Mutex::new(()))
1074 .lock()
1075 .unwrap_or_else(|p| p.into_inner())
1076 }
1077
1078 #[test]
1083 fn audit_emits_at_every_call_site() {
1084 let _g = sink_lock();
1085 let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1086 super::init_for_test(buf.clone());
1087
1088 let actions = [
1089 AuditAction::Store,
1090 AuditAction::Recall,
1091 AuditAction::Update,
1092 AuditAction::Delete,
1093 AuditAction::Link,
1094 AuditAction::Promote,
1095 AuditAction::Forget,
1096 AuditAction::Consolidate,
1097 AuditAction::Export,
1098 AuditAction::Import,
1099 AuditAction::Approve,
1100 AuditAction::Reject,
1101 AuditAction::SessionBoot,
1102 ];
1103 for (i, action) in actions.iter().copied().enumerate() {
1104 let id = format!("mem-{i}");
1105 super::emit(EventBuilder::new(
1106 action,
1107 actor("ai:test@host", "explicit", None),
1108 target_memory(id, "ns-x", Some("t".to_string()), None, None),
1109 ));
1110 }
1111
1112 let lines = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1113 let count = lines.lines().filter(|l| !l.is_empty()).count();
1114 assert_eq!(
1115 count,
1116 actions.len(),
1117 "expected one audit line per action, got {count}: {lines}"
1118 );
1119 let report = verify_chain_from_reader(lines.as_bytes()).unwrap();
1121 assert!(
1122 report.first_failure.is_none(),
1123 "chain must verify across all call sites; failure: {:?}",
1124 report.first_failure
1125 );
1126 assert_eq!(report.total_lines as usize, actions.len());
1127
1128 super::shutdown_for_test();
1129 }
1130
1131 #[test]
1132 fn audit_emit_is_noop_when_disabled() {
1133 let _g = sink_lock();
1134 super::shutdown_for_test();
1135 super::emit(EventBuilder::new(
1138 AuditAction::Store,
1139 actor("a", "explicit", None),
1140 target_memory("m", "ns", None, None, None),
1141 ));
1142 assert!(!super::is_enabled());
1144 }
1145
1146 #[test]
1147 fn audit_compliance_preset_soc2_overrides_retention() {
1148 let cfg = crate::config::AuditConfig {
1153 enabled: Some(true),
1154 retention_days: Some(90),
1155 compliance: Some(crate::config::AuditComplianceConfig {
1156 soc2: Some(crate::config::CompliancePreset {
1157 applied: Some(true),
1158 retention_days: Some(730),
1159 redact_content: Some(true),
1160 attestation_cadence_minutes: Some(60),
1161 encrypt_at_rest: None,
1162 pseudonymize_actors: None,
1163 }),
1164 ..Default::default()
1165 }),
1166 ..Default::default()
1167 };
1168 let resolved = cfg.effective_retention_days();
1169 assert_eq!(resolved, 730, "SOC2 preset must override default retention");
1170 }
1171
1172 #[test]
1179 fn audit_init_creates_log_file_in_fresh_directory() {
1180 let _g = sink_lock();
1181 let tmp = tempfile::tempdir().unwrap();
1182 let path = tmp.path().join("nested").join("audit.log");
1183 super::init(&path, true, false).unwrap();
1185 assert!(path.exists(), "init must create the log file");
1186 assert!(super::is_enabled());
1187 super::shutdown_for_test();
1188 }
1189
1190 #[test]
1191 fn audit_init_seeds_last_hash_from_existing_chain() {
1192 let _g = sink_lock();
1193 let tmp = tempfile::tempdir().unwrap();
1194 let path = tmp.path().join("audit.log");
1195
1196 let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1205 let e2 = sample_event(2, &e1.self_hash);
1206 let mut body = String::new();
1207 body.push_str(&serde_json::to_string(&e1).unwrap());
1208 body.push('\n');
1209 body.push_str(&serde_json::to_string(&e2).unwrap());
1210 body.push('\n');
1211 std::fs::write(&path, body).unwrap();
1212
1213 super::init(&path, true, false).unwrap();
1216
1217 super::emit(EventBuilder::new(
1219 AuditAction::Store,
1220 actor("ai:t@h", "explicit", None),
1221 target_memory("m3", "ns-x", Some("t".to_string()), None, None),
1222 ));
1223
1224 let body = std::fs::read_to_string(&path).unwrap();
1225 let third_line = body.lines().nth(2).expect("3rd line");
1226 let parsed: AuditEvent = serde_json::from_str(third_line).unwrap();
1227 assert_eq!(parsed.prev_hash, e2.self_hash, "chain must continue");
1228 super::shutdown_for_test();
1229 }
1230
1231 #[test]
1232 fn audit_init_skips_chain_tail_when_log_corrupted() {
1233 let _g = sink_lock();
1234 let tmp = tempfile::tempdir().unwrap();
1235 let path = tmp.path().join("audit.log");
1236 std::fs::write(&path, "{not valid json\n").unwrap();
1239 super::init(&path, true, false).unwrap();
1240 super::emit(EventBuilder::new(
1242 AuditAction::Store,
1243 actor("a", "explicit", None),
1244 target_memory("m", "ns", None, None, None),
1245 ));
1246 let body = std::fs::read_to_string(&path).unwrap();
1247 let last = body.lines().filter(|l| !l.is_empty()).last().unwrap();
1248 let parsed: AuditEvent = serde_json::from_str(last).unwrap();
1249 assert_eq!(parsed.prev_hash, CHAIN_HEAD_PREV_HASH);
1250 super::shutdown_for_test();
1251 }
1252
1253 #[test]
1254 fn audit_event_builder_error_outcome() {
1255 let b = EventBuilder::new(
1256 AuditAction::Store,
1257 actor("a", "explicit", None),
1258 target_memory("m", "ns", None, None, None),
1259 )
1260 .error("boom");
1261 assert_eq!(b.outcome, AuditOutcome::Error);
1262 assert_eq!(b.error.as_deref(), Some("boom"));
1263 }
1264
1265 #[test]
1266 fn audit_event_builder_error_caps_long_message() {
1267 let long = "x".repeat(1000);
1268 let b = EventBuilder::new(
1269 AuditAction::Store,
1270 actor("a", "explicit", None),
1271 target_memory("m", "ns", None, None, None),
1272 )
1273 .error(long);
1274 assert_eq!(b.error.as_ref().unwrap().chars().count(), 256);
1276 }
1277
1278 #[test]
1279 fn audit_event_builder_outcome_chain() {
1280 let b = EventBuilder::new(
1281 AuditAction::Store,
1282 actor("a", "explicit", None),
1283 target_memory("m", "ns", None, None, None),
1284 )
1285 .outcome(AuditOutcome::Deny);
1286 assert_eq!(b.outcome, AuditOutcome::Deny);
1287 }
1288
1289 #[test]
1290 fn audit_event_builder_auth_and_request_id() {
1291 let auth = AuditAuth {
1292 source_ip: Some("203.0.113.1".to_string()),
1293 mtls_fp: None,
1294 api_key_id_hash: Some("abc".to_string()),
1295 };
1296 let b = EventBuilder::new(
1297 AuditAction::Store,
1298 actor("a", "explicit", None),
1299 target_memory("m", "ns", None, None, None),
1300 )
1301 .auth(auth.clone())
1302 .request_id("req-123");
1303 assert_eq!(b.auth, Some(auth));
1304 assert_eq!(b.request_id.as_deref(), Some("req-123"));
1305 }
1306
1307 #[test]
1308 fn audit_init_from_config_disabled_clears_sink() {
1309 let _g = sink_lock();
1310 let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1312 super::init_for_test(buf);
1313 assert!(super::is_enabled());
1314
1315 let cfg = crate::config::AuditConfig {
1316 enabled: Some(false),
1317 ..Default::default()
1318 };
1319 super::init_from_config(&cfg).unwrap();
1320 assert!(!super::is_enabled());
1322 super::shutdown_for_test();
1323 }
1324
1325 #[test]
1326 fn audit_init_from_config_enabled_initialises_sink_at_resolved_path() {
1327 let _g = sink_lock();
1328 super::shutdown_for_test();
1329 let tmp = tempfile::tempdir().unwrap();
1330 let path = tmp.path().join("audit.log");
1331 let cfg = crate::config::AuditConfig {
1332 enabled: Some(true),
1333 path: Some(path.to_string_lossy().into_owned()),
1334 redact_content: Some(true),
1335 append_only: Some(false),
1339 ..Default::default()
1340 };
1341 super::init_from_config(&cfg).unwrap();
1342 assert!(super::is_enabled());
1343 assert!(path.exists(), "audit log file must be created");
1345 super::shutdown_for_test();
1346 }
1347
1348 #[test]
1349 fn audit_finalize_audit_file_keeps_explicit_file_path() {
1350 let cfg = crate::config::AuditConfig {
1351 enabled: Some(true),
1352 path: Some("/var/log/ai-memory/x.log".to_string()),
1353 ..Default::default()
1354 };
1355 let p = resolve_audit_path(&cfg);
1356 assert_eq!(p, PathBuf::from("/var/log/ai-memory/x.log"));
1358 }
1359
1360 #[test]
1361 fn audit_finalize_audit_file_appends_audit_log_for_dir_path() {
1362 let cfg = crate::config::AuditConfig {
1363 enabled: Some(true),
1364 path: Some("/var/log/ai-memory/".to_string()),
1365 ..Default::default()
1366 };
1367 let p = resolve_audit_path(&cfg);
1368 assert!(p.ends_with("audit.log"));
1369 }
1370
1371 #[test]
1372 fn audit_finalize_audit_file_appends_audit_log_for_extension_less_path() {
1373 let cfg = crate::config::AuditConfig {
1375 enabled: Some(true),
1376 path: Some("/var/log/aim_audit_dir".to_string()),
1377 ..Default::default()
1378 };
1379 let p = resolve_audit_path(&cfg);
1380 assert!(p.ends_with("audit.log"));
1381 }
1382
1383 #[test]
1384 fn audit_verify_detects_sequence_regression() {
1385 let e1 = sample_event(5, CHAIN_HEAD_PREV_HASH);
1388 let e2 = sample_event(5, &e1.self_hash);
1390 let mut buf = String::new();
1391 for ev in [&e1, &e2] {
1392 buf.push_str(&serde_json::to_string(ev).unwrap());
1393 buf.push('\n');
1394 }
1395 let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1396 let failure = report.first_failure.expect("sequence regression");
1397 assert!(matches!(failure.kind, VerifyFailureKind::Sequence));
1398 }
1399
1400 #[test]
1401 fn audit_verify_detects_malformed_json_line() {
1402 let buf = "this is not json\n";
1404 let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1405 let failure = report.first_failure.expect("parse failure");
1406 assert!(matches!(failure.kind, VerifyFailureKind::Parse));
1407 assert!(failure.detail.contains("malformed JSON"));
1408 }
1409
1410 #[test]
1411 fn audit_verify_skips_blank_lines() {
1412 let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1414 let e2 = sample_event(2, &e1.self_hash);
1415 let buf = format!(
1416 "\n{}\n\n{}\n\n",
1417 serde_json::to_string(&e1).unwrap(),
1418 serde_json::to_string(&e2).unwrap()
1419 );
1420 let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1421 assert!(report.first_failure.is_none());
1422 assert_eq!(report.total_lines, 2);
1423 }
1424
1425 #[test]
1426 fn audit_verify_report_into_result_ok() {
1427 let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1428 let report = verify_chain_from_reader(
1429 format!("{}\n", serde_json::to_string(&e1).unwrap()).as_bytes(),
1430 )
1431 .unwrap();
1432 let n = report.into_result().unwrap();
1433 assert_eq!(n, 1);
1434 }
1435
1436 #[test]
1437 fn audit_verify_report_into_result_err() {
1438 let report = VerifyReport {
1439 total_lines: 5,
1440 first_failure: Some(VerifyFailure {
1441 line_number: 3,
1442 kind: VerifyFailureKind::ChainBreak,
1443 detail: "x".to_string(),
1444 }),
1445 };
1446 let err = report.into_result().unwrap_err();
1447 let msg = format!("{err}");
1448 assert!(msg.contains("audit chain verification failed"));
1449 assert!(msg.contains("line 3"));
1450 }
1451
1452 #[test]
1453 fn audit_emit_records_request_id_and_auth() {
1454 let _g = sink_lock();
1455 let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1456 super::init_for_test(buf.clone());
1457 super::emit(
1458 EventBuilder::new(
1459 AuditAction::Store,
1460 actor("a", "explicit", None),
1461 target_memory("m", "ns", None, None, None),
1462 )
1463 .auth(AuditAuth {
1464 source_ip: Some("198.51.100.7".to_string()),
1465 mtls_fp: None,
1466 api_key_id_hash: None,
1467 })
1468 .request_id("trace-abc"),
1469 );
1470 let body = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1471 assert!(body.contains("\"request_id\":\"trace-abc\""), "got: {body}");
1472 assert!(body.contains("198.51.100.7"));
1473 super::shutdown_for_test();
1474 }
1475
1476 #[test]
1477 fn audit_emit_records_error_outcome() {
1478 let _g = sink_lock();
1479 let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1480 super::init_for_test(buf.clone());
1481 super::emit(
1482 EventBuilder::new(
1483 AuditAction::Store,
1484 actor("a", "explicit", None),
1485 target_memory("m", "ns", None, None, None),
1486 )
1487 .error("disk full"),
1488 );
1489 let body = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1490 assert!(body.contains("\"outcome\":\"error\""), "got: {body}");
1491 assert!(body.contains("\"error\":\"disk full\""), "got: {body}");
1492 super::shutdown_for_test();
1493 }
1494
1495 #[test]
1496 fn audit_expand_tilde_passthrough_when_no_tilde() {
1497 assert_eq!(super::expand_tilde("/abs/path"), "/abs/path");
1499 assert_eq!(super::expand_tilde("rel/path"), "rel/path");
1500 }
1501
1502 #[test]
1503 fn audit_target_sweep_uses_wildcard_id() {
1504 let t = super::target_sweep("ns-y");
1505 assert_eq!(t.memory_id, "*");
1506 assert_eq!(t.namespace, "ns-y");
1507 }
1508
1509 #[test]
1510 fn audit_target_memory_round_trips_optional_fields() {
1511 let t = super::target_memory(
1512 "mem-1",
1513 "ns-x",
1514 Some("title".to_string()),
1515 Some("long".to_string()),
1516 Some("team".to_string()),
1517 );
1518 assert_eq!(t.tier.as_deref(), Some("long"));
1519 assert_eq!(t.scope.as_deref(), Some("team"));
1520 }
1521}