1use std::fs::{File, OpenOptions};
40use std::io::{BufRead, BufReader, Read, Write};
41use std::path::{Path, PathBuf};
42use std::sync::Mutex;
43use std::sync::atomic::Ordering;
44
45use anyhow::{Context, Result, anyhow};
46use chrono::Utc;
47use serde::{Deserialize, Serialize};
48use sha2::{Digest, Sha256};
49
50use crate::runtime_context::RuntimeContext;
51
52pub(crate) const OP_CONSOLIDATE: &str = "consolidate";
56
57pub const SCHEMA_VERSION: u32 = 1;
63
64pub const CHAIN_HEAD_PREV_HASH: &str =
68 "0000000000000000000000000000000000000000000000000000000000000000";
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
73pub struct AuditEvent {
74 pub schema_version: u32,
76 pub timestamp: String,
78 pub sequence: u64,
80 pub actor: AuditActor,
81 pub action: AuditAction,
82 pub target: AuditTarget,
83 pub outcome: AuditOutcome,
84 #[serde(skip_serializing_if = "Option::is_none")]
87 pub auth: Option<AuditAuth>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub session_id: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub request_id: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
95 pub error: Option<String>,
96 pub prev_hash: String,
99 pub self_hash: String,
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105pub struct AuditActor {
106 pub agent_id: String,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub scope: Option<String>,
112 pub synthesis_source: String,
116}
117
118pub mod synthesis_sources {
125 pub const EXPLICIT: &str = "explicit";
127 pub const MCP_CLIENT_INFO: &str = "mcp_client_info";
129 pub const HOST_FALLBACK: &str = "host_fallback";
131 pub const HTTP_HEADER: &str = "http_header";
133 pub const DEFAULT_FALLBACK: &str = "default_fallback";
135}
136
137#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
140#[serde(rename_all = "snake_case")]
141pub enum AuditAction {
142 Recall,
143 Store,
144 Update,
145 Delete,
146 Link,
147 Promote,
148 Forget,
149 Consolidate,
150 Export,
151 Import,
152 Approve,
153 Reject,
154 SessionBoot,
155 CaptureLag,
161}
162
163impl AuditAction {
164 #[must_use]
166 pub fn as_str(&self) -> &'static str {
167 match self {
168 Self::Recall => "recall",
169 Self::Store => "store",
170 Self::Update => "update",
171 Self::Delete => "delete",
172 Self::Link => "link",
173 Self::Promote => "promote",
174 Self::Forget => "forget",
175 Self::Consolidate => OP_CONSOLIDATE,
176 Self::Export => "export",
177 Self::Import => "import",
178 Self::Approve => "approve",
179 Self::Reject => "reject",
180 Self::SessionBoot => "session_boot",
181 Self::CaptureLag => "capture_lag",
182 }
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
188pub struct AuditTarget {
189 pub memory_id: String,
192 pub namespace: String,
194 #[serde(skip_serializing_if = "Option::is_none")]
199 pub title: Option<String>,
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub tier: Option<String>,
203 #[serde(skip_serializing_if = "Option::is_none")]
205 pub scope: Option<String>,
206}
207
208#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
210#[serde(rename_all = "snake_case")]
211pub enum AuditOutcome {
212 Allow,
213 Deny,
214 Error,
215 Pending,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
221pub struct AuditAuth {
222 #[serde(skip_serializing_if = "Option::is_none")]
223 pub source_ip: Option<String>,
224 #[serde(skip_serializing_if = "Option::is_none")]
225 pub mtls_fp: Option<String>,
226 #[serde(skip_serializing_if = "Option::is_none")]
229 pub api_key_id_hash: Option<String>,
230}
231
232pub struct AuditSink {
248 inner: Mutex<SinkInner>,
249 #[allow(dead_code)]
250 redact_content: bool,
251}
252
253impl std::fmt::Debug for AuditSink {
254 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 f.debug_struct("AuditSink")
256 .field("redact_content", &self.redact_content)
257 .finish_non_exhaustive()
258 }
259}
260
261struct SinkInner {
262 writer: Box<dyn Write + Send>,
263 last_hash: String,
266 #[allow(dead_code)]
269 path: Option<PathBuf>,
270}
271
272pub fn init(path: &Path, redact_content: bool, append_only_hint: bool) -> Result<()> {
281 if let Some(parent) = path.parent()
282 && !parent.as_os_str().is_empty()
283 {
284 std::fs::create_dir_all(parent)
285 .with_context(|| format!("creating audit log dir {}", parent.display()))?;
286 }
287
288 let (last_hash, last_sequence) = match read_chain_tail(path) {
299 Ok(Some((hash, seq))) => (hash, seq),
300 _ => (CHAIN_HEAD_PREV_HASH.to_string(), 0),
301 };
302
303 let file = OpenOptions::new()
304 .create(true)
305 .append(true)
306 .open(path)
307 .with_context(|| format!("opening audit log {}", path.display()))?;
308
309 if append_only_hint {
310 if let Err(e) = mark_append_only(path) {
313 tracing::warn!(
314 "audit: append-only OS flag could not be set on {} ({e}); \
315 the hash chain remains the authoritative tamper-evidence",
316 path.display()
317 );
318 }
319 }
320
321 let sink = AuditSink {
322 inner: Mutex::new(SinkInner {
323 writer: Box::new(file),
324 last_hash,
325 path: Some(path.to_path_buf()),
326 }),
327 redact_content,
328 };
329
330 let audit = &RuntimeContext::global().audit;
331 audit.sequence.store(last_sequence, Ordering::SeqCst);
332 if let Ok(mut guard) = audit.sink.write() {
333 *guard = Some(std::sync::Arc::new(sink));
334 }
335 Ok(())
336}
337
338#[cfg(test)]
342pub fn init_for_test(buf: std::sync::Arc<Mutex<Vec<u8>>>) {
343 struct VecWriter(std::sync::Arc<Mutex<Vec<u8>>>);
344 impl Write for VecWriter {
345 fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
346 self.0
347 .lock()
348 .expect("test sink poisoned")
349 .extend_from_slice(data);
350 Ok(data.len())
351 }
352 fn flush(&mut self) -> std::io::Result<()> {
353 Ok(())
354 }
355 }
356 let sink = AuditSink {
357 inner: Mutex::new(SinkInner {
358 writer: Box::new(VecWriter(buf)),
359 last_hash: CHAIN_HEAD_PREV_HASH.to_string(),
360 path: None,
361 }),
362 redact_content: true,
363 };
364 let audit = &RuntimeContext::global().audit;
365 audit.sequence.store(0, Ordering::SeqCst);
366 if let Ok(mut guard) = audit.sink.write() {
367 *guard = Some(std::sync::Arc::new(sink));
368 }
369}
370
371#[cfg(test)]
377pub(crate) fn sink_test_lock() -> std::sync::MutexGuard<'static, ()> {
378 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
379 LOCK.get_or_init(|| std::sync::Mutex::new(()))
380 .lock()
381 .unwrap_or_else(|p| p.into_inner())
382}
383
384#[cfg(test)]
387pub fn shutdown_for_test() {
388 let audit = &RuntimeContext::global().audit;
389 if let Ok(mut guard) = audit.sink.write() {
390 *guard = None;
391 }
392 audit.sequence.store(0, Ordering::SeqCst);
393}
394
395fn read_chain_tail(path: &Path) -> Result<Option<(String, u64)>> {
417 if !path.exists() {
418 return Ok(None);
419 }
420 let file = File::open(path)?;
421 let reader = BufReader::new(file);
422 let mut last: Option<(String, u64)> = None;
423 let mut prior_seq: Option<u64> = None;
424 for line in reader.lines() {
425 let line = line?;
426 if line.trim().is_empty() {
427 continue;
428 }
429 if let Ok(ev) = serde_json::from_str::<AuditEvent>(&line) {
430 if let Some(prev) = prior_seq
436 && prev >= ev.sequence
437 {
438 tracing::warn!(
439 target: "ai_memory::audit",
440 prior_seq = prev,
441 this_seq = ev.sequence,
442 path = %path.display(),
443 "audit: out-of-order sequence number detected on init scan \
444 (prior {prev} >= this {this}). Hash-chain integrity is the \
445 authoritative tamper signal; verify with `ai-memory audit verify`.",
446 prev = prev,
447 this = ev.sequence
448 );
449 }
450 prior_seq = Some(ev.sequence);
451 last = Some((ev.self_hash, ev.sequence));
452 }
453 }
454 Ok(last)
455}
456
457#[must_use]
459pub fn is_enabled() -> bool {
460 RuntimeContext::global()
461 .audit
462 .sink
463 .read()
464 .map(|g| g.is_some())
465 .unwrap_or(false)
466}
467
468fn compute_self_hash(ev: &AuditEvent) -> String {
477 let canonical = canonical_json_for_hash(ev);
478 let mut hasher = Sha256::new();
479 hasher.update(canonical.as_bytes());
480 hex_encode(&hasher.finalize())
481}
482
483fn canonical_json_for_hash(ev: &AuditEvent) -> String {
488 let mut clone = ev.clone();
489 clone.self_hash.clear();
490 serde_json::to_string(&clone).expect("AuditEvent always serializes")
491}
492
493fn hex_encode(bytes: &[u8]) -> String {
494 static HEX: &[u8; 16] = b"0123456789abcdef";
495 let mut out = String::with_capacity(bytes.len() * 2);
496 for b in bytes {
497 out.push(HEX[(b >> 4) as usize] as char);
498 out.push(HEX[(b & 0x0f) as usize] as char);
499 }
500 out
501}
502
503#[derive(Debug, Clone)]
512pub struct EventBuilder {
513 pub action: AuditAction,
514 pub actor: AuditActor,
515 pub target: AuditTarget,
516 pub outcome: AuditOutcome,
517 pub auth: Option<AuditAuth>,
518 pub session_id: Option<String>,
519 pub request_id: Option<String>,
520 pub error: Option<String>,
521}
522
523impl EventBuilder {
524 #[must_use]
527 pub fn new(action: AuditAction, actor: AuditActor, target: AuditTarget) -> Self {
528 Self {
529 action,
530 actor,
531 target,
532 outcome: AuditOutcome::Allow,
533 auth: None,
534 session_id: None,
535 request_id: None,
536 error: None,
537 }
538 }
539
540 #[must_use]
542 pub fn outcome(mut self, outcome: AuditOutcome) -> Self {
543 self.outcome = outcome;
544 self
545 }
546
547 #[must_use]
550 pub fn error(mut self, msg: impl Into<String>) -> Self {
551 self.error = Some(sanitize_field(&msg.into(), 256));
552 self.outcome = AuditOutcome::Error;
553 self
554 }
555
556 #[must_use]
557 pub fn auth(mut self, auth: AuditAuth) -> Self {
558 self.auth = Some(auth);
559 self
560 }
561
562 #[must_use]
563 pub fn request_id(mut self, id: impl Into<String>) -> Self {
564 self.request_id = Some(id.into());
565 self
566 }
567}
568
569pub fn emit(builder: EventBuilder) {
573 if let Err(e) = try_emit(builder) {
574 tracing::error!("audit: emission failed: {e}");
575 }
576}
577
578fn try_emit(builder: EventBuilder) -> Result<()> {
581 let audit = &RuntimeContext::global().audit;
582 let sink = {
583 let guard = audit
584 .sink
585 .read()
586 .map_err(|_| anyhow!("audit sink rwlock poisoned"))?;
587 match guard.as_ref() {
588 Some(s) => s.clone(),
589 None => return Ok(()),
590 }
591 };
592
593 let mut inner = sink
594 .inner
595 .lock()
596 .map_err(|_| anyhow!("audit sink mutex poisoned"))?;
597
598 let sequence = audit.sequence.fetch_add(1, Ordering::SeqCst) + 1;
599
600 let mut ev = AuditEvent {
601 schema_version: SCHEMA_VERSION,
602 timestamp: Utc::now().to_rfc3339(),
603 sequence,
604 actor: builder.actor,
605 action: builder.action,
606 target: AuditTarget {
607 memory_id: sanitize_field(&builder.target.memory_id, 128),
608 namespace: sanitize_field(&builder.target.namespace, 128),
609 title: builder.target.title.map(|t| sanitize_field(&t, 200)),
610 tier: builder.target.tier,
611 scope: builder.target.scope,
612 },
613 outcome: builder.outcome,
614 auth: builder.auth,
615 session_id: builder.session_id,
616 request_id: builder.request_id,
617 error: builder.error,
618 prev_hash: inner.last_hash.clone(),
619 self_hash: String::new(),
620 };
621
622 let self_hash = compute_self_hash(&ev);
623 ev.self_hash = self_hash.clone();
624
625 let line = serde_json::to_string(&ev).context("serializing audit event")?;
626 writeln!(inner.writer, "{line}").context("appending audit line")?;
627 inner.writer.flush().ok();
628 inner.last_hash = self_hash;
629 Ok(())
630}
631
632fn sanitize_field(s: &str, max_chars: usize) -> String {
636 let cleaned: String = s
637 .chars()
638 .filter(|c| !c.is_control() || *c == '\t')
639 .collect();
640 if cleaned.chars().count() <= max_chars {
641 cleaned
642 } else {
643 cleaned.chars().take(max_chars).collect()
644 }
645}
646
647#[must_use]
655pub fn actor(
656 agent_id: impl Into<String>,
657 synthesis_source: impl Into<String>,
658 scope: Option<String>,
659) -> AuditActor {
660 AuditActor {
661 agent_id: agent_id.into(),
662 synthesis_source: synthesis_source.into(),
663 scope,
664 }
665}
666
667#[must_use]
669pub fn target_memory(
670 memory_id: impl Into<String>,
671 namespace: impl Into<String>,
672 title: Option<String>,
673 tier: Option<String>,
674 scope: Option<String>,
675) -> AuditTarget {
676 AuditTarget {
677 memory_id: memory_id.into(),
678 namespace: namespace.into(),
679 title,
680 tier,
681 scope,
682 }
683}
684
685#[must_use]
687pub fn target_sweep(namespace: impl Into<String>) -> AuditTarget {
688 AuditTarget {
689 memory_id: "*".to_string(),
690 namespace: namespace.into(),
691 title: None,
692 tier: None,
693 scope: None,
694 }
695}
696
697#[derive(Debug, Clone, PartialEq, Eq)]
703pub struct VerifyReport {
704 pub total_lines: u64,
705 pub first_failure: Option<VerifyFailure>,
706}
707
708#[derive(Debug, Clone, PartialEq, Eq)]
709pub struct VerifyFailure {
710 pub line_number: u64,
711 pub kind: VerifyFailureKind,
712 pub detail: String,
713}
714
715#[derive(Debug, Clone, PartialEq, Eq)]
742pub enum VerifyFailureKind {
743 Parse,
745 SelfHash,
747 ChainBreak,
749 Sequence,
751}
752
753impl VerifyReport {
754 pub fn into_result(self) -> Result<u64> {
756 if let Some(failure) = self.first_failure {
757 Err(anyhow!(
758 "audit chain verification failed at line {}: {:?} — {}",
759 failure.line_number,
760 failure.kind,
761 failure.detail
762 ))
763 } else {
764 Ok(self.total_lines)
765 }
766 }
767}
768
769pub fn verify_chain(path: &Path) -> Result<VerifyReport> {
776 let file = File::open(path).with_context(|| crate::errors::msg::opening(path.display()))?;
777 verify_chain_from_reader(file)
778}
779
780pub fn verify_chain_from_reader<R: Read>(reader: R) -> Result<VerifyReport> {
783 let buf = BufReader::new(reader);
784 let mut total: u64 = 0;
785 let mut prev_hash = CHAIN_HEAD_PREV_HASH.to_string();
786 let mut prev_seq: u64 = 0;
787
788 for (idx, line) in buf.lines().enumerate() {
789 let line_no = (idx as u64) + 1;
790 let line = line.with_context(|| format!("reading audit line {line_no}"))?;
791 if line.trim().is_empty() {
792 continue;
793 }
794 total += 1;
795
796 let ev: AuditEvent = match serde_json::from_str(&line) {
797 Ok(e) => e,
798 Err(e) => {
799 return Ok(VerifyReport {
800 total_lines: total,
801 first_failure: Some(VerifyFailure {
802 line_number: line_no,
803 kind: VerifyFailureKind::Parse,
804 detail: format!("malformed JSON: {e}"),
805 }),
806 });
807 }
808 };
809
810 if ev.prev_hash != prev_hash {
811 return Ok(VerifyReport {
812 total_lines: total,
813 first_failure: Some(VerifyFailure {
814 line_number: line_no,
815 kind: VerifyFailureKind::ChainBreak,
816 detail: format!(
817 "prev_hash mismatch: expected {prev_hash}, got {}",
818 ev.prev_hash
819 ),
820 }),
821 });
822 }
823
824 if ev.sequence <= prev_seq && prev_seq != 0 {
825 return Ok(VerifyReport {
826 total_lines: total,
827 first_failure: Some(VerifyFailure {
828 line_number: line_no,
829 kind: VerifyFailureKind::Sequence,
830 detail: format!(
831 "sequence not monotonic: prior={prev_seq}, this={}",
832 ev.sequence
833 ),
834 }),
835 });
836 }
837
838 let recomputed = compute_self_hash(&ev);
839 if recomputed != ev.self_hash {
840 return Ok(VerifyReport {
841 total_lines: total,
842 first_failure: Some(VerifyFailure {
843 line_number: line_no,
844 kind: VerifyFailureKind::SelfHash,
845 detail: format!(
846 "self_hash mismatch: stored={}, recomputed={}",
847 ev.self_hash, recomputed
848 ),
849 }),
850 });
851 }
852
853 prev_hash = ev.self_hash.clone();
854 prev_seq = ev.sequence;
855 }
856
857 Ok(VerifyReport {
858 total_lines: total,
859 first_failure: None,
860 })
861}
862
863pub fn init_from_config(cfg: &crate::config::AuditConfig) -> Result<()> {
874 if !cfg.enabled.unwrap_or(false) {
875 if let Ok(mut guard) = RuntimeContext::global().audit.sink.write() {
876 *guard = None;
877 }
878 return Ok(());
879 }
880 let resolved_path = resolve_audit_path(cfg);
881 init(
882 &resolved_path,
883 cfg.redact_content.unwrap_or(true),
884 cfg.append_only.unwrap_or(true),
885 )
886}
887
888#[must_use]
896pub fn resolve_audit_path(cfg: &crate::config::AuditConfig) -> PathBuf {
897 let resolved = crate::log_paths::resolve_audit_dir(None, cfg.path.as_deref())
898 .map(|r| r.path)
899 .unwrap_or_else(|_| {
900 crate::log_paths::platform_default(crate::log_paths::DirKind::Audit).path
901 });
902 finalize_audit_file(resolved, cfg.path.as_deref())
903}
904
905pub fn resolve_audit_path_with_override(
913 cli_override: Option<&Path>,
914 cfg: &crate::config::AuditConfig,
915) -> Result<(PathBuf, crate::log_paths::PathSource)> {
916 let r = crate::log_paths::resolve_audit_dir(cli_override, cfg.path.as_deref())?;
917 let final_path = finalize_audit_file(r.path, cfg.path.as_deref());
918 Ok((final_path, r.source))
919}
920
921fn finalize_audit_file(p: PathBuf, raw_config: Option<&str>) -> PathBuf {
924 if let Some(raw) = raw_config
927 && !raw.ends_with('/')
928 && std::path::Path::new(raw).extension().is_some()
929 {
930 return p;
931 }
932 if p.extension().is_none() || p.to_string_lossy().ends_with('/') {
933 p.join("audit.log")
934 } else {
935 p
936 }
937}
938
939pub(crate) fn expand_tilde(raw: &str) -> String {
940 if let Some(rest) = raw.strip_prefix("~/")
941 && let Ok(home) = std::env::var("HOME")
942 {
943 return format!("{home}/{rest}");
944 }
945 raw.to_string()
946}
947
948#[cfg(unix)]
955fn mark_append_only(path: &Path) -> Result<()> {
956 use std::ffi::CString;
957 use std::os::unix::ffi::OsStrExt;
958
959 let c_path =
960 CString::new(path.as_os_str().as_bytes()).context("path contains an interior NUL byte")?;
961 #[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd"))]
962 {
963 let rc = unsafe { libc::chflags(c_path.as_ptr(), libc::UF_APPEND.into()) };
967 if rc != 0 {
968 return Err(anyhow!(
969 "chflags(UF_APPEND) failed: errno={}",
970 std::io::Error::last_os_error()
971 ));
972 }
973 return Ok(());
974 }
975 #[cfg(target_os = "linux")]
976 {
977 const FS_APPEND_FL: libc::c_int = 0x0000_0020;
983 const FS_IOC_SETFLAGS: libc::c_ulong = 0x4008_6602;
987 let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) };
988 if fd < 0 {
989 return Err(anyhow!(
990 "open(audit log) for ioctl failed: errno={}",
991 std::io::Error::last_os_error()
992 ));
993 }
994 let mut flags: libc::c_int = 0;
995 let rc = unsafe { libc::ioctl(fd, FS_IOC_SETFLAGS, &mut flags) };
999 if rc == 0 {
1000 flags |= FS_APPEND_FL;
1001 let rc2 = unsafe { libc::ioctl(fd, FS_IOC_SETFLAGS, &mut flags) };
1002 unsafe { libc::close(fd) };
1003 if rc2 != 0 {
1004 return Err(anyhow!(
1005 "ioctl(FS_IOC_SETFLAGS) failed: errno={}",
1006 std::io::Error::last_os_error()
1007 ));
1008 }
1009 return Ok(());
1010 }
1011 unsafe { libc::close(fd) };
1012 Err(anyhow!(
1013 "ioctl(FS_IOC_GETFLAGS) failed: errno={}",
1014 std::io::Error::last_os_error()
1015 ))
1016 }
1017 #[cfg(not(any(
1018 target_os = "macos",
1019 target_os = "freebsd",
1020 target_os = "openbsd",
1021 target_os = "linux"
1022 )))]
1023 {
1024 let _ = c_path;
1025 Err(anyhow!(
1026 "append-only flag not supported on this unix variant"
1027 ))
1028 }
1029}
1030
1031#[cfg(not(unix))]
1032fn mark_append_only(_path: &Path) -> Result<()> {
1033 Err(anyhow!("append-only flag is unix-only"))
1034}
1035
1036#[cfg(test)]
1041mod tests {
1042 use super::*;
1043 use crate::models::Tier;
1044
1045 fn sample_event(seq: u64, prev: &str) -> AuditEvent {
1046 let mut ev = AuditEvent {
1047 schema_version: SCHEMA_VERSION,
1048 timestamp: "2026-04-30T00:00:00+00:00".to_string(),
1049 sequence: seq,
1050 actor: actor("ai:test@host:pid-1", "host_fallback", None),
1051 action: AuditAction::Store,
1052 target: target_memory(
1053 format!("mem-{seq}"),
1054 "ns-x",
1055 Some("title".to_string()),
1056 Some(Tier::Mid.as_str().to_string()),
1057 None,
1058 ),
1059 outcome: AuditOutcome::Allow,
1060 auth: None,
1061 session_id: None,
1062 request_id: None,
1063 error: None,
1064 prev_hash: prev.to_string(),
1065 self_hash: String::new(),
1066 };
1067 ev.self_hash = compute_self_hash(&ev);
1068 ev
1069 }
1070
1071 #[test]
1072 fn audit_event_round_trips_through_serde() {
1073 let ev = sample_event(1, CHAIN_HEAD_PREV_HASH);
1074 let s = serde_json::to_string(&ev).unwrap();
1075 let back: AuditEvent = serde_json::from_str(&s).unwrap();
1076 assert_eq!(back, ev);
1077 assert_eq!(back.schema_version, SCHEMA_VERSION);
1078 }
1079
1080 #[test]
1081 fn audit_chain_links_correctly_for_three_events() {
1082 let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1083 let e2 = sample_event(2, &e1.self_hash);
1084 let e3 = sample_event(3, &e2.self_hash);
1085 let mut buf = String::new();
1086 for ev in [&e1, &e2, &e3] {
1087 buf.push_str(&serde_json::to_string(ev).unwrap());
1088 buf.push('\n');
1089 }
1090 let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1091 assert!(report.first_failure.is_none(), "{:?}", report.first_failure);
1092 assert_eq!(report.total_lines, 3);
1093 }
1094
1095 #[test]
1096 fn audit_verify_detects_tampered_line() {
1097 let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1098 let mut e2 = sample_event(2, &e1.self_hash);
1099 e2.target.title = Some("EVIL".to_string());
1101 let e3 = sample_event(3, &e2.self_hash);
1102 let mut buf = String::new();
1103 for ev in [&e1, &e2, &e3] {
1104 buf.push_str(&serde_json::to_string(ev).unwrap());
1105 buf.push('\n');
1106 }
1107 let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1108 let failure = report.first_failure.expect("tampering must be detected");
1109 assert_eq!(failure.line_number, 2);
1110 assert!(matches!(failure.kind, VerifyFailureKind::SelfHash));
1111 }
1112
1113 #[test]
1114 fn audit_verify_detects_chain_break() {
1115 let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1116 let e2 = sample_event(2, "deadbeef");
1118 let mut buf = String::new();
1119 for ev in [&e1, &e2] {
1120 buf.push_str(&serde_json::to_string(ev).unwrap());
1121 buf.push('\n');
1122 }
1123 let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1124 let failure = report.first_failure.expect("chain break must be detected");
1125 assert!(matches!(failure.kind, VerifyFailureKind::ChainBreak));
1126 }
1127
1128 #[test]
1129 fn audit_redacts_content_by_default() {
1130 let ev = sample_event(1, CHAIN_HEAD_PREV_HASH);
1135 let json = serde_json::to_value(&ev).unwrap();
1136 assert!(
1137 json.get("content").is_none(),
1138 "AuditEvent must never carry a content field"
1139 );
1140 assert!(
1141 json["target"].get("content").is_none(),
1142 "AuditTarget must never carry a content field"
1143 );
1144 }
1145
1146 #[test]
1147 fn audit_action_as_str_round_trips() {
1148 for action in [
1149 AuditAction::Recall,
1150 AuditAction::Store,
1151 AuditAction::Update,
1152 AuditAction::Delete,
1153 AuditAction::Link,
1154 AuditAction::Promote,
1155 AuditAction::Forget,
1156 AuditAction::Consolidate,
1157 AuditAction::Export,
1158 AuditAction::Import,
1159 AuditAction::Approve,
1160 AuditAction::Reject,
1161 AuditAction::SessionBoot,
1162 ] {
1163 let s = action.as_str();
1164 let v: serde_json::Value = serde_json::to_value(action).unwrap();
1167 assert_eq!(v.as_str().unwrap(), s);
1168 }
1169 }
1170
1171 #[test]
1172 fn audit_sanitize_strips_newlines() {
1173 let cleaned = sanitize_field("line1\nline2\rline3", 32);
1174 assert!(!cleaned.contains('\n'));
1175 assert!(!cleaned.contains('\r'));
1176 }
1177
1178 #[test]
1179 fn audit_sanitize_caps_length() {
1180 let s = "x".repeat(500);
1181 let cleaned = sanitize_field(&s, 100);
1182 assert_eq!(cleaned.chars().count(), 100);
1183 }
1184
1185 #[test]
1186 fn audit_resolve_path_directory_expands_to_file() {
1187 let cfg = crate::config::AuditConfig {
1188 enabled: Some(true),
1189 path: Some("/tmp/ai-memory/audit/".to_string()),
1190 ..Default::default()
1191 };
1192 let p = resolve_audit_path(&cfg);
1193 assert!(p.ends_with("audit.log"));
1194 }
1195
1196 #[test]
1197 fn audit_resolve_path_explicit_file_kept() {
1198 let cfg = crate::config::AuditConfig {
1199 enabled: Some(true),
1200 path: Some("/var/log/ai-memory/custom.log".to_string()),
1201 ..Default::default()
1202 };
1203 let p = resolve_audit_path(&cfg);
1204 assert_eq!(p, PathBuf::from("/var/log/ai-memory/custom.log"));
1205 }
1206
1207 fn sink_lock() -> std::sync::MutexGuard<'static, ()> {
1211 super::sink_test_lock()
1212 }
1213
1214 #[test]
1219 fn audit_emits_at_every_call_site() {
1220 let _g = sink_lock();
1221 let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1222 super::init_for_test(buf.clone());
1223
1224 let actions = [
1225 AuditAction::Store,
1226 AuditAction::Recall,
1227 AuditAction::Update,
1228 AuditAction::Delete,
1229 AuditAction::Link,
1230 AuditAction::Promote,
1231 AuditAction::Forget,
1232 AuditAction::Consolidate,
1233 AuditAction::Export,
1234 AuditAction::Import,
1235 AuditAction::Approve,
1236 AuditAction::Reject,
1237 AuditAction::SessionBoot,
1238 AuditAction::CaptureLag,
1239 ];
1240 for (i, action) in actions.iter().copied().enumerate() {
1241 let id = format!("mem-{i}");
1242 super::emit(EventBuilder::new(
1243 action,
1244 actor("ai:test@host", "explicit", None),
1245 target_memory(id, "ns-x", Some("t".to_string()), None, None),
1246 ));
1247 }
1248
1249 let lines = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1250 let count = lines.lines().filter(|l| !l.is_empty()).count();
1251 assert_eq!(
1252 count,
1253 actions.len(),
1254 "expected one audit line per action, got {count}: {lines}"
1255 );
1256 let report = verify_chain_from_reader(lines.as_bytes()).unwrap();
1258 assert!(
1259 report.first_failure.is_none(),
1260 "chain must verify across all call sites; failure: {:?}",
1261 report.first_failure
1262 );
1263 assert_eq!(report.total_lines as usize, actions.len());
1264
1265 super::shutdown_for_test();
1266 }
1267
1268 #[test]
1269 fn audit_emit_is_noop_when_disabled() {
1270 let _g = sink_lock();
1271 super::shutdown_for_test();
1272 super::emit(EventBuilder::new(
1275 AuditAction::Store,
1276 actor("a", "explicit", None),
1277 target_memory("m", "ns", None, None, None),
1278 ));
1279 assert!(!super::is_enabled());
1281 }
1282
1283 #[test]
1284 fn audit_compliance_preset_soc2_overrides_retention() {
1285 let cfg = crate::config::AuditConfig {
1290 enabled: Some(true),
1291 retention_days: Some(90),
1292 compliance: Some(crate::config::AuditComplianceConfig {
1293 soc2: Some(crate::config::CompliancePreset {
1294 applied: Some(true),
1295 retention_days: Some(730),
1296 redact_content: Some(true),
1297 attestation_cadence_minutes: Some(60),
1298 encrypt_at_rest: None,
1299 pseudonymize_actors: None,
1300 }),
1301 ..Default::default()
1302 }),
1303 ..Default::default()
1304 };
1305 let resolved = cfg.effective_retention_days();
1306 assert_eq!(resolved, 730, "SOC2 preset must override default retention");
1307 }
1308
1309 #[test]
1316 fn audit_init_creates_log_file_in_fresh_directory() {
1317 let _g = sink_lock();
1318 let tmp = tempfile::tempdir().unwrap();
1319 let path = tmp.path().join("nested").join("audit.log");
1320 super::init(&path, true, false).unwrap();
1322 assert!(path.exists(), "init must create the log file");
1323 assert!(super::is_enabled());
1324 super::shutdown_for_test();
1325 }
1326
1327 #[test]
1328 fn audit_init_seeds_last_hash_from_existing_chain() {
1329 let _g = sink_lock();
1330 let tmp = tempfile::tempdir().unwrap();
1331 let path = tmp.path().join("audit.log");
1332
1333 let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1343 let e2 = sample_event(2, &e1.self_hash);
1344 let mut body = String::new();
1345 body.push_str(&serde_json::to_string(&e1).unwrap());
1346 body.push('\n');
1347 body.push_str(&serde_json::to_string(&e2).unwrap());
1348 body.push('\n');
1349 std::fs::write(&path, body).unwrap();
1350
1351 super::init(&path, true, false).unwrap();
1354
1355 super::emit(EventBuilder::new(
1357 AuditAction::Store,
1358 actor("ai:t@h", "explicit", None),
1359 target_memory("m3", "ns-x", Some("t".to_string()), None, None),
1360 ));
1361
1362 let body = std::fs::read_to_string(&path).unwrap();
1363 let third_line = body.lines().nth(2).expect("3rd line");
1364 let parsed: AuditEvent = serde_json::from_str(third_line).unwrap();
1365 assert_eq!(parsed.prev_hash, e2.self_hash, "chain must continue");
1366 super::shutdown_for_test();
1367 }
1368
1369 #[test]
1378 fn audit_init_seeds_sequence_from_existing_chain_tail() {
1379 let _g = sink_lock();
1380 let tmp = tempfile::tempdir().unwrap();
1381 let path = tmp.path().join("audit.log");
1382
1383 super::init(&path, true, false).unwrap();
1387 for i in 0..5 {
1388 super::emit(EventBuilder::new(
1389 AuditAction::Store,
1390 actor("ai:writer", "explicit", None),
1391 target_memory(&format!("m{i}"), "ns", Some(format!("t{i}")), None, None),
1392 ));
1393 }
1394
1395 let body = std::fs::read_to_string(&path).unwrap();
1397 let lines: Vec<_> = body.lines().filter(|l| !l.is_empty()).collect();
1398 assert_eq!(lines.len(), 5, "phase 1 must emit 5 events");
1399 for (i, line) in lines.iter().enumerate() {
1400 let ev: AuditEvent = serde_json::from_str(line).unwrap();
1401 #[allow(clippy::cast_possible_truncation)]
1402 let expected = (i as u64) + 1;
1403 assert_eq!(
1404 ev.sequence, expected,
1405 "phase 1 event {i} must have sequence {expected}"
1406 );
1407 }
1408
1409 super::shutdown_for_test();
1412 super::init(&path, true, false).unwrap();
1413
1414 super::emit(EventBuilder::new(
1417 AuditAction::Store,
1418 actor("ai:writer", "explicit", None),
1419 target_memory("m6", "ns", Some("t6".to_string()), None, None),
1420 ));
1421
1422 let body = std::fs::read_to_string(&path).unwrap();
1423 let lines: Vec<_> = body.lines().filter(|l| !l.is_empty()).collect();
1424 assert_eq!(lines.len(), 6, "phase 2 must append a 6th event");
1425
1426 let last: AuditEvent = serde_json::from_str(lines[5]).unwrap();
1427 assert_eq!(
1428 last.sequence, 6,
1429 "F2: post-restart event must continue sequence from disk (got {}, expected 6)",
1430 last.sequence,
1431 );
1432
1433 let fifth: AuditEvent = serde_json::from_str(lines[4]).unwrap();
1435 assert_eq!(
1436 last.prev_hash, fifth.self_hash,
1437 "F2 must not regress hash-chain continuity"
1438 );
1439 super::shutdown_for_test();
1440 }
1441
1442 #[test]
1443 fn audit_init_skips_chain_tail_when_log_corrupted() {
1444 let _g = sink_lock();
1445 let tmp = tempfile::tempdir().unwrap();
1446 let path = tmp.path().join("audit.log");
1447 std::fs::write(&path, "{not valid json\n").unwrap();
1450 super::init(&path, true, false).unwrap();
1451 super::emit(EventBuilder::new(
1453 AuditAction::Store,
1454 actor("a", "explicit", None),
1455 target_memory("m", "ns", None, None, None),
1456 ));
1457 let body = std::fs::read_to_string(&path).unwrap();
1458 let last = body.lines().filter(|l| !l.is_empty()).last().unwrap();
1459 let parsed: AuditEvent = serde_json::from_str(last).unwrap();
1460 assert_eq!(parsed.prev_hash, CHAIN_HEAD_PREV_HASH);
1461 super::shutdown_for_test();
1462 }
1463
1464 #[test]
1471 fn audit_init_warns_on_out_of_order_sequence() {
1472 let _g = sink_lock();
1473 let tmp = tempfile::tempdir().unwrap();
1474 let path = tmp.path().join("audit.log");
1475
1476 let make_event = |seq: u64| AuditEvent {
1480 schema_version: SCHEMA_VERSION,
1481 timestamp: "2026-05-10T00:00:00Z".to_string(),
1482 sequence: seq,
1483 actor: AuditActor {
1484 agent_id: "ai:test".to_string(),
1485 scope: None,
1486 synthesis_source: "explicit".to_string(),
1487 },
1488 action: AuditAction::Store,
1489 target: AuditTarget {
1490 memory_id: format!("m-seq-{seq}"),
1491 namespace: "ns".to_string(),
1492 title: None,
1493 tier: None,
1494 scope: None,
1495 },
1496 outcome: AuditOutcome::Allow,
1497 auth: None,
1498 session_id: None,
1499 request_id: None,
1500 error: None,
1501 prev_hash: CHAIN_HEAD_PREV_HASH.to_string(),
1502 self_hash: format!("{seq:064x}"),
1503 };
1504
1505 let line_a = serde_json::to_string(&make_event(2)).unwrap();
1506 let line_b = serde_json::to_string(&make_event(1)).unwrap();
1507 std::fs::write(&path, format!("{line_a}\n{line_b}\n")).unwrap();
1508
1509 #[derive(Clone, Default)]
1511 struct WarnSink(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
1512 impl std::io::Write for WarnSink {
1513 fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
1514 self.0.lock().unwrap().extend_from_slice(b);
1515 Ok(b.len())
1516 }
1517 fn flush(&mut self) -> std::io::Result<()> {
1518 Ok(())
1519 }
1520 }
1521 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for WarnSink {
1522 type Writer = WarnSink;
1523 fn make_writer(&'a self) -> Self::Writer {
1524 self.clone()
1525 }
1526 }
1527 let sink = WarnSink::default();
1528 let buf = sink.0.clone();
1529 let subscriber = tracing_subscriber::fmt()
1530 .with_max_level(tracing::Level::WARN)
1531 .with_writer(sink)
1532 .without_time()
1533 .finish();
1534
1535 tracing::subscriber::with_default(subscriber, || {
1536 super::init(&path, true, false)
1537 .expect("M14: init must succeed despite out-of-order seqs");
1538 });
1539 let captured = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1540 assert!(
1541 captured.contains("out-of-order sequence"),
1542 "M14: expected out-of-order WARN, got: {captured:?}"
1543 );
1544 assert!(
1546 captured.contains("prior 2"),
1547 "M14: WARN must include prior sequence (=2), got: {captured:?}"
1548 );
1549 assert!(
1550 captured.contains("this 1"),
1551 "M14: WARN must include this sequence (=1), got: {captured:?}"
1552 );
1553
1554 super::emit(EventBuilder::new(
1557 AuditAction::Store,
1558 actor("ai:writer", "explicit", None),
1559 target_memory("m-after-warn", "ns", None, None, None),
1560 ));
1561 let body = std::fs::read_to_string(&path).unwrap();
1562 let lines: Vec<_> = body.lines().filter(|l| !l.is_empty()).collect();
1563 assert_eq!(
1564 lines.len(),
1565 3,
1566 "M14: init must accept the file and emit must still work"
1567 );
1568 super::shutdown_for_test();
1569 }
1570
1571 #[test]
1572 fn audit_event_builder_error_outcome() {
1573 let b = EventBuilder::new(
1574 AuditAction::Store,
1575 actor("a", "explicit", None),
1576 target_memory("m", "ns", None, None, None),
1577 )
1578 .error("boom");
1579 assert_eq!(b.outcome, AuditOutcome::Error);
1580 assert_eq!(b.error.as_deref(), Some("boom"));
1581 }
1582
1583 #[test]
1584 fn audit_event_builder_error_caps_long_message() {
1585 let long = "x".repeat(1000);
1586 let b = EventBuilder::new(
1587 AuditAction::Store,
1588 actor("a", "explicit", None),
1589 target_memory("m", "ns", None, None, None),
1590 )
1591 .error(long);
1592 assert_eq!(b.error.as_ref().unwrap().chars().count(), 256);
1594 }
1595
1596 #[test]
1597 fn audit_event_builder_outcome_chain() {
1598 let b = EventBuilder::new(
1599 AuditAction::Store,
1600 actor("a", "explicit", None),
1601 target_memory("m", "ns", None, None, None),
1602 )
1603 .outcome(AuditOutcome::Deny);
1604 assert_eq!(b.outcome, AuditOutcome::Deny);
1605 }
1606
1607 #[test]
1608 fn audit_event_builder_auth_and_request_id() {
1609 let auth = AuditAuth {
1610 source_ip: Some("203.0.113.1".to_string()),
1611 mtls_fp: None,
1612 api_key_id_hash: Some("abc".to_string()),
1613 };
1614 let b = EventBuilder::new(
1615 AuditAction::Store,
1616 actor("a", "explicit", None),
1617 target_memory("m", "ns", None, None, None),
1618 )
1619 .auth(auth.clone())
1620 .request_id("req-123");
1621 assert_eq!(b.auth, Some(auth));
1622 assert_eq!(b.request_id.as_deref(), Some("req-123"));
1623 }
1624
1625 #[test]
1626 fn audit_init_from_config_disabled_clears_sink() {
1627 let _g = sink_lock();
1628 let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1630 super::init_for_test(buf);
1631 assert!(super::is_enabled());
1632
1633 let cfg = crate::config::AuditConfig {
1634 enabled: Some(false),
1635 ..Default::default()
1636 };
1637 super::init_from_config(&cfg).unwrap();
1638 assert!(!super::is_enabled());
1640 super::shutdown_for_test();
1641 }
1642
1643 #[test]
1644 fn audit_init_from_config_enabled_initialises_sink_at_resolved_path() {
1645 let _g = sink_lock();
1646 super::shutdown_for_test();
1647 let tmp = tempfile::tempdir().unwrap();
1648 let path = tmp.path().join("audit.log");
1649 let cfg = crate::config::AuditConfig {
1650 enabled: Some(true),
1651 path: Some(path.to_string_lossy().into_owned()),
1652 redact_content: Some(true),
1653 append_only: Some(false),
1657 ..Default::default()
1658 };
1659 super::init_from_config(&cfg).unwrap();
1660 assert!(super::is_enabled());
1661 assert!(path.exists(), "audit log file must be created");
1663 super::shutdown_for_test();
1664 }
1665
1666 #[test]
1667 fn audit_finalize_audit_file_keeps_explicit_file_path() {
1668 let cfg = crate::config::AuditConfig {
1669 enabled: Some(true),
1670 path: Some("/var/log/ai-memory/x.log".to_string()),
1671 ..Default::default()
1672 };
1673 let p = resolve_audit_path(&cfg);
1674 assert_eq!(p, PathBuf::from("/var/log/ai-memory/x.log"));
1676 }
1677
1678 #[test]
1679 fn audit_finalize_audit_file_appends_audit_log_for_dir_path() {
1680 let cfg = crate::config::AuditConfig {
1681 enabled: Some(true),
1682 path: Some("/var/log/ai-memory/".to_string()),
1683 ..Default::default()
1684 };
1685 let p = resolve_audit_path(&cfg);
1686 assert!(p.ends_with("audit.log"));
1687 }
1688
1689 #[test]
1690 fn audit_finalize_audit_file_appends_audit_log_for_extension_less_path() {
1691 let cfg = crate::config::AuditConfig {
1693 enabled: Some(true),
1694 path: Some("/var/log/aim_audit_dir".to_string()),
1695 ..Default::default()
1696 };
1697 let p = resolve_audit_path(&cfg);
1698 assert!(p.ends_with("audit.log"));
1699 }
1700
1701 #[test]
1702 fn audit_verify_detects_sequence_regression() {
1703 let e1 = sample_event(5, CHAIN_HEAD_PREV_HASH);
1706 let e2 = sample_event(5, &e1.self_hash);
1708 let mut buf = String::new();
1709 for ev in [&e1, &e2] {
1710 buf.push_str(&serde_json::to_string(ev).unwrap());
1711 buf.push('\n');
1712 }
1713 let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1714 let failure = report.first_failure.expect("sequence regression");
1715 assert!(matches!(failure.kind, VerifyFailureKind::Sequence));
1716 }
1717
1718 #[test]
1719 fn audit_verify_detects_malformed_json_line() {
1720 let buf = "this is not json\n";
1722 let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1723 let failure = report.first_failure.expect("parse failure");
1724 assert!(matches!(failure.kind, VerifyFailureKind::Parse));
1725 assert!(failure.detail.contains("malformed JSON"));
1726 }
1727
1728 #[test]
1729 fn audit_verify_skips_blank_lines() {
1730 let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1732 let e2 = sample_event(2, &e1.self_hash);
1733 let buf = format!(
1734 "\n{}\n\n{}\n\n",
1735 serde_json::to_string(&e1).unwrap(),
1736 serde_json::to_string(&e2).unwrap()
1737 );
1738 let report = verify_chain_from_reader(buf.as_bytes()).unwrap();
1739 assert!(report.first_failure.is_none());
1740 assert_eq!(report.total_lines, 2);
1741 }
1742
1743 #[test]
1744 fn audit_verify_report_into_result_ok() {
1745 let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1746 let report = verify_chain_from_reader(
1747 format!("{}\n", serde_json::to_string(&e1).unwrap()).as_bytes(),
1748 )
1749 .unwrap();
1750 let n = report.into_result().unwrap();
1751 assert_eq!(n, 1);
1752 }
1753
1754 #[test]
1755 fn audit_verify_report_into_result_err() {
1756 let report = VerifyReport {
1757 total_lines: 5,
1758 first_failure: Some(VerifyFailure {
1759 line_number: 3,
1760 kind: VerifyFailureKind::ChainBreak,
1761 detail: "x".to_string(),
1762 }),
1763 };
1764 let err = report.into_result().unwrap_err();
1765 let msg = format!("{err}");
1766 assert!(msg.contains("audit chain verification failed"));
1767 assert!(msg.contains("line 3"));
1768 }
1769
1770 #[test]
1771 fn audit_emit_records_request_id_and_auth() {
1772 let _g = sink_lock();
1773 let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1774 super::init_for_test(buf.clone());
1775 super::emit(
1776 EventBuilder::new(
1777 AuditAction::Store,
1778 actor("a", "explicit", None),
1779 target_memory("m", "ns", None, None, None),
1780 )
1781 .auth(AuditAuth {
1782 source_ip: Some("198.51.100.7".to_string()),
1783 mtls_fp: None,
1784 api_key_id_hash: None,
1785 })
1786 .request_id("trace-abc"),
1787 );
1788 let body = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1789 assert!(body.contains("\"request_id\":\"trace-abc\""), "got: {body}");
1790 assert!(body.contains("198.51.100.7"));
1791 super::shutdown_for_test();
1792 }
1793
1794 #[test]
1795 fn audit_emit_records_error_outcome() {
1796 let _g = sink_lock();
1797 let buf: std::sync::Arc<Mutex<Vec<u8>>> = std::sync::Arc::new(Mutex::new(Vec::new()));
1798 super::init_for_test(buf.clone());
1799 super::emit(
1800 EventBuilder::new(
1801 AuditAction::Store,
1802 actor("a", "explicit", None),
1803 target_memory("m", "ns", None, None, None),
1804 )
1805 .error("disk full"),
1806 );
1807 let body = String::from_utf8(buf.lock().unwrap().clone()).unwrap();
1808 assert!(body.contains("\"outcome\":\"error\""), "got: {body}");
1809 assert!(body.contains("\"error\":\"disk full\""), "got: {body}");
1810 super::shutdown_for_test();
1811 }
1812
1813 #[test]
1814 fn audit_expand_tilde_passthrough_when_no_tilde() {
1815 assert_eq!(super::expand_tilde("/abs/path"), "/abs/path");
1817 assert_eq!(super::expand_tilde("rel/path"), "rel/path");
1818 }
1819
1820 #[test]
1821 fn audit_target_sweep_uses_wildcard_id() {
1822 let t = super::target_sweep("ns-y");
1823 assert_eq!(t.memory_id, "*");
1824 assert_eq!(t.namespace, "ns-y");
1825 }
1826
1827 #[test]
1828 fn audit_target_memory_round_trips_optional_fields() {
1829 let t = super::target_memory(
1830 "mem-1",
1831 "ns-x",
1832 Some("title".to_string()),
1833 Some(Tier::Long.as_str().to_string()),
1834 Some("team".to_string()),
1835 );
1836 assert_eq!(t.tier.as_deref(), Some(Tier::Long.as_str()));
1837 assert_eq!(t.scope.as_deref(), Some("team"));
1838 }
1839
1840 #[test]
1847 fn expand_tilde_substitutes_home_when_set() {
1848 let out = super::expand_tilde("~/audit/log");
1859 assert!(
1863 out.ends_with("/audit/log") || out == "~/audit/log",
1864 "unexpected output shape: {out}"
1865 );
1866 }
1867
1868 #[test]
1869 fn expand_tilde_no_match_passthrough() {
1870 assert_eq!(super::expand_tilde("~root/etc"), "~root/etc");
1874 assert_eq!(super::expand_tilde("~"), "~");
1875 }
1876
1877 #[test]
1878 fn audit_init_returns_error_when_parent_path_is_a_file() {
1879 let _g = sink_lock();
1883 let tmp = tempfile::tempdir().unwrap();
1884 let blocker = tmp.path().join("blocker");
1886 std::fs::write(&blocker, b"i am a file, not a directory").unwrap();
1887 let log_path = blocker.join("nested").join("audit.log");
1890 let err = super::init(&log_path, true, false).unwrap_err();
1891 let msg = format!("{err:#}");
1892 assert!(
1893 msg.contains("creating audit log dir") || msg.contains("audit"),
1894 "expected wrapped context, got: {msg}"
1895 );
1896 super::shutdown_for_test();
1897 }
1898
1899 #[test]
1900 fn audit_init_applies_append_only_flag_on_macos() {
1901 let _g = sink_lock();
1906 let tmp = tempfile::tempdir().unwrap();
1907 let path = tmp.path().join("audit.log");
1908 std::fs::write(&path, b"").unwrap();
1910 super::init(&path, true, true).expect("init must tolerate flag outcome");
1915 assert!(super::is_enabled());
1916 super::shutdown_for_test();
1917 #[cfg(any(target_os = "macos", target_os = "freebsd", target_os = "openbsd"))]
1921 unsafe {
1922 use std::ffi::CString;
1923 use std::os::unix::ffi::OsStrExt;
1924 if let Ok(c) = CString::new(path.as_os_str().as_bytes()) {
1925 let _ = libc::chflags(c.as_ptr(), 0);
1926 }
1927 }
1928 }
1929
1930 #[test]
1931 fn read_chain_tail_returns_none_for_missing_file() {
1932 let tmp = tempfile::tempdir().unwrap();
1934 let missing = tmp.path().join("nope.log");
1935 let _g = sink_lock();
1938 super::init(&missing, true, false).unwrap();
1939 super::emit(EventBuilder::new(
1943 AuditAction::Store,
1944 actor("a", "explicit", None),
1945 target_memory("m", "ns", None, None, None),
1946 ));
1947 let body = std::fs::read_to_string(&missing).unwrap();
1948 let line = body.lines().next().unwrap();
1949 let parsed: AuditEvent = serde_json::from_str(line).unwrap();
1950 assert_eq!(parsed.prev_hash, CHAIN_HEAD_PREV_HASH);
1951 super::shutdown_for_test();
1952 }
1953
1954 #[test]
1955 fn read_chain_tail_skips_blank_lines() {
1956 let _g = sink_lock();
1961 let tmp = tempfile::tempdir().unwrap();
1962 let path = tmp.path().join("audit.log");
1963 let e1 = sample_event(1, CHAIN_HEAD_PREV_HASH);
1964 let e2 = sample_event(2, &e1.self_hash);
1965 let body = format!(
1966 "{}\n\n\n{}\n \n",
1967 serde_json::to_string(&e1).unwrap(),
1968 serde_json::to_string(&e2).unwrap(),
1969 );
1970 std::fs::write(&path, body).unwrap();
1971 super::init(&path, true, false).unwrap();
1972 super::emit(EventBuilder::new(
1973 AuditAction::Store,
1974 actor("a", "explicit", None),
1975 target_memory("m", "ns", None, None, None),
1976 ));
1977 let full = std::fs::read_to_string(&path).unwrap();
1978 let lines: Vec<_> = full.lines().filter(|l| !l.trim().is_empty()).collect();
1979 let last = lines.last().unwrap();
1980 let parsed: AuditEvent = serde_json::from_str(last).unwrap();
1981 assert_eq!(
1982 parsed.prev_hash, e2.self_hash,
1983 "blank lines must be skipped"
1984 );
1985 super::shutdown_for_test();
1986 }
1987
1988 #[test]
1989 fn verify_chain_open_error_wrapped_with_context() {
1990 let tmp = tempfile::tempdir().unwrap();
1993 let missing = tmp.path().join("does-not-exist.log");
1994 let err = super::verify_chain(&missing).unwrap_err();
1995 let msg = format!("{err:#}");
1996 assert!(msg.contains("opening"), "expected context, got: {msg}");
1997 assert!(msg.contains("does-not-exist.log"), "got: {msg}");
1998 }
1999
2000 #[test]
2001 fn finalize_audit_file_keeps_explicit_extension_path() {
2002 let cfg = crate::config::AuditConfig {
2007 enabled: Some(true),
2008 path: Some("./custom.txt".to_string()),
2009 ..Default::default()
2010 };
2011 let p = resolve_audit_path(&cfg);
2012 assert!(
2015 p.to_string_lossy().ends_with(".txt"),
2016 "got: {}",
2017 p.display()
2018 );
2019 }
2020
2021 #[test]
2022 fn finalize_audit_file_keeps_resolved_file_when_no_config_override() {
2023 let p = PathBuf::from("/var/log/aimemory.log");
2029 let out = super::finalize_audit_file(p.clone(), None);
2030 assert_eq!(out, p);
2031 }
2032
2033 #[test]
2034 fn resolve_audit_path_falls_back_to_platform_default_when_resolver_errs() {
2035 #[cfg(unix)]
2044 {
2045 use std::os::unix::fs::PermissionsExt;
2046 let tmp = tempfile::tempdir().unwrap();
2047 let www = tmp.path().join("world_writable");
2048 std::fs::create_dir_all(&www).unwrap();
2049 std::fs::set_permissions(&www, std::fs::Permissions::from_mode(0o777)).unwrap();
2050 let cfg = crate::config::AuditConfig {
2051 enabled: Some(true),
2052 path: Some(www.to_string_lossy().into_owned()),
2053 ..Default::default()
2054 };
2055 let p = super::resolve_audit_path(&cfg);
2056 assert!(
2059 !p.starts_with(&www),
2060 "world-writable dir must not be used; got: {}",
2061 p.display()
2062 );
2063 }
2064 }
2065
2066 #[test]
2067 fn resolve_audit_path_with_override_propagates_world_writable_error() {
2068 #[cfg(unix)]
2072 {
2073 use std::os::unix::fs::PermissionsExt;
2074 let tmp = tempfile::tempdir().unwrap();
2075 let www = tmp.path().join("ww");
2076 std::fs::create_dir_all(&www).unwrap();
2077 std::fs::set_permissions(&www, std::fs::Permissions::from_mode(0o777)).unwrap();
2078 let cfg = crate::config::AuditConfig::default();
2079 let err = super::resolve_audit_path_with_override(Some(&www), &cfg).unwrap_err();
2080 let msg = format!("{err}");
2081 assert!(
2082 msg.contains("world-writable"),
2083 "expected world-writable error, got: {msg}"
2084 );
2085 }
2086 }
2087
2088 #[test]
2089 fn init_with_directory_in_place_of_file_returns_open_error() {
2090 let _g = sink_lock();
2094 let tmp = tempfile::tempdir().unwrap();
2095 let err = super::init(tmp.path(), true, false).unwrap_err();
2098 let msg = format!("{err:#}");
2099 assert!(msg.contains("opening audit log"), "got: {msg}");
2100 super::shutdown_for_test();
2101 }
2102
2103 #[test]
2104 fn resolve_audit_path_with_override_returns_source_tag() {
2105 let tmp = tempfile::tempdir().unwrap();
2109 let cfg = crate::config::AuditConfig::default();
2110 let (path, _source) =
2111 super::resolve_audit_path_with_override(Some(tmp.path()), &cfg).unwrap();
2112 assert!(
2115 path.starts_with(tmp.path()),
2116 "expected override-rooted path, got: {}",
2117 path.display()
2118 );
2119 assert!(path.ends_with("audit.log"), "got: {}", path.display());
2121 }
2122}