1use std::fs::{self, OpenOptions};
2use std::io::{BufRead, Write};
3use std::path::{Path, PathBuf};
4use std::sync::Mutex;
5
6use anyhow::{Context, Result};
7use chrono::Utc;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Deserialize, Serialize)]
11pub struct AuditEntry {
12 pub timestamp: String,
13 pub repo: String,
14 pub action: String,
15 pub status: String,
16 pub before: serde_json::Value,
17 pub after: serde_json::Value,
18}
19
20pub struct AuditLog {
21 path: PathBuf,
22 file: Mutex<Option<fs::File>>,
23}
24
25impl AuditLog {
26 pub fn new() -> Result<Self> {
27 let dir = dirs_path()?;
28 fs::create_dir_all(&dir).context("Failed to create ~/.ward/ directory")?;
29
30 let path = dir.join("audit.log");
31 let file = OpenOptions::new()
32 .create(true)
33 .append(true)
34 .open(&path)
35 .context("Failed to open audit log")?;
36
37 Ok(Self {
38 path,
39 file: Mutex::new(Some(file)),
40 })
41 }
42
43 pub fn log(
44 &self,
45 repo: &str,
46 action: &str,
47 status: &str,
48 before: bool,
49 after: bool,
50 ) -> Result<()> {
51 let entry = AuditEntry {
52 timestamp: Utc::now().to_rfc3339(),
53 repo: repo.to_string(),
54 action: action.to_string(),
55 status: status.to_string(),
56 before: serde_json::Value::Bool(before),
57 after: serde_json::Value::Bool(after),
58 };
59
60 let line = serde_json::to_string(&entry)?;
61
62 let mut guard = self
63 .file
64 .lock()
65 .map_err(|e| anyhow::anyhow!("Audit log mutex poisoned: {e}"))?;
66 if let Some(ref mut f) = *guard {
67 writeln!(f, "{line}")?;
68 }
69
70 tracing::debug!("audit: {line}");
71 Ok(())
72 }
73
74 pub fn path(&self) -> &PathBuf {
75 &self.path
76 }
77}
78
79pub fn default_log_path() -> Result<PathBuf> {
80 Ok(dirs_path()?.join("audit.log"))
81}
82
83pub fn read_entries(path: &Path) -> Result<Vec<AuditEntry>> {
84 let file = fs::File::open(path)
85 .with_context(|| format!("Failed to open audit log: {}", path.display()))?;
86
87 let reader = std::io::BufReader::new(file);
88 let mut entries = Vec::new();
89
90 for line in reader.lines() {
91 let line = line?;
92 let trimmed = line.trim();
93 if trimmed.is_empty() {
94 continue;
95 }
96 match serde_json::from_str::<AuditEntry>(trimmed) {
97 Ok(entry) => entries.push(entry),
98 Err(e) => {
99 tracing::warn!("Skipping malformed audit entry: {e}");
100 }
101 }
102 }
103
104 Ok(entries)
105}
106
107fn dirs_path() -> Result<PathBuf> {
108 let home = std::env::var("HOME").context("HOME not set")?;
109 Ok(PathBuf::from(home).join(".ward"))
110}
111
112#[cfg(test)]
113impl AuditLog {
114 fn new_for_test(path: PathBuf, file: fs::File) -> Self {
115 Self {
116 path,
117 file: Mutex::new(Some(file)),
118 }
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn audit_log_creates_file_and_writes() {
128 let dir = tempfile::tempdir().unwrap();
129 let path = dir.path().join("audit.log");
130 let file = OpenOptions::new()
131 .create(true)
132 .append(true)
133 .open(&path)
134 .unwrap();
135
136 let log = AuditLog::new_for_test(path.clone(), file);
137
138 log.log("test-repo", "enable_thing", "success", false, true)
139 .unwrap();
140
141 let content = std::fs::read_to_string(&path).unwrap();
142 assert!(content.contains("test-repo"));
143 assert!(content.contains("enable_thing"));
144 assert!(content.contains("success"));
145 }
146
147 #[test]
148 fn audit_log_appends_multiple_entries() {
149 let dir = tempfile::tempdir().unwrap();
150 let path = dir.path().join("audit.log");
151 let file = OpenOptions::new()
152 .create(true)
153 .append(true)
154 .open(&path)
155 .unwrap();
156
157 let log = AuditLog::new_for_test(path.clone(), file);
158
159 log.log("repo1", "action1", "success", false, true).unwrap();
160 log.log("repo2", "action2", "failure", true, false).unwrap();
161
162 let content = std::fs::read_to_string(&path).unwrap();
163 let lines: Vec<&str> = content.lines().collect();
164 assert_eq!(lines.len(), 2);
165 assert!(lines[0].contains("repo1"));
166 assert!(lines[1].contains("repo2"));
167 }
168
169 #[test]
170 fn audit_entry_is_valid_json() {
171 let dir = tempfile::tempdir().unwrap();
172 let path = dir.path().join("audit.log");
173 let file = OpenOptions::new()
174 .create(true)
175 .append(true)
176 .open(&path)
177 .unwrap();
178
179 let log = AuditLog::new_for_test(path.clone(), file);
180
181 log.log("test-repo", "test_action", "success", false, true)
182 .unwrap();
183
184 let content = std::fs::read_to_string(&path).unwrap();
185 let entry: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
186 assert_eq!(entry["repo"], "test-repo");
187 assert_eq!(entry["action"], "test_action");
188 assert_eq!(entry["status"], "success");
189 assert_eq!(entry["before"], false);
190 assert_eq!(entry["after"], true);
191 assert!(!entry["timestamp"].as_str().unwrap().is_empty());
192 }
193
194 #[test]
195 fn read_entries_parses_log_file() {
196 let dir = tempfile::tempdir().unwrap();
197 let path = dir.path().join("audit.log");
198 let file = OpenOptions::new()
199 .create(true)
200 .append(true)
201 .open(&path)
202 .unwrap();
203
204 let log = AuditLog::new_for_test(path.clone(), file);
205 log.log("repo-a", "set_secret_scanning", "success", false, true)
206 .unwrap();
207 log.log("repo-b", "enable_dependabot_alerts", "success", false, true)
208 .unwrap();
209 log.log("repo-c", "set_push_protection", "failure", false, true)
210 .unwrap();
211
212 let entries = read_entries(&path).unwrap();
213 assert_eq!(entries.len(), 3);
214 assert_eq!(entries[0].repo, "repo-a");
215 assert_eq!(entries[1].action, "enable_dependabot_alerts");
216 assert_eq!(entries[2].status, "failure");
217 }
218
219 #[test]
220 fn read_entries_skips_empty_lines() {
221 let dir = tempfile::tempdir().unwrap();
222 let path = dir.path().join("audit.log");
223
224 let entry = r#"{"timestamp":"2024-01-01T00:00:00Z","repo":"r","action":"a","status":"success","before":false,"after":true}"#;
225 std::fs::write(&path, format!("\n{entry}\n\n{entry}\n")).unwrap();
226
227 let entries = read_entries(&path).unwrap();
228 assert_eq!(entries.len(), 2);
229 }
230
231 #[test]
232 fn read_entries_returns_empty_for_empty_file() {
233 let dir = tempfile::tempdir().unwrap();
234 let path = dir.path().join("audit.log");
235 std::fs::write(&path, "").unwrap();
236
237 let entries = read_entries(&path).unwrap();
238 assert!(entries.is_empty());
239 }
240}