1use std::io::Write;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Serialize, Deserialize, Clone)]
18pub struct AuditEntry {
19 pub timestamp: String,
21 pub capability: String,
23 pub signature: String,
25 pub attempt_count: usize,
27 pub tier_before: String,
29 pub tier_after: String,
31 pub succeeded: bool,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub source_fingerprint: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub error_summary: Option<String>,
39}
40
41pub fn append(path: &str, entry: &AuditEntry) -> std::io::Result<()> {
46 let mut line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
47 line.push('\n');
48 let mut file = std::fs::OpenOptions::new()
49 .create(true)
50 .append(true)
51 .open(path)?;
52 file.write_all(line.as_bytes())
53}
54
55pub fn read_all(path: &str) -> std::io::Result<Vec<AuditEntry>> {
60 let raw = std::fs::read_to_string(path)?;
61 let entries = raw
62 .lines()
63 .filter(|l| !l.trim().is_empty())
64 .filter_map(|l| serde_json::from_str::<AuditEntry>(l).ok())
65 .collect();
66 Ok(entries)
67}
68
69pub fn print_summary(path: &str, entries: &[AuditEntry]) {
74 let total = entries.len();
75 let succeeded = entries.iter().filter(|e| e.succeeded).count();
76 let failed = total - succeeded;
77 let last_ts = entries.last().map(|e| e.timestamp.as_str()).unwrap_or("—");
78
79 let mut caps: std::collections::HashMap<&str, (usize, usize, usize, &str)> =
81 std::collections::HashMap::new();
82 for e in entries {
83 let entry = caps.entry(e.capability.as_str()).or_insert((0, 0, 0, ""));
84 entry.0 += 1;
85 if e.succeeded {
86 entry.1 += 1;
87 } else {
88 entry.2 += 1;
89 }
90 entry.3 = e.tier_after.as_str();
91 }
92
93 println!("forge audit: {path} ({total} entries)");
94 println!();
95 println!("summary");
96 println!(" total runs: {total} ({succeeded} succeeded, {failed} failed)");
97 println!(" unique caps: {}", caps.len());
98 println!(" last run: {last_ts}");
99 println!();
100
101 if caps.is_empty() {
102 return;
103 }
104
105 println!(
106 "{:<38} {:>5} {:>4} {:>4} tier",
107 "capability", "runs", "pass", "fail"
108 );
109 println!("{}", "─".repeat(62));
110
111 let mut rows: Vec<(&str, usize, usize, usize, &str)> = caps
112 .iter()
113 .map(|(&cap, &(runs, pass, fail, tier))| (cap, runs, pass, fail, tier))
114 .collect();
115 rows.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
116
117 for (cap, runs, pass, fail, tier) in rows {
118 let display = if cap.len() > 37 {
119 format!("{}…", &cap[..36])
120 } else {
121 cap.to_string()
122 };
123 println!(
124 "{:<38} {:>5} {:>4} {:>4} {tier}",
125 display, runs, pass, fail
126 );
127 }
128}
129
130pub fn print_tail(entries: &[AuditEntry], n: usize) {
131 let slice = if entries.len() > n {
132 &entries[entries.len() - n..]
133 } else {
134 entries
135 };
136
137 if slice.is_empty() {
138 println!("(no entries)");
139 return;
140 }
141
142 println!(
143 "{:<22} {:<32} {:>5} {:>6} tier_after",
144 "timestamp", "capability", "atts", "result"
145 );
146 println!("{}", "─".repeat(78));
147
148 for e in slice {
149 let cap = if e.capability.len() > 31 {
150 format!("{}…", &e.capability[..30])
151 } else {
152 e.capability.clone()
153 };
154 let result = if e.succeeded { "ok" } else { "FAIL" };
155 println!(
156 "{:<22} {:<32} {:>5} {:>6} {}",
157 e.timestamp, cap, e.attempt_count, result, e.tier_after
158 );
159 }
160}
161
162pub fn fingerprint(data: &[u8]) -> String {
167 let mut hash: u64 = 0xcbf29ce484222325;
168 for &byte in data {
169 hash ^= byte as u64;
170 hash = hash.wrapping_mul(0x00000100000001b3);
171 }
172 format!("{hash:016x}")
173}
174
175pub fn iso_now() -> String {
180 use std::time::{SystemTime, UNIX_EPOCH};
181 let secs = SystemTime::now()
182 .duration_since(UNIX_EPOCH)
183 .map(|d| d.as_secs())
184 .unwrap_or(0);
185 unix_secs_to_iso(secs)
186}
187
188fn is_leap(year: u64) -> bool {
189 year.is_multiple_of(400) || (year.is_multiple_of(4) && !year.is_multiple_of(100))
190}
191
192fn unix_secs_to_iso(mut secs: u64) -> String {
193 let s = secs % 60;
194 secs /= 60;
195 let m = secs % 60;
196 secs /= 60;
197 let h = secs % 24;
198 let mut days = secs / 24;
199
200 let mut year = 1970u64;
201 loop {
202 let dy = if is_leap(year) { 366 } else { 365 };
203 if days < dy {
204 break;
205 }
206 days -= dy;
207 year += 1;
208 }
209
210 let month_days: [u64; 12] = [
211 31,
212 if is_leap(year) { 29 } else { 28 },
213 31,
214 30,
215 31,
216 30,
217 31,
218 31,
219 30,
220 31,
221 30,
222 31,
223 ];
224 let mut month = 1u64;
225 for &dm in &month_days {
226 if days < dm {
227 break;
228 }
229 days -= dm;
230 month += 1;
231 }
232 let day = days + 1;
233
234 format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
235}
236
237#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn fingerprint_is_deterministic() {
247 let a = fingerprint(b"hello world");
248 let b = fingerprint(b"hello world");
249 assert_eq!(a, b);
250 assert_eq!(a.len(), 16);
251 }
252
253 #[test]
254 fn fingerprint_differs_on_different_input() {
255 assert_ne!(fingerprint(b"foo"), fingerprint(b"bar"));
256 }
257
258 #[test]
259 fn iso_now_looks_right() {
260 let ts = iso_now();
261 assert!(ts.ends_with('Z'), "expected Z suffix: {ts}");
262 assert!(ts.contains('T'), "expected T separator: {ts}");
263 assert!(ts.starts_with("20"), "expected 20xx year: {ts}");
264 }
265
266 #[test]
267 fn unix_secs_to_iso_epoch() {
268 assert_eq!(unix_secs_to_iso(0), "1970-01-01T00:00:00Z");
269 }
270
271 #[test]
272 fn unix_secs_to_iso_known_date() {
273 assert_eq!(unix_secs_to_iso(1767225600), "2026-01-01T00:00:00Z");
275 }
276
277 #[test]
278 fn append_and_read_roundtrip() {
279 let dir = tempfile::tempdir().unwrap();
280 let path = dir.path().join("audit.jsonl");
281 let path_str = path.to_str().unwrap();
282
283 let entry = AuditEntry {
284 timestamp: "2026-01-01T00:00:00Z".into(),
285 capability: "word count".into(),
286 signature: "abc123".into(),
287 attempt_count: 1,
288 tier_before: "Untrusted".into(),
289 tier_after: "Emerging".into(),
290 succeeded: true,
291 source_fingerprint: Some("deadbeef12345678".into()),
292 error_summary: None,
293 };
294
295 append(path_str, &entry).unwrap();
296 append(path_str, &entry).unwrap();
297
298 let entries = read_all(path_str).unwrap();
299 assert_eq!(entries.len(), 2);
300 assert_eq!(entries[0].capability, "word count");
301 assert!(entries[1].succeeded);
302 }
303}