use std::path::{Path, PathBuf};
use chrono::Utc;
use csaf_models::audit_log::{self, AuditLogEntry};
use csaf_models::db::DbPool;
use csaf_models::settings::Settings;
use crate::error::{CsafError, Result};
use crate::fs::DataDir;
use crate::sidecar;
const MAX_AUDIT_ROWS: usize = 10_000_000;
const SARIF_DRIVER_NAME: &str = "csaf-crud";
const SARIF_SCHEMA: &str = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(clippy::struct_excessive_bools)]
pub struct AuditExportOptions {
pub markdown: bool,
pub csv: bool,
pub json: bool,
pub sarif: bool,
}
impl Default for AuditExportOptions {
fn default() -> Self {
Self {
markdown: true,
csv: true,
json: true,
sarif: true,
}
}
}
#[derive(Debug, Clone)]
pub struct AuditExportResult {
pub timestamp: String,
pub rows: usize,
pub written: Vec<PathBuf>,
pub sidecars: Vec<PathBuf>,
}
pub fn export_audit_log(
pool: &DbPool,
out_dir: impl AsRef<Path>,
settings: &Settings,
opts: &AuditExportOptions,
) -> Result<AuditExportResult> {
let dir = crate::fs::DataDir::open_or_create(out_dir.as_ref())?;
let timestamp = Utc::now().format("%Y-%m-%dT%H-%M-%SZ").to_string();
let entries: Vec<AuditLogEntry> =
pool.with_conn(|conn| audit_log::list(conn, None, MAX_AUDIT_ROWS, 0))?;
let rows = entries.len();
let mut written: Vec<PathBuf> = Vec::new();
let mut sidecars: Vec<PathBuf> = Vec::new();
if opts.markdown {
let rel = format!("audit-{timestamp}.md");
let body = to_markdown(&entries);
dir.write(&rel, body.as_bytes())?;
record_sidecars(&dir, &rel, body.as_bytes(), settings, &mut sidecars)?;
written.push(dir.resolve(&rel));
}
if opts.csv {
let rel = format!("audit-{timestamp}.csv");
let body = to_csv(&entries);
dir.write(&rel, body.as_bytes())?;
record_sidecars(&dir, &rel, body.as_bytes(), settings, &mut sidecars)?;
written.push(dir.resolve(&rel));
}
if opts.json {
let rel = format!("audit-{timestamp}.json");
let body = serde_json::to_vec_pretty(&entries)?;
dir.write(&rel, &body)?;
record_sidecars(&dir, &rel, &body, settings, &mut sidecars)?;
written.push(dir.resolve(&rel));
}
if opts.sarif {
let rel = format!("audit-{timestamp}.sarif");
let sarif_value = to_sarif(&entries);
validate_sarif(&sarif_value)?;
let body = serde_json::to_vec_pretty(&sarif_value)?;
dir.write(&rel, &body)?;
record_sidecars(&dir, &rel, &body, settings, &mut sidecars)?;
written.push(dir.resolve(&rel));
}
Ok(AuditExportResult {
timestamp,
rows,
written,
sidecars,
})
}
fn record_sidecars(
dir: &DataDir,
rel: &str,
bytes: &[u8],
settings: &Settings,
sidecars: &mut Vec<PathBuf>,
) -> Result<()> {
for sidecar_rel in sidecar::write_sidecar_files_for(
dir,
rel,
bytes,
sidecar::SidecarHashes::from_settings(settings),
)? {
sidecars.push(dir.resolve(&sidecar_rel));
}
Ok(())
}
fn to_markdown(entries: &[AuditLogEntry]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(256 + entries.len() * 120);
out.push_str("# Audit Log Export\n\n");
let _ = writeln!(
out,
"Generated: `{}`\n",
Utc::now().format("%Y-%m-%dT%H:%M:%SZ")
);
let _ = writeln!(out, "Total rows: **{}**\n", entries.len());
out.push_str("| id | timestamp | action | tracking_id | user_id | details |\n");
out.push_str("|----|-----------|--------|-------------|---------|---------|\n");
for e in entries {
let _ = writeln!(
out,
"| {} | {} | {} | {} | {} | {} |",
e.id,
md_escape(&e.timestamp),
md_escape(&e.action),
md_escape(&e.tracking_id),
e.user_id
.map_or_else(|| "—".to_owned(), |id| id.to_string()),
md_escape(e.details.as_deref().unwrap_or("")),
);
}
out
}
fn md_escape(value: &str) -> String {
value
.replace('\\', r"\\")
.replace('|', r"\|")
.replace('\n', " ")
.replace('\r', "")
}
fn to_csv(entries: &[AuditLogEntry]) -> String {
use std::fmt::Write as _;
let mut out = String::with_capacity(128 + entries.len() * 100);
out.push_str("id,timestamp,action,tracking_id,user_id,details\r\n");
for e in entries {
let _ = write!(
out,
"{},{},{},{},{},{}\r\n",
e.id,
csv_quote(&e.timestamp),
csv_quote(&e.action),
csv_quote(&e.tracking_id),
e.user_id.map_or(String::new(), |id| id.to_string()),
csv_quote(e.details.as_deref().unwrap_or("")),
);
}
out
}
fn csv_quote(value: &str) -> String {
let needs_quoting = value.chars().any(|c| matches!(c, ',' | '"' | '\r' | '\n'));
if needs_quoting {
format!("\"{}\"", value.replace('"', "\"\""))
} else {
value.to_owned()
}
}
fn to_sarif(entries: &[AuditLogEntry]) -> serde_json::Value {
use serde_json::json;
let mut rule_ids: Vec<&str> = entries.iter().map(|e| e.action.as_str()).collect();
rule_ids.sort_unstable();
rule_ids.dedup();
let rules: Vec<serde_json::Value> = rule_ids
.iter()
.map(|rule_id| {
json!({
"id": rule_id,
"name": rule_id,
"shortDescription": { "text": format!("Audit event: {rule_id}") },
"fullDescription": { "text": format!("CSAF CRUD audit-log action '{rule_id}'.") },
"defaultConfiguration": { "level": default_level_for_action(rule_id) },
})
})
.collect();
let results: Vec<serde_json::Value> = entries
.iter()
.map(|e| {
let mut props = serde_json::Map::new();
props.insert("timestamp".to_owned(), json!(e.timestamp));
props.insert("audit_id".to_owned(), json!(e.id));
if let Some(uid) = e.user_id {
props.insert("user_id".to_owned(), json!(uid));
}
if let Some(details) = &e.details {
props.insert("details".to_owned(), json!(details));
}
json!({
"ruleId": e.action,
"level": default_level_for_action(&e.action),
"message": {
"text": format!(
"{action} {tracking} at {ts}",
action = e.action,
tracking = e.tracking_id,
ts = e.timestamp,
)
},
"properties": props,
})
})
.collect();
json!({
"$schema": SARIF_SCHEMA,
"version": "2.1.0",
"runs": [{
"tool": {
"driver": {
"name": SARIF_DRIVER_NAME,
"version": env!("CARGO_PKG_VERSION"),
"informationUri": "https://gitlab.com/vPierre/ndaal_public_csaf_crud",
"rules": rules,
}
},
"results": results,
}]
})
}
fn default_level_for_action(action: &str) -> &'static str {
match action {
"delete" | "update" | "settings_reset" => "warning",
_ => "note",
}
}
fn validate_sarif(log: &serde_json::Value) -> Result<()> {
let invalid = |msg: &str| -> CsafError { CsafError::Config(format!("SARIF invalid: {msg}")) };
let Some(version) = log.get("version").and_then(|v| v.as_str()) else {
return Err(invalid("missing `version`"));
};
if version != "2.1.0" {
return Err(invalid("version must be '2.1.0'"));
}
if log.get("$schema").and_then(|v| v.as_str()).is_none() {
return Err(invalid("missing `$schema`"));
}
let Some(runs) = log.get("runs").and_then(|v| v.as_array()) else {
return Err(invalid("`runs` must be an array"));
};
let [run] = runs.as_slice() else {
return Err(invalid("exactly one run expected"));
};
if run.pointer("/tool/driver/name").is_none() {
return Err(invalid("missing `tool.driver.name`"));
}
if let Some(results) = run.get("results").and_then(|v| v.as_array()) {
for (idx, r) in results.iter().enumerate() {
if r.get("ruleId").and_then(|v| v.as_str()).is_none() {
return Err(invalid(&format!("result[{idx}] missing ruleId")));
}
if r.pointer("/message/text")
.and_then(|v| v.as_str())
.is_none()
{
return Err(invalid(&format!("result[{idx}] missing message.text")));
}
let level = r.get("level").and_then(|v| v.as_str()).unwrap_or("");
if !matches!(level, "none" | "note" | "warning" | "error") {
return Err(invalid(&format!(
"result[{idx}] has unknown level `{level}`"
)));
}
}
}
Ok(())
}
#[cfg(test)]
#[allow(clippy::case_sensitive_file_extension_comparisons)]
mod tests {
use super::*;
fn seed_entries(count: usize) -> Vec<AuditLogEntry> {
(0..count)
.map(|i| {
let id = i64::try_from(i).unwrap_or(i64::MAX);
AuditLogEntry {
id,
timestamp: format!("2026-04-23T00:00:{i:02}Z"),
action: if i % 3 == 0 {
"create".to_owned()
} else if i % 3 == 1 {
"update".to_owned()
} else {
"delete".to_owned()
},
tracking_id: format!("ndaal-sa-2026-{i:03}"),
user_id: if i % 2 == 0 { Some(id) } else { None },
details: if i % 4 == 0 {
Some(format!("row {i}"))
} else {
None
},
}
})
.collect()
}
#[test]
fn test_to_markdown_header_and_rows() {
let entries = seed_entries(3);
let md = to_markdown(&entries);
assert!(md.starts_with("# Audit Log Export"));
assert!(md.contains("Total rows: **3**"));
assert!(md.contains("| id | timestamp | action | tracking_id | user_id | details |"));
assert!(md.contains("ndaal-sa-2026-000"));
assert!(md.contains("ndaal-sa-2026-002"));
}
#[test]
fn test_to_markdown_escapes_pipe_and_newline() {
let e = AuditLogEntry {
id: 1,
timestamp: "2026-04-23T00:00:00Z".to_owned(),
action: "create".to_owned(),
tracking_id: "ndaal-sa-2026-001".to_owned(),
user_id: None,
details: Some("a | b\nc".to_owned()),
};
let md = to_markdown(std::slice::from_ref(&e));
assert!(md.contains(r"a \| b c"));
assert!(!md.contains("a | b\nc"));
}
#[test]
fn test_to_csv_rfc4180_quoting() {
let e = AuditLogEntry {
id: 1,
timestamp: "2026-04-23T00:00:00Z".to_owned(),
action: "create".to_owned(),
tracking_id: "ndaal-sa-2026-001".to_owned(),
user_id: Some(42),
details: Some(r#"has,comma and "quote""#.to_owned()),
};
let csv = to_csv(std::slice::from_ref(&e));
assert!(csv.starts_with("id,timestamp,action,tracking_id,user_id,details\r\n"));
assert!(csv.contains(r#""has,comma and ""quote"""#));
assert!(csv.ends_with("\r\n"));
}
#[test]
fn test_to_sarif_passes_self_validation() {
let entries = seed_entries(5);
let sarif = to_sarif(&entries);
validate_sarif(&sarif).expect("self-produced SARIF must validate");
assert_eq!(sarif["version"], "2.1.0");
assert!(sarif["$schema"].is_string());
assert_eq!(sarif["runs"][0]["tool"]["driver"]["name"], "csaf-crud");
assert_eq!(sarif["runs"][0]["results"].as_array().unwrap().len(), 5);
}
#[test]
fn test_to_sarif_levels() {
let entries = vec![
AuditLogEntry {
id: 1,
timestamp: "t".to_owned(),
action: "create".to_owned(),
tracking_id: "x".to_owned(),
user_id: None,
details: None,
},
AuditLogEntry {
id: 2,
timestamp: "t".to_owned(),
action: "delete".to_owned(),
tracking_id: "x".to_owned(),
user_id: None,
details: None,
},
AuditLogEntry {
id: 3,
timestamp: "t".to_owned(),
action: "settings_reset".to_owned(),
tracking_id: "all".to_owned(),
user_id: None,
details: None,
},
];
let sarif = to_sarif(&entries);
assert_eq!(sarif["runs"][0]["results"][0]["level"], "note");
assert_eq!(sarif["runs"][0]["results"][1]["level"], "warning");
assert_eq!(sarif["runs"][0]["results"][2]["level"], "warning");
}
#[test]
fn test_validate_sarif_rejects_bad_version() {
let bad = serde_json::json!({
"version": "1.0",
"$schema": "https://example/sarif",
"runs": [{ "tool": { "driver": { "name": "x" } } }],
});
assert!(validate_sarif(&bad).is_err());
}
#[test]
fn test_validate_sarif_rejects_missing_rule_id() {
let bad = serde_json::json!({
"version": "2.1.0",
"$schema": "https://example/sarif",
"runs": [{
"tool": { "driver": { "name": "x" } },
"results": [{ "message": { "text": "m" }, "level": "note" }],
}],
});
assert!(validate_sarif(&bad).is_err());
}
#[test]
fn test_export_audit_log_writes_four_payloads_and_sidecars() {
let pool = DbPool::open_in_memory().expect("db open");
pool.with_conn(|conn| {
audit_log::record(conn, "create", "ndaal-sa-2026-001", None, None)?;
audit_log::record(conn, "delete", "ndaal-sa-2026-002", None, None)?;
audit_log::record(conn, "settings_reset", "all", None, None)
})
.expect("seed audit_log");
let out = tempfile::tempdir().expect("tmp");
let settings = Settings::default(); let res = export_audit_log(&pool, out.path(), &settings, &AuditExportOptions::default())
.expect("export ok");
assert_eq!(res.rows, 3);
assert_eq!(res.written.len(), 4);
assert_eq!(res.sidecars.len(), 20);
for path in &res.written {
assert!(path.exists(), "missing payload {}", path.display());
}
for side in &res.sidecars {
assert!(side.exists(), "missing sidecar {}", side.display());
let name = side.file_name().unwrap().to_string_lossy();
assert!(
name.ends_with(".sha-256")
|| name.ends_with(".sha-512")
|| name.ends_with(".sha3-512")
|| name.ends_with(".blake3-512")
|| name.ends_with(".shake256-512"),
"unexpected sidecar extension: {name}"
);
assert!(!name.ends_with(".sha256"));
assert!(!name.ends_with(".sha512"));
}
}
#[test]
fn test_export_audit_log_respects_format_opts() {
let pool = DbPool::open_in_memory().expect("db open");
pool.with_conn(|conn| audit_log::record(conn, "create", "t", None, None))
.expect("seed");
let out = tempfile::tempdir().expect("tmp");
let opts = AuditExportOptions {
markdown: false,
csv: true,
json: false,
sarif: true,
};
let res = export_audit_log(&pool, out.path(), &Settings::default(), &opts).expect("ok");
assert_eq!(res.written.len(), 2);
let names: Vec<String> = res
.written
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().to_string())
.collect();
assert!(names.iter().any(|n| n.ends_with(".csv")));
assert!(names.iter().any(|n| n.ends_with(".sarif")));
assert!(!names.iter().any(|n| n.ends_with(".md")));
assert!(!names.iter().any(|n| n.ends_with(".json")));
}
#[test]
fn test_export_audit_log_respects_sidecar_toggles() {
let pool = DbPool::open_in_memory().expect("db open");
pool.with_conn(|conn| audit_log::record(conn, "create", "t", None, None))
.expect("seed");
let out = tempfile::tempdir().expect("tmp");
let settings = Settings {
sidecar_sha256: true,
sidecar_sha512: false,
sidecar_sha3_512: true,
sidecar_blake3_512: false,
sidecar_shake256_512: false,
..Settings::default()
};
let res = export_audit_log(&pool, out.path(), &settings, &AuditExportOptions::default())
.expect("ok");
assert_eq!(res.sidecars.len(), 8);
for side in &res.sidecars {
let name = side.file_name().unwrap().to_string_lossy();
assert!(
name.ends_with(".sha-256") || name.ends_with(".sha3-512"),
"sha-512 must be skipped: {name}"
);
}
}
#[test]
fn test_export_audit_log_creates_missing_dir() {
let pool = DbPool::open_in_memory().expect("db open");
let tmp = tempfile::tempdir().expect("tmp");
let nested = tmp.path().join("a/b/c");
assert!(!nested.exists());
export_audit_log(
&pool,
&nested,
&Settings::default(),
&AuditExportOptions::default(),
)
.expect("ok");
assert!(nested.exists());
}
#[test]
fn test_export_audit_log_empty_table() {
let pool = DbPool::open_in_memory().expect("db open");
let out = tempfile::tempdir().expect("tmp");
let res = export_audit_log(
&pool,
out.path(),
&Settings::default(),
&AuditExportOptions::default(),
)
.expect("ok");
assert_eq!(res.rows, 0);
assert_eq!(res.written.len(), 4);
assert_eq!(res.sidecars.len(), 20);
}
#[test]
fn test_export_audit_log_json_roundtrip() {
let pool = DbPool::open_in_memory().expect("db open");
pool.with_conn(|conn| audit_log::record(conn, "create", "t", None, Some("x")))
.expect("seed");
let out = tempfile::tempdir().expect("tmp");
let opts = AuditExportOptions {
markdown: false,
csv: false,
json: true,
sarif: false,
};
let res = export_audit_log(&pool, out.path(), &Settings::default(), &opts).expect("ok");
let bytes = std::fs::read(&res.written[0]).expect("read");
let parsed: Vec<AuditLogEntry> = serde_json::from_slice(&bytes).expect("parse");
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].action, "create");
}
}