返回 CodeWhale
audit.rs
根目录 / crates / tui / src / audit.rs
1 //! Lightweight audit logging for sensitive operations.
2
3 use std::fs;
4 use std::path::PathBuf;
5
6 use chrono::Utc;
7 use serde_json::{Value, json};
8
9 use crate::utils::{flush_and_sync, open_append};
10
11 /// Append an audit event to `$CODEWHALE_HOME/audit.log` (or the default
12 /// `~/.codewhale/audit.log` when no explicit CodeWhale home is configured).
13 ///
14 /// This helper is best-effort by design: callers should not fail critical flows
15 /// if audit persistence fails.
16 pub fn log_sensitive_event(event: &str, details: Value) {
17 if let Err(err) = append_event(event, details) {
18 crate::logging::warn(format!("audit log write failed: {err}"));
19 }
20 }
21
22 /// Size at which `audit.log` is rolled to `audit.log.1`.
23 ///
24 /// The log was append-only with no bound at all: a real `~/.codewhale/audit.log`
25 /// had reached 2.6 MB and was still growing, with nothing in the product that
26 /// would ever shrink it. Unbounded growth in the user's config directory is not
27 /// a viable end state, and neither is silently discarding the record — so one
28 /// previous generation is kept, which bounds the pair at ~2× this value while
29 /// preserving well over a year of ordinary use.
30 const AUDIT_LOG_ROTATE_BYTES: u64 = 16 * 1024 * 1024;
31
32 /// Roll `audit.log` to `audit.log.1` once it passes [`AUDIT_LOG_ROTATE_BYTES`].
33 ///
34 /// Exactly one previous generation is kept; the older `.1` is replaced. Rolling
35 /// is a rename, so no record is ever rewritten in place and an event is never
36 /// lost to a partially-copied file.
37 ///
38 /// Best-effort by the same rule as the rest of this module: if the roll fails
39 /// the event is still appended to the existing file. A too-large audit log is a
40 /// far better outcome than a dropped audit record.
41 fn rotate_if_oversized(path: &std::path::Path) {
42 let oversized = fs::metadata(path).is_ok_and(|meta| meta.len() >= AUDIT_LOG_ROTATE_BYTES);
43 if !oversized {
44 return;
45 }
46 let mut rolled = path.as_os_str().to_owned();
47 rolled.push(".1");
48 let _ = fs::rename(path, std::path::Path::new(&rolled));
49 }
50
51 fn append_event(event: &str, details: Value) -> anyhow::Result<()> {
52 let path = default_audit_path()?;
53 let parent = path.parent().map(|p| p.to_path_buf());
54 if let Some(ref parent) = parent {
55 fs::create_dir_all(parent)?;
56 }
57 rotate_if_oversized(&path);
58 // Open for append with a BufWriter for buffered I/O, then flush + fsync
59 // after each event so the record is durably on disk.
60 let mut writer = open_append(&path)?;
61 let record = json!({
62 "ts": Utc::now().to_rfc3339(),
63 "event": event,
64 "details": details,
65 });
66 let line = serde_json::to_string(&record)?;
67 use std::io::Write;
68 writeln!(writer, "{line}")?;
69 flush_and_sync(&mut writer)?;
70 Ok(())
71 }
72
73 fn default_audit_path() -> anyhow::Result<PathBuf> {
74 Ok(codewhale_config::codewhale_home()?.join("audit.log"))
75 }
76
77 /// Where audit events are written, for surfaces that point a person at the
78 /// full record (for example `/permissions`). `None` when no Codewhale home
79 /// resolves; callers show a placeholder rather than guessing a path.
80 #[must_use]
81 pub fn audit_log_path() -> Option<PathBuf> {
82 default_audit_path().ok()
83 }
84
85 #[cfg(test)]
86 mod tests {
87 use super::{AUDIT_LOG_ROTATE_BYTES, rotate_if_oversized};
88
89 #[test]
90 fn a_small_log_is_left_alone() {
91 let dir = tempfile::TempDir::new().expect("tempdir");
92 let path = dir.path().join("audit.log");
93 std::fs::write(&path, b"{}\n").expect("write");
94 rotate_if_oversized(&path);
95 assert!(path.exists(), "an ordinary log is never rolled");
96 assert!(!dir.path().join("audit.log.1").exists());
97 }
98
99 #[test]
100 fn an_oversized_log_rolls_to_one_previous_generation() {
101 let dir = tempfile::TempDir::new().expect("tempdir");
102 let path = dir.path().join("audit.log");
103 let rolled = dir.path().join("audit.log.1");
104 std::fs::write(&path, vec![b'x'; AUDIT_LOG_ROTATE_BYTES as usize]).expect("write");
105
106 rotate_if_oversized(&path);
107
108 assert!(
109 !path.exists(),
110 "the live log is rolled aside, not truncated"
111 );
112 assert_eq!(
113 std::fs::metadata(&rolled).expect("rolled log").len(),
114 AUDIT_LOG_ROTATE_BYTES,
115 "the previous generation keeps every byte — rolling is a rename"
116 );
117 }
118
119 #[test]
120 fn rolling_twice_keeps_exactly_one_previous_generation() {
121 let dir = tempfile::TempDir::new().expect("tempdir");
122 let path = dir.path().join("audit.log");
123 let rolled = dir.path().join("audit.log.1");
124
125 std::fs::write(&path, vec![b'a'; AUDIT_LOG_ROTATE_BYTES as usize]).expect("first");
126 rotate_if_oversized(&path);
127 std::fs::write(&path, vec![b'b'; AUDIT_LOG_ROTATE_BYTES as usize]).expect("second");
128 rotate_if_oversized(&path);
129
130 let kept = std::fs::read(&rolled).expect("rolled log");
131 assert_eq!(kept.len(), AUDIT_LOG_ROTATE_BYTES as usize);
132 assert_eq!(kept[0], b'b', "the newer generation replaces the older one");
133 assert!(
134 !dir.path().join("audit.log.2").exists(),
135 "generations must not accumulate — that is the bug being fixed"
136 );
137 }
138
139 #[test]
140 fn a_missing_log_is_not_an_error() {
141 let dir = tempfile::TempDir::new().expect("tempdir");
142 rotate_if_oversized(&dir.path().join("audit.log"));
143 }
144 }
145
145 lines RUST