cortex_runtime/audit/
logger.rs1use anyhow::{Context, Result};
9use chrono::Utc;
10use serde::Serialize;
11use std::fs::{File, OpenOptions};
12use std::io::Write;
13use std::path::PathBuf;
14
15const MAX_LOG_SIZE: u64 = 100 * 1024 * 1024;
17
18const MAX_ROTATIONS: u32 = 5;
20
21#[derive(Debug, Clone, Serialize)]
23pub struct AuditEvent {
24 pub timestamp: String,
25 pub method: String,
26 pub domain: Option<String>,
27 pub url: Option<String>,
28 pub session_id: Option<String>,
29 pub duration_ms: u64,
30 pub status: String,
31}
32
33pub struct AuditLogger {
35 file: File,
36 path: PathBuf,
37 current_size: u64,
39}
40
41impl AuditLogger {
42 pub fn open(path: &PathBuf) -> Result<Self> {
44 if let Some(parent) = path.parent() {
45 std::fs::create_dir_all(parent)?;
46 }
47
48 let file = OpenOptions::new()
49 .create(true)
50 .append(true)
51 .open(path)
52 .with_context(|| format!("failed to open audit log: {}", path.display()))?;
53
54 let current_size = file.metadata().map(|m| m.len()).unwrap_or(0);
55
56 Ok(Self {
57 file,
58 path: path.clone(),
59 current_size,
60 })
61 }
62
63 pub fn default_logger() -> Result<Self> {
65 let path = dirs::home_dir()
66 .unwrap_or_else(|| PathBuf::from("/tmp"))
67 .join(".cortex")
68 .join("audit.jsonl");
69 Self::open(&path)
70 }
71
72 pub fn log(&mut self, event: &AuditEvent) -> Result<()> {
74 if self.current_size >= MAX_LOG_SIZE {
76 self.rotate()?;
77 }
78
79 let json = serde_json::to_string(event)?;
80 let bytes_written = writeln!(self.file, "{json}")
81 .map(|()| json.len() as u64 + 1)
82 .unwrap_or(0);
83 self.current_size += bytes_written;
84 Ok(())
85 }
86
87 pub fn log_method(
89 &mut self,
90 method: &str,
91 domain: Option<&str>,
92 url: Option<&str>,
93 session_id: Option<&str>,
94 duration_ms: u64,
95 status: &str,
96 ) -> Result<()> {
97 self.log(&AuditEvent {
98 timestamp: Utc::now().to_rfc3339(),
99 method: method.to_string(),
100 domain: domain.map(String::from),
101 url: url.map(String::from),
102 session_id: session_id.map(String::from),
103 duration_ms,
104 status: status.to_string(),
105 })
106 }
107
108 fn rotate(&mut self) -> Result<()> {
110 self.file.flush()?;
112
113 for i in (1..MAX_ROTATIONS).rev() {
115 let from = rotation_path(&self.path, i);
116 let to = rotation_path(&self.path, i + 1);
117 if from.exists() {
118 let _ = std::fs::rename(&from, &to);
119 }
120 }
121
122 let first_rotation = rotation_path(&self.path, 1);
124 let _ = std::fs::rename(&self.path, &first_rotation);
125
126 let oldest = rotation_path(&self.path, MAX_ROTATIONS);
128 if oldest.exists() {
129 let _ = std::fs::remove_file(&oldest);
130 }
131
132 self.file = OpenOptions::new()
134 .create(true)
135 .append(true)
136 .open(&self.path)
137 .with_context(|| "failed to reopen audit log after rotation")?;
138 self.current_size = 0;
139
140 Ok(())
141 }
142}
143
144fn rotation_path(base: &std::path::Path, index: u32) -> PathBuf {
146 let name = format!(
147 "{}.{index}",
148 base.file_name()
149 .and_then(|n| n.to_str())
150 .unwrap_or("audit.jsonl")
151 );
152 base.with_file_name(name)
153}