返回 CodeWhale
audit.rs
根目录 / crates / tui / src / audit.rs
1 //! Lightweight audit logging for sensitive operations.
2
3 use std::fs;
4 use std::path::{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 let result = default_audit_path().and_then(|path| append_event(&path, event, details));
18 if let Err(err) = result {
19 crate::logging::warn(format!("audit log write failed: {err}"));
20 }
21 }
22
23 /// Append an audit event to `<codewhale_home>/audit.log` for a caller that
24 /// already owns an explicit Codewhale home (for example the DSH integration's
25 /// `DshPaths`), so its audit record lands beside the rest of its state rather
26 /// than in whatever home the process environment names (#6534).
27 pub fn log_sensitive_event_in(codewhale_home: &Path, event: &str, details: Value) {
28 if let Err(err) = append_event(&codewhale_home.join("audit.log"), event, details) {
29 crate::logging::warn(format!("audit log write failed: {err}"));
30 }
31 }
32
33 /// Size at which `audit.log` is rolled to `audit.log.1`.
34 ///
35 /// The log was append-only with no bound at all: a real `~/.codewhale/audit.log`
36 /// had reached 2.6 MB and was still growing, with nothing in the product that
37 /// would ever shrink it. Unbounded growth in the user's config directory is not
38 /// a viable end state, and neither is silently discarding the record — so one
39 /// previous generation is kept, which bounds the pair at ~2× this value while
40 /// preserving well over a year of ordinary use.
41 const AUDIT_LOG_ROTATE_BYTES: u64 = 16 * 1024 * 1024;
42
43 /// Roll `audit.log` to `audit.log.1` once it passes [`AUDIT_LOG_ROTATE_BYTES`].
44 ///
45 /// Exactly one previous generation is kept; the older `.1` is replaced. Rolling
46 /// is a rename, so no record is ever rewritten in place and an event is never
47 /// lost to a partially-copied file.
48 ///
49 /// Best-effort by the same rule as the rest of this module: if the roll fails
50 /// the event is still appended to the existing file. A too-large audit log is a
51 /// far better outcome than a dropped audit record.
52 fn rotate_if_oversized(path: &std::path::Path) {
53 let oversized = fs::metadata(path).is_ok_and(|meta| meta.len() >= AUDIT_LOG_ROTATE_BYTES);
54 if !oversized {
55 return;
56 }
57 let mut rolled = path.as_os_str().to_owned();
58 rolled.push(".1");
59 let _ = fs::rename(path, std::path::Path::new(&rolled));
60 }
61
62 fn append_event(path: &Path, event: &str, details: Value) -> anyhow::Result<()> {
63 let path = path.to_path_buf();
64 let parent = path.parent().map(|p| p.to_path_buf());
65 if let Some(ref parent) = parent {
66 fs::create_dir_all(parent)?;
67 }
68 rotate_if_oversized(&path);
69 // Open for append with a BufWriter for buffered I/O, then flush + fsync
70 // after each event so the record is durably on disk.
71 let mut writer = open_append(&path)?;
72 let record = json!({
73 "ts": Utc::now().to_rfc3339(),
74 "event": event,
75 "details": details,
76 });
77 let line = serde_json::to_string(&record)?;
78 use std::io::Write;
79 writeln!(writer, "{line}")?;
80 flush_and_sync(&mut writer)?;
81 Ok(())
82 }
83
84 fn default_audit_path() -> anyhow::Result<PathBuf> {
85 // A test that has not sealed its own home must never append to the
86 // developer's real ~/.codewhale/audit.log (#6534); it gets a per-process
87 // scratch log instead. An *ambient* CODEWHALE_HOME is not a seal: it names
88 // somebody's real profile just as `~/.codewhale` does.
89 #[cfg(test)]
90 if !crate::test_support::home_is_sealed() {
91 return Ok(std::env::temp_dir()
92 .join(format!("codewhale-test-audit-{}", std::process::id()))
93 .join("audit.log"));
94 }
95 Ok(codewhale_config::codewhale_home()?.join("audit.log"))
96 }
97
98 /// Where audit events are written, for surfaces that point a person at the
99 /// full record (for example `/permissions`). `None` when no Codewhale home
100 /// resolves; callers show a placeholder rather than guessing a path.
101 #[must_use]
102 pub fn audit_log_path() -> Option<PathBuf> {
103 default_audit_path().ok()
104 }
105
106 #[cfg(test)]
107 mod tests {
108 use super::{AUDIT_LOG_ROTATE_BYTES, rotate_if_oversized};
109
110 /// #6534 guard: with no explicit CODEWHALE_HOME, a test process resolves
111 /// the audit log outside the real user home, so no test can append to it.
112 #[test]
113 fn test_process_never_resolves_the_real_home_audit_log() {
114 let _lock = crate::test_support::lock_test_env();
115 let _home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME");
116 let path = super::audit_log_path().expect("audit path");
117 let real = codewhale_paths::user_home()
118 .expect("user home")
119 .join(".codewhale")
120 .join("audit.log");
121 assert_ne!(path, real);
122 assert!(path.starts_with(std::env::temp_dir()), "{}", path.display());
123 }
124
125 #[test]
126 fn an_explicit_home_receives_its_own_events() {
127 let dir = tempfile::tempdir().expect("tempdir");
128 super::log_sensitive_event_in(dir.path(), "test.event", serde_json::json!({"k": 1}));
129 let log = std::fs::read_to_string(dir.path().join("audit.log")).expect("audit.log");
130 assert!(log.contains("\"event\":\"test.event\""), "{log}");
131 }
132
133 #[test]
134 fn a_small_log_is_left_alone() {
135 let dir = tempfile::TempDir::new().expect("tempdir");
136 let path = dir.path().join("audit.log");
137 std::fs::write(&path, b"{}\n").expect("write");
138 rotate_if_oversized(&path);
139 assert!(path.exists(), "an ordinary log is never rolled");
140 assert!(!dir.path().join("audit.log.1").exists());
141 }
142
143 #[test]
144 fn an_oversized_log_rolls_to_one_previous_generation() {
145 let dir = tempfile::TempDir::new().expect("tempdir");
146 let path = dir.path().join("audit.log");
147 let rolled = dir.path().join("audit.log.1");
148 std::fs::write(&path, vec![b'x'; AUDIT_LOG_ROTATE_BYTES as usize]).expect("write");
149
150 rotate_if_oversized(&path);
151
152 assert!(
153 !path.exists(),
154 "the live log is rolled aside, not truncated"
155 );
156 assert_eq!(
157 std::fs::metadata(&rolled).expect("rolled log").len(),
158 AUDIT_LOG_ROTATE_BYTES,
159 "the previous generation keeps every byte — rolling is a rename"
160 );
161 }
162
163 #[test]
164 fn rolling_twice_keeps_exactly_one_previous_generation() {
165 let dir = tempfile::TempDir::new().expect("tempdir");
166 let path = dir.path().join("audit.log");
167 let rolled = dir.path().join("audit.log.1");
168
169 std::fs::write(&path, vec![b'a'; AUDIT_LOG_ROTATE_BYTES as usize]).expect("first");
170 rotate_if_oversized(&path);
171 std::fs::write(&path, vec![b'b'; AUDIT_LOG_ROTATE_BYTES as usize]).expect("second");
172 rotate_if_oversized(&path);
173
174 let kept = std::fs::read(&rolled).expect("rolled log");
175 assert_eq!(kept.len(), AUDIT_LOG_ROTATE_BYTES as usize);
176 assert_eq!(kept[0], b'b', "the newer generation replaces the older one");
177 assert!(
178 !dir.path().join("audit.log.2").exists(),
179 "generations must not accumulate — that is the bug being fixed"
180 );
181 }
182
183 #[test]
184 fn a_missing_log_is_not_an_error() {
185 let dir = tempfile::TempDir::new().expect("tempdir");
186 rotate_if_oversized(&dir.path().join("audit.log"));
187 }
188 }
189
189 lines RUST