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