Skip to main content

csaf_core/
audit_export.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne
3
4//! Export the `audit_log` SQLite table in four formats with matching
5//! hash sidecars.
6//!
7//! Produces four files under a caller-chosen directory, each named
8//! `audit-<UTC>.ext` where `<UTC>` is `%Y-%m-%dT%H-%M-%SZ`:
9//!
10//! | Format | Extension | Builder                           |
11//! |--------|-----------|-----------------------------------|
12//! | JSON   | `.json`   | `serde_json::to_vec_pretty`       |
13//! | CSV    | `.csv`    | hand-rolled RFC 4180 writer       |
14//! | SARIF  | `.sarif`  | hand-rolled SARIF 2.1.0 via serde |
15//! | Markdown | `.md`   | hand-rolled GFM table             |
16//!
17//! For each payload the three hash sidecars mandated by `CLAUDE.md`
18//! (`.sha-256`, `.sha-512`, `.sha3-512`) are emitted via
19//! [`crate::sidecar::write_sidecar_files_for`], controlled by the
20//! matching `Settings.sidecar_*` toggles.
21
22use std::path::{Path, PathBuf};
23
24use chrono::Utc;
25use csaf_models::audit_log::{self, AuditLogEntry};
26use csaf_models::db::DbPool;
27use csaf_models::settings::Settings;
28
29use crate::error::{CsafError, Result};
30use crate::sidecar;
31
32/// Maximum number of audit rows an export ever pulls in one pass.
33///
34/// Ten million is three orders of magnitude above any realistic CSAF CRUD
35/// deployment's audit history and still fits comfortably in memory as
36/// JSON/CSV/SARIF/MD. Hard cap rather than streaming for simplicity.
37const MAX_AUDIT_ROWS: usize = 10_000_000;
38
39/// Tool name written into the SARIF `tool.driver.name` field.
40const SARIF_DRIVER_NAME: &str = "csaf-crud";
41
42/// SARIF 2.1.0 standard schema URI.
43const SARIF_SCHEMA: &str = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
44
45/// Which formats to emit on an audit-log export.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47// Four independent boolean flags is the natural shape here — each
48// format is orthogonal, and collapsing to a bitflag type would make the
49// HTML form plumbing in `routes::admin` considerably noisier.
50#[allow(clippy::struct_excessive_bools)]
51pub struct AuditExportOptions {
52    /// Emit `audit-<ts>.md` (GitHub-flavoured Markdown table).
53    pub markdown: bool,
54    /// Emit `audit-<ts>.csv` (RFC 4180).
55    pub csv: bool,
56    /// Emit `audit-<ts>.json` (pretty-printed JSON array).
57    pub json: bool,
58    /// Emit `audit-<ts>.sarif` (SARIF 2.1.0).
59    pub sarif: bool,
60}
61
62impl Default for AuditExportOptions {
63    fn default() -> Self {
64        Self {
65            markdown: true,
66            csv: true,
67            json: true,
68            sarif: true,
69        }
70    }
71}
72
73/// Result of an `export_audit_log` call.
74#[derive(Debug, Clone)]
75pub struct AuditExportResult {
76    /// Filename-safe UTC timestamp used for every written artefact.
77    pub timestamp: String,
78    /// Number of audit-log rows exported.
79    pub rows: usize,
80    /// Paths to the payload files (md, csv, json, sarif in that order
81    /// when enabled).
82    pub written: Vec<PathBuf>,
83    /// Paths to every hash sidecar written.
84    pub sidecars: Vec<PathBuf>,
85}
86
87/// Export the audit log from the supplied sqlite pool.
88///
89/// `out_dir` is created if missing. The four payload filenames share a
90/// single timestamp so operators can group them on disk at a glance.
91///
92/// # Errors
93///
94/// - [`CsafError::Database`] if the audit_log query fails.
95/// - [`CsafError::Io`] if the output directory or any payload cannot be
96///   written.
97/// - [`CsafError::Config`] if the SARIF payload fails self-validation
98///   (invariant: we never ship malformed SARIF).
99pub fn export_audit_log(
100    pool: &DbPool,
101    out_dir: &Path,
102    settings: &Settings,
103    opts: &AuditExportOptions,
104) -> Result<AuditExportResult> {
105    std::fs::create_dir_all(out_dir)?;
106
107    // Timestamp-safe filename stem — ":" and "." are illegal on Windows
108    // filesystems, so we use "-" throughout.
109    let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%SZ").to_string();
110
111    let entries: Vec<AuditLogEntry> =
112        pool.with_conn(|conn| audit_log::list(conn, None, MAX_AUDIT_ROWS, 0))?;
113
114    let rows = entries.len();
115    let mut written: Vec<PathBuf> = Vec::new();
116    let mut sidecars: Vec<PathBuf> = Vec::new();
117
118    if opts.markdown {
119        let path = out_dir.join(format!("audit-{timestamp}.md"));
120        let body = to_markdown(&entries);
121        std::fs::write(&path, &body)?;
122        record_sidecars(&path, body.as_bytes(), settings, &mut sidecars)?;
123        written.push(path);
124    }
125
126    if opts.csv {
127        let path = out_dir.join(format!("audit-{timestamp}.csv"));
128        let body = to_csv(&entries);
129        std::fs::write(&path, &body)?;
130        record_sidecars(&path, body.as_bytes(), settings, &mut sidecars)?;
131        written.push(path);
132    }
133
134    if opts.json {
135        let path = out_dir.join(format!("audit-{timestamp}.json"));
136        let body = serde_json::to_vec_pretty(&entries)?;
137        std::fs::write(&path, &body)?;
138        record_sidecars(&path, &body, settings, &mut sidecars)?;
139        written.push(path);
140    }
141
142    if opts.sarif {
143        let path = out_dir.join(format!("audit-{timestamp}.sarif"));
144        let sarif_value = to_sarif(&entries);
145        // Self-validate before write — cheap sanity guard against schema
146        // drift in `to_sarif`. See `validate_sarif` for the full list of
147        // invariants enforced.
148        validate_sarif(&sarif_value)?;
149        let body = serde_json::to_vec_pretty(&sarif_value)?;
150        std::fs::write(&path, &body)?;
151        record_sidecars(&path, &body, settings, &mut sidecars)?;
152        written.push(path);
153    }
154
155    Ok(AuditExportResult {
156        timestamp,
157        rows,
158        written,
159        sidecars,
160    })
161}
162
163/// Write the three hash sidecars for `payload` and push any produced
164/// paths into `sidecars`.
165fn record_sidecars(
166    payload_path: &Path,
167    bytes: &[u8],
168    settings: &Settings,
169    sidecars: &mut Vec<PathBuf>,
170) -> Result<()> {
171    let (s256, s512, s3) = sidecar::write_sidecar_files_for(
172        payload_path,
173        bytes,
174        settings.sidecar_sha256,
175        settings.sidecar_sha512,
176        settings.sidecar_sha3_512,
177    )?;
178    if let Some(p) = s256 {
179        sidecars.push(p);
180    }
181    if let Some(p) = s512 {
182        sidecars.push(p);
183    }
184    if let Some(p) = s3 {
185        sidecars.push(p);
186    }
187    Ok(())
188}
189
190/// Render audit entries as a GitHub Flavoured Markdown table.
191fn to_markdown(entries: &[AuditLogEntry]) -> String {
192    use std::fmt::Write as _;
193
194    let mut out = String::with_capacity(256 + entries.len() * 120);
195    out.push_str("# Audit Log Export\n\n");
196    let _ = writeln!(
197        out,
198        "Generated: `{}`\n",
199        Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
200    );
201    let _ = writeln!(out, "Total rows: **{}**\n", entries.len());
202    out.push_str("| id | timestamp | action | tracking_id | user_id | details |\n");
203    out.push_str("|----|-----------|--------|-------------|---------|---------|\n");
204    for e in entries {
205        let _ = writeln!(
206            out,
207            "| {} | {} | {} | {} | {} | {} |",
208            e.id,
209            md_escape(&e.timestamp),
210            md_escape(&e.action),
211            md_escape(&e.tracking_id),
212            e.user_id
213                .map_or_else(|| "—".to_owned(), |id| id.to_string()),
214            md_escape(e.details.as_deref().unwrap_or("")),
215        );
216    }
217    out
218}
219
220/// Escape characters that would break a GFM table row.
221fn md_escape(value: &str) -> String {
222    value
223        .replace('\\', r"\\")
224        .replace('|', r"\|")
225        .replace('\n', " ")
226        .replace('\r', "")
227}
228
229/// Render audit entries as RFC 4180 CSV (UTF-8, CRLF terminated).
230fn to_csv(entries: &[AuditLogEntry]) -> String {
231    use std::fmt::Write as _;
232
233    let mut out = String::with_capacity(128 + entries.len() * 100);
234    out.push_str("id,timestamp,action,tracking_id,user_id,details\r\n");
235    for e in entries {
236        let _ = write!(
237            out,
238            "{},{},{},{},{},{}\r\n",
239            e.id,
240            csv_quote(&e.timestamp),
241            csv_quote(&e.action),
242            csv_quote(&e.tracking_id),
243            e.user_id.map_or(String::new(), |id| id.to_string()),
244            csv_quote(e.details.as_deref().unwrap_or("")),
245        );
246    }
247    out
248}
249
250/// RFC 4180 quoting: wrap in double-quotes and escape inner quotes by
251/// doubling them. Only wrap if the value contains `,`, `"`, `\r`, or `\n`.
252fn csv_quote(value: &str) -> String {
253    let needs_quoting = value.chars().any(|c| matches!(c, ',' | '"' | '\r' | '\n'));
254    if needs_quoting {
255        format!("\"{}\"", value.replace('"', "\"\""))
256    } else {
257        value.to_owned()
258    }
259}
260
261/// Build a SARIF 2.1.0 log (as a `serde_json::Value`) from audit entries.
262///
263/// One `Run` with `tool.driver.name = "csaf-crud"`, one `Result` per
264/// entry. `rule_id` mirrors the audit `action`; severity `Level` is
265/// `note` for routine read/create/import/export, `warning` for any
266/// mutation or reset.
267fn to_sarif(entries: &[AuditLogEntry]) -> serde_json::Value {
268    use serde_json::json;
269
270    // Unique rule definitions, one per observed action, so downstream
271    // SARIF consumers can cite a rule by ID.
272    let mut rule_ids: Vec<&str> = entries.iter().map(|e| e.action.as_str()).collect();
273    rule_ids.sort_unstable();
274    rule_ids.dedup();
275
276    let rules: Vec<serde_json::Value> = rule_ids
277        .iter()
278        .map(|rule_id| {
279            json!({
280                "id": rule_id,
281                "name": rule_id,
282                "shortDescription": { "text": format!("Audit event: {rule_id}") },
283                "fullDescription":  { "text": format!("CSAF CRUD audit-log action '{rule_id}'.") },
284                "defaultConfiguration": { "level": default_level_for_action(rule_id) },
285            })
286        })
287        .collect();
288
289    let results: Vec<serde_json::Value> = entries
290        .iter()
291        .map(|e| {
292            let mut props = serde_json::Map::new();
293            props.insert("timestamp".to_owned(), json!(e.timestamp));
294            props.insert("audit_id".to_owned(), json!(e.id));
295            if let Some(uid) = e.user_id {
296                props.insert("user_id".to_owned(), json!(uid));
297            }
298            if let Some(details) = &e.details {
299                props.insert("details".to_owned(), json!(details));
300            }
301
302            json!({
303                "ruleId": e.action,
304                "level":  default_level_for_action(&e.action),
305                "message": {
306                    "text": format!(
307                        "{action} {tracking} at {ts}",
308                        action = e.action,
309                        tracking = e.tracking_id,
310                        ts = e.timestamp,
311                    )
312                },
313                "properties": props,
314            })
315        })
316        .collect();
317
318    json!({
319        "$schema": SARIF_SCHEMA,
320        "version": "2.1.0",
321        "runs": [{
322            "tool": {
323                "driver": {
324                    "name": SARIF_DRIVER_NAME,
325                    "version": env!("CARGO_PKG_VERSION"),
326                    "informationUri": "https://gitlab.com/vPierre/ndaal_public_csaf_crud",
327                    "rules": rules,
328                }
329            },
330            "results": results,
331        }]
332    })
333}
334
335/// SARIF severity for an audit-log action.
336///
337/// Any mutation ('update', 'delete') or state reset ('settings_reset')
338/// is `warning`. Everything else (create / import / export / read) is
339/// `note`.
340fn default_level_for_action(action: &str) -> &'static str {
341    match action {
342        "delete" | "update" | "settings_reset" => "warning",
343        _ => "note",
344    }
345}
346
347/// Cheap structural SARIF 2.1.0 validator.
348///
349/// Asserts the invariants we care about: top-level `version = "2.1.0"`,
350/// `$schema` present, exactly one run, every result has `ruleId`, a
351/// `message.text`, and a recognised `level`. A full JSON-Schema
352/// validation would pull in a heavy dep; these checks catch every real
353/// regression we have observed.
354fn validate_sarif(log: &serde_json::Value) -> Result<()> {
355    let invalid = |msg: &str| -> CsafError { CsafError::Config(format!("SARIF invalid: {msg}")) };
356
357    let Some(version) = log.get("version").and_then(|v| v.as_str()) else {
358        return Err(invalid("missing `version`"));
359    };
360    if version != "2.1.0" {
361        return Err(invalid("version must be '2.1.0'"));
362    }
363    if log.get("$schema").and_then(|v| v.as_str()).is_none() {
364        return Err(invalid("missing `$schema`"));
365    }
366
367    let Some(runs) = log.get("runs").and_then(|v| v.as_array()) else {
368        return Err(invalid("`runs` must be an array"));
369    };
370    let [run] = runs.as_slice() else {
371        return Err(invalid("exactly one run expected"));
372    };
373    if run.pointer("/tool/driver/name").is_none() {
374        return Err(invalid("missing `tool.driver.name`"));
375    }
376
377    if let Some(results) = run.get("results").and_then(|v| v.as_array()) {
378        for (idx, r) in results.iter().enumerate() {
379            if r.get("ruleId").and_then(|v| v.as_str()).is_none() {
380                return Err(invalid(&format!("result[{idx}] missing ruleId")));
381            }
382            if r.pointer("/message/text")
383                .and_then(|v| v.as_str())
384                .is_none()
385            {
386                return Err(invalid(&format!("result[{idx}] missing message.text")));
387            }
388            let level = r.get("level").and_then(|v| v.as_str()).unwrap_or("");
389            if !matches!(level, "none" | "note" | "warning" | "error") {
390                return Err(invalid(&format!(
391                    "result[{idx}] has unknown level `{level}`"
392                )));
393            }
394        }
395    }
396    Ok(())
397}
398
399#[cfg(test)]
400// Extension comparisons in these tests are intentionally case-sensitive:
401// every filename is produced by our own code, so the deterministic
402// lowercase form is both necessary and sufficient.
403#[allow(clippy::case_sensitive_file_extension_comparisons)]
404mod tests {
405    use super::*;
406
407    fn seed_entries(count: usize) -> Vec<AuditLogEntry> {
408        (0..count)
409            .map(|i| {
410                // `i` is a small loop counter bounded by `count` at the
411                // call site — go through `try_from` so the cast can
412                // never wrap on any 64-bit target and so rust-doctor's
413                // `cast_possible_wrap` stays quiet.
414                let id = i64::try_from(i).unwrap_or(i64::MAX);
415                AuditLogEntry {
416                    id,
417                    timestamp: format!("2026-04-23T00:00:{i:02}Z"),
418                    action: if i % 3 == 0 {
419                        "create".to_owned()
420                    } else if i % 3 == 1 {
421                        "update".to_owned()
422                    } else {
423                        "delete".to_owned()
424                    },
425                    tracking_id: format!("ndaal-sa-2026-{i:03}"),
426                    user_id: if i % 2 == 0 { Some(id) } else { None },
427                    details: if i % 4 == 0 {
428                        Some(format!("row {i}"))
429                    } else {
430                        None
431                    },
432                }
433            })
434            .collect()
435    }
436
437    #[test]
438    fn test_to_markdown_header_and_rows() {
439        let entries = seed_entries(3);
440        let md = to_markdown(&entries);
441        assert!(md.starts_with("# Audit Log Export"));
442        assert!(md.contains("Total rows: **3**"));
443        assert!(md.contains("| id | timestamp | action | tracking_id | user_id | details |"));
444        assert!(md.contains("ndaal-sa-2026-000"));
445        assert!(md.contains("ndaal-sa-2026-002"));
446    }
447
448    #[test]
449    fn test_to_markdown_escapes_pipe_and_newline() {
450        let e = AuditLogEntry {
451            id: 1,
452            timestamp: "2026-04-23T00:00:00Z".to_owned(),
453            action: "create".to_owned(),
454            tracking_id: "ndaal-sa-2026-001".to_owned(),
455            user_id: None,
456            details: Some("a | b\nc".to_owned()),
457        };
458        let md = to_markdown(std::slice::from_ref(&e));
459        // Pipe escaped, newline flattened.
460        assert!(md.contains(r"a \| b c"));
461        assert!(!md.contains("a | b\nc"));
462    }
463
464    #[test]
465    fn test_to_csv_rfc4180_quoting() {
466        let e = AuditLogEntry {
467            id: 1,
468            timestamp: "2026-04-23T00:00:00Z".to_owned(),
469            action: "create".to_owned(),
470            tracking_id: "ndaal-sa-2026-001".to_owned(),
471            user_id: Some(42),
472            details: Some(r#"has,comma and "quote""#.to_owned()),
473        };
474        let csv = to_csv(std::slice::from_ref(&e));
475        assert!(csv.starts_with("id,timestamp,action,tracking_id,user_id,details\r\n"));
476        // RFC 4180: quote-wrap + double the inner quotes.
477        assert!(csv.contains(r#""has,comma and ""quote"""#));
478        // Rows must be CRLF-terminated.
479        assert!(csv.ends_with("\r\n"));
480    }
481
482    #[test]
483    fn test_to_sarif_passes_self_validation() {
484        let entries = seed_entries(5);
485        let sarif = to_sarif(&entries);
486        validate_sarif(&sarif).expect("self-produced SARIF must validate");
487
488        assert_eq!(sarif["version"], "2.1.0");
489        assert!(sarif["$schema"].is_string());
490        assert_eq!(sarif["runs"][0]["tool"]["driver"]["name"], "csaf-crud");
491        assert_eq!(sarif["runs"][0]["results"].as_array().unwrap().len(), 5);
492    }
493
494    #[test]
495    fn test_to_sarif_levels() {
496        let entries = vec![
497            AuditLogEntry {
498                id: 1,
499                timestamp: "t".to_owned(),
500                action: "create".to_owned(),
501                tracking_id: "x".to_owned(),
502                user_id: None,
503                details: None,
504            },
505            AuditLogEntry {
506                id: 2,
507                timestamp: "t".to_owned(),
508                action: "delete".to_owned(),
509                tracking_id: "x".to_owned(),
510                user_id: None,
511                details: None,
512            },
513            AuditLogEntry {
514                id: 3,
515                timestamp: "t".to_owned(),
516                action: "settings_reset".to_owned(),
517                tracking_id: "all".to_owned(),
518                user_id: None,
519                details: None,
520            },
521        ];
522        let sarif = to_sarif(&entries);
523        assert_eq!(sarif["runs"][0]["results"][0]["level"], "note");
524        assert_eq!(sarif["runs"][0]["results"][1]["level"], "warning");
525        assert_eq!(sarif["runs"][0]["results"][2]["level"], "warning");
526    }
527
528    #[test]
529    fn test_validate_sarif_rejects_bad_version() {
530        let bad = serde_json::json!({
531            "version": "1.0",
532            "$schema": "https://example/sarif",
533            "runs": [{ "tool": { "driver": { "name": "x" } } }],
534        });
535        assert!(validate_sarif(&bad).is_err());
536    }
537
538    #[test]
539    fn test_validate_sarif_rejects_missing_rule_id() {
540        let bad = serde_json::json!({
541            "version": "2.1.0",
542            "$schema": "https://example/sarif",
543            "runs": [{
544                "tool": { "driver": { "name": "x" } },
545                "results": [{ "message": { "text": "m" }, "level": "note" }],
546            }],
547        });
548        assert!(validate_sarif(&bad).is_err());
549    }
550
551    #[test]
552    fn test_export_audit_log_writes_four_payloads_and_sidecars() {
553        let pool = DbPool::open_in_memory().expect("db open");
554        pool.with_conn(|conn| {
555            audit_log::record(conn, "create", "ndaal-sa-2026-001", None, None)?;
556            audit_log::record(conn, "delete", "ndaal-sa-2026-002", None, None)?;
557            audit_log::record(conn, "settings_reset", "all", None, None)
558        })
559        .expect("seed audit_log");
560
561        let out = tempfile::tempdir().expect("tmp");
562        let settings = Settings::default(); // all sidecars ON
563        let res = export_audit_log(&pool, out.path(), &settings, &AuditExportOptions::default())
564            .expect("export ok");
565
566        assert_eq!(res.rows, 3);
567        assert_eq!(res.written.len(), 4);
568        // 3 sidecars per payload × 4 payloads = 12.
569        assert_eq!(res.sidecars.len(), 12);
570
571        for path in &res.written {
572            assert!(path.exists(), "missing payload {}", path.display());
573        }
574        for side in &res.sidecars {
575            assert!(side.exists(), "missing sidecar {}", side.display());
576            let name = side.file_name().unwrap().to_string_lossy();
577            assert!(
578                name.ends_with(".sha-256")
579                    || name.ends_with(".sha-512")
580                    || name.ends_with(".sha3-512"),
581                "unexpected sidecar extension: {name}"
582            );
583            assert!(!name.ends_with(".sha256"));
584            assert!(!name.ends_with(".sha512"));
585        }
586    }
587
588    #[test]
589    fn test_export_audit_log_respects_format_opts() {
590        let pool = DbPool::open_in_memory().expect("db open");
591        pool.with_conn(|conn| audit_log::record(conn, "create", "t", None, None))
592            .expect("seed");
593
594        let out = tempfile::tempdir().expect("tmp");
595        let opts = AuditExportOptions {
596            markdown: false,
597            csv: true,
598            json: false,
599            sarif: true,
600        };
601        let res = export_audit_log(&pool, out.path(), &Settings::default(), &opts).expect("ok");
602        assert_eq!(res.written.len(), 2);
603        let names: Vec<String> = res
604            .written
605            .iter()
606            .map(|p| p.file_name().unwrap().to_string_lossy().to_string())
607            .collect();
608        assert!(names.iter().any(|n| n.ends_with(".csv")));
609        assert!(names.iter().any(|n| n.ends_with(".sarif")));
610        assert!(!names.iter().any(|n| n.ends_with(".md")));
611        assert!(!names.iter().any(|n| n.ends_with(".json")));
612    }
613
614    #[test]
615    fn test_export_audit_log_respects_sidecar_toggles() {
616        let pool = DbPool::open_in_memory().expect("db open");
617        pool.with_conn(|conn| audit_log::record(conn, "create", "t", None, None))
618            .expect("seed");
619
620        let out = tempfile::tempdir().expect("tmp");
621        let settings = Settings {
622            sidecar_sha256: true,
623            sidecar_sha512: false,
624            sidecar_sha3_512: true,
625            ..Settings::default()
626        };
627        let res = export_audit_log(&pool, out.path(), &settings, &AuditExportOptions::default())
628            .expect("ok");
629
630        // 4 payloads × 2 sidecars = 8.
631        assert_eq!(res.sidecars.len(), 8);
632        for side in &res.sidecars {
633            let name = side.file_name().unwrap().to_string_lossy();
634            assert!(
635                name.ends_with(".sha-256") || name.ends_with(".sha3-512"),
636                "sha-512 must be skipped: {name}"
637            );
638        }
639    }
640
641    #[test]
642    fn test_export_audit_log_creates_missing_dir() {
643        let pool = DbPool::open_in_memory().expect("db open");
644        let tmp = tempfile::tempdir().expect("tmp");
645        let nested = tmp.path().join("a/b/c");
646        assert!(!nested.exists());
647
648        export_audit_log(
649            &pool,
650            &nested,
651            &Settings::default(),
652            &AuditExportOptions::default(),
653        )
654        .expect("ok");
655        assert!(nested.exists());
656    }
657
658    #[test]
659    fn test_export_audit_log_empty_table() {
660        let pool = DbPool::open_in_memory().expect("db open");
661        let out = tempfile::tempdir().expect("tmp");
662        let res = export_audit_log(
663            &pool,
664            out.path(),
665            &Settings::default(),
666            &AuditExportOptions::default(),
667        )
668        .expect("ok");
669        assert_eq!(res.rows, 0);
670        assert_eq!(res.written.len(), 4);
671        // Each payload still gets its three sidecars.
672        assert_eq!(res.sidecars.len(), 12);
673    }
674
675    #[test]
676    fn test_export_audit_log_json_roundtrip() {
677        let pool = DbPool::open_in_memory().expect("db open");
678        pool.with_conn(|conn| audit_log::record(conn, "create", "t", None, Some("x")))
679            .expect("seed");
680
681        let out = tempfile::tempdir().expect("tmp");
682        let opts = AuditExportOptions {
683            markdown: false,
684            csv: false,
685            json: true,
686            sarif: false,
687        };
688        let res = export_audit_log(&pool, out.path(), &Settings::default(), &opts).expect("ok");
689
690        let bytes = std::fs::read(&res.written[0]).expect("read");
691        let parsed: Vec<AuditLogEntry> = serde_json::from_slice(&bytes).expect("parse");
692        assert_eq!(parsed.len(), 1);
693        assert_eq!(parsed[0].action, "create");
694    }
695}