Skip to main content

ward/engine/
audit_log.rs

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}