Skip to main content

reddb_server/runtime/
audit_log.rs

1//! Structured audit log for SOC 2 / HIPAA / ISO 27001 deploys.
2//!
3//! Replaces the previous free-form `record(action, principal, target,
4//! result, details)` API with a stable JSON-Lines schema keyed on
5//! `event_id`, `ts`, `principal`, `tenant`, `action`, `resource`,
6//! `outcome`, `detail`, `remote_addr`, and `correlation_id` so external
7//! tooling (Splunk, Datadog HEC, ELK, BigQuery, Athena) can ingest the
8//! file without per-deploy regex.
9//!
10//! Operational properties:
11//!   * **Async-write**: emit sites push onto a bounded `std::sync::mpsc`
12//!     channel; a dedicated thread owns the file handle and flushes on
13//!     a periodic timer (default 250 ms) or per-event when
14//!     `RED_AUDIT_FSYNC=every`. If the channel fills the emit site
15//!     falls back to a direct sync write — losing throughput is
16//!     preferable to dropping audit lines.
17//!   * **Rotation**: when the active file exceeds `RED_AUDIT_MAX_BYTES`
18//!     (default 64 MiB) the writer renames it to
19//!     `.audit.log.<ms>.zst`, zstd-compresses it, and starts a fresh
20//!     active file. The repo already pulls `zstd`; we don't add a gzip
21//!     dependency.
22//!   * **Hash chain (tamper-evidence)**: each event carries a
23//!     `prev_hash` field — the sha256 of the previous JSON line. An
24//!     auditor verifying the file recomputes the chain; a single edit
25//!     anywhere in the file breaks every subsequent hash. Does **not**
26//!     defend against an attacker with `root + write` (they could
27//!     rebuild the chain), but it does defend against accidental edits
28//!     and most insider tampering.
29//!   * **SIEM streaming**: when `RED_AUDIT_STREAM_URL` is set every
30//!     line is also POSTed to that URL fire-and-forget.
31//!
32//! Pre-1.0: the file format breaks from the previous shape. Old
33//! `.audit.log` files are NOT readable by the new query endpoint.
34//! That's an accepted regression — operators upgrading should rotate
35//! the file before the deploy.
36
37use std::io::{BufWriter, Write};
38use std::path::{Path, PathBuf};
39use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
40use std::sync::{mpsc, Arc, Mutex};
41use std::thread::{self, JoinHandle};
42use std::time::{Duration, Instant};
43
44use crate::crypto::{os_random, sha256};
45use crate::json::{Map, Value as JsonValue};
46use reddb_types::encoding::base64_encode;
47
48// ---------------------------------------------------------------------------
49// Schema
50// ---------------------------------------------------------------------------
51
52/// Auth pathway that produced the principal. Decoupled from
53/// `crate::auth::AuthSource` so we can record System / Anonymous /
54/// Session / ApiKey lanes that aren't surfaced by the runtime auth
55/// enum (which only covers Password / ClientCert / Oauth today).
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum AuditAuthSource {
58    ApiKey,
59    Session,
60    Password,
61    Oauth,
62    ClientCert,
63    Anonymous,
64    System,
65}
66
67impl AuditAuthSource {
68    pub fn as_str(self) -> &'static str {
69        match self {
70            Self::ApiKey => "api_key",
71            Self::Session => "session",
72            Self::Password => "password",
73            Self::Oauth => "oauth",
74            Self::ClientCert => "client_cert",
75            Self::Anonymous => "anonymous",
76            Self::System => "system",
77        }
78    }
79
80    pub fn parse(s: &str) -> Option<Self> {
81        Some(match s {
82            "api_key" => Self::ApiKey,
83            "session" => Self::Session,
84            "password" => Self::Password,
85            "oauth" => Self::Oauth,
86            "client_cert" => Self::ClientCert,
87            "anonymous" => Self::Anonymous,
88            "system" => Self::System,
89            _ => return None,
90        })
91    }
92}
93
94/// Outcome of the audited action.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub enum Outcome {
97    Success,
98    Denied,
99    Error,
100}
101
102impl Outcome {
103    pub fn as_str(self) -> &'static str {
104        match self {
105            Self::Success => "success",
106            Self::Denied => "denied",
107            Self::Error => "error",
108        }
109    }
110
111    pub fn parse(s: &str) -> Option<Self> {
112        Some(match s {
113            "success" | "ok" => Self::Success,
114            "denied" | "deny" => Self::Denied,
115            "error" | "err" => Self::Error,
116            _ => return None,
117        })
118    }
119}
120
121/// Structured audit event. Serialised as one JSONL row per call.
122#[derive(Debug, Clone)]
123pub struct AuditEvent {
124    pub ts: u128,
125    pub event_id: String,
126    pub principal: Option<String>,
127    pub source: AuditAuthSource,
128    pub tenant: Option<String>,
129    pub action: String,
130    pub resource: Option<String>,
131    pub outcome: Outcome,
132    pub detail: JsonValue,
133    pub remote_addr: Option<String>,
134    pub correlation_id: Option<String>,
135}
136
137impl AuditEvent {
138    /// Convenience builder. The caller fills the required fields and
139    /// chains the optional ones — keeps emit sites readable.
140    pub fn builder(action: impl Into<String>) -> AuditEventBuilder {
141        AuditEventBuilder {
142            inner: AuditEvent {
143                ts: crate::utils::now_unix_millis() as u128,
144                event_id: new_event_id(),
145                principal: None,
146                source: AuditAuthSource::System,
147                tenant: None,
148                action: action.into(),
149                resource: None,
150                outcome: Outcome::Success,
151                detail: JsonValue::Null,
152                remote_addr: None,
153                correlation_id: None,
154            },
155        }
156    }
157
158    /// Wrap a pre-structured-API call into the new schema. Used by the
159    /// back-compat `record(action, principal, target, result, details)`
160    /// path so existing emit sites keep compiling.
161    pub fn from_legacy(
162        action: &str,
163        principal: &str,
164        target: &str,
165        result: &str,
166        details: JsonValue,
167    ) -> Self {
168        let outcome = if result == "ok" {
169            Outcome::Success
170        } else if result.starts_with("err") {
171            Outcome::Error
172        } else if result.starts_with("denied") || result.starts_with("deny") {
173            Outcome::Denied
174        } else {
175            Outcome::Success
176        };
177        let mut detail = details;
178        if !result.is_empty() && result != "ok" {
179            // Preserve the human-readable result string for forensic
180            // continuity with pre-restructuring lines.
181            let mut obj = match detail {
182                JsonValue::Object(map) => map,
183                JsonValue::Null => Map::new(),
184                other => {
185                    let mut m = Map::new();
186                    m.insert("legacy".to_string(), other);
187                    m
188                }
189            };
190            obj.entry("result_text".to_string())
191                .or_insert(JsonValue::String(result.to_string()));
192            detail = JsonValue::Object(obj);
193        }
194        Self {
195            ts: crate::utils::now_unix_millis() as u128,
196            event_id: new_event_id(),
197            principal: if principal.is_empty() || principal == "system" {
198                None
199            } else {
200                Some(principal.to_string())
201            },
202            source: if principal == "system" {
203                AuditAuthSource::System
204            } else if principal.is_empty() {
205                AuditAuthSource::Anonymous
206            } else {
207                AuditAuthSource::Password
208            },
209            tenant: None,
210            action: action.to_string(),
211            resource: if target.is_empty() {
212                None
213            } else {
214                Some(target.to_string())
215            },
216            outcome,
217            detail,
218            remote_addr: None,
219            correlation_id: None,
220        }
221    }
222
223    /// Render to a single JSONL line, optionally seeded with a
224    /// `prev_hash` for the tamper-evident chain.
225    pub fn to_json_line(&self, prev_hash: Option<&str>) -> String {
226        let mut object = Map::new();
227        object.insert("ts".to_string(), JsonValue::Number(self.ts as f64));
228        object.insert(
229            "ts_iso".to_string(),
230            JsonValue::String(format_iso8601(self.ts as u64)),
231        );
232        object.insert(
233            "event_id".to_string(),
234            JsonValue::String(self.event_id.clone()),
235        );
236        if let Some(p) = &self.principal {
237            object.insert("principal".to_string(), JsonValue::String(p.clone()));
238        }
239        object.insert(
240            "source".to_string(),
241            JsonValue::String(self.source.as_str().to_string()),
242        );
243        if let Some(t) = &self.tenant {
244            object.insert("tenant".to_string(), JsonValue::String(t.clone()));
245        }
246        object.insert("action".to_string(), JsonValue::String(self.action.clone()));
247        if let Some(r) = &self.resource {
248            object.insert("resource".to_string(), JsonValue::String(r.clone()));
249        }
250        object.insert(
251            "outcome".to_string(),
252            JsonValue::String(self.outcome.as_str().to_string()),
253        );
254        if !matches!(self.detail, JsonValue::Null) {
255            object.insert("detail".to_string(), self.detail.clone());
256        }
257        if let Some(ip) = &self.remote_addr {
258            object.insert("remote_addr".to_string(), JsonValue::String(ip.clone()));
259        }
260        if let Some(cid) = &self.correlation_id {
261            object.insert("correlation_id".to_string(), JsonValue::String(cid.clone()));
262        }
263        if let Some(h) = prev_hash {
264            object.insert("prev_hash".to_string(), JsonValue::String(h.to_string()));
265        }
266        JsonValue::Object(object).to_string_compact()
267    }
268
269    /// Parse one JSONL line back into an `AuditEvent`. Returns `None`
270    /// for legacy lines (pre-restructuring) or malformed JSON; callers
271    /// in the query path use this to filter.
272    pub fn parse_line(line: &str) -> Option<Self> {
273        let v: JsonValue = crate::json::from_str(line).ok()?;
274        let action = v.get("action")?.as_str()?.to_string();
275        let outcome_s = v.get("outcome").and_then(|n| n.as_str()).unwrap_or("");
276        let outcome = Outcome::parse(outcome_s)?;
277        let ts = v.get("ts").and_then(|n| n.as_f64()).unwrap_or(0.0) as u128;
278        let event_id = v
279            .get("event_id")
280            .and_then(|n| n.as_str())
281            .unwrap_or("")
282            .to_string();
283        let source = v
284            .get("source")
285            .and_then(|n| n.as_str())
286            .and_then(AuditAuthSource::parse)
287            .unwrap_or(AuditAuthSource::System);
288        Some(Self {
289            ts,
290            event_id,
291            principal: v
292                .get("principal")
293                .and_then(|n| n.as_str())
294                .map(|s| s.to_string()),
295            source,
296            tenant: v
297                .get("tenant")
298                .and_then(|n| n.as_str())
299                .map(|s| s.to_string()),
300            action,
301            resource: v
302                .get("resource")
303                .and_then(|n| n.as_str())
304                .map(|s| s.to_string()),
305            outcome,
306            detail: v.get("detail").cloned().unwrap_or(JsonValue::Null),
307            remote_addr: v
308                .get("remote_addr")
309                .and_then(|n| n.as_str())
310                .map(|s| s.to_string()),
311            correlation_id: v
312                .get("correlation_id")
313                .and_then(|n| n.as_str())
314                .map(|s| s.to_string()),
315        })
316    }
317}
318
319/// Builder for `AuditEvent`. Generated by `AuditEvent::builder()`.
320pub struct AuditEventBuilder {
321    inner: AuditEvent,
322}
323
324impl AuditEventBuilder {
325    pub fn principal(mut self, principal: impl Into<String>) -> Self {
326        self.inner.principal = Some(principal.into());
327        self
328    }
329
330    pub fn principal_opt(mut self, principal: Option<String>) -> Self {
331        self.inner.principal = principal;
332        self
333    }
334
335    pub fn source(mut self, source: AuditAuthSource) -> Self {
336        self.inner.source = source;
337        self
338    }
339
340    pub fn tenant(mut self, tenant: impl Into<String>) -> Self {
341        self.inner.tenant = Some(tenant.into());
342        self
343    }
344
345    pub fn resource(mut self, resource: impl Into<String>) -> Self {
346        self.inner.resource = Some(resource.into());
347        self
348    }
349
350    pub fn outcome(mut self, outcome: Outcome) -> Self {
351        self.inner.outcome = outcome;
352        self
353    }
354
355    pub fn detail(mut self, detail: JsonValue) -> Self {
356        self.inner.detail = detail;
357        self
358    }
359
360    /// Add a single typed audit field to the detail object. Goes
361    /// through `AuditFieldEscaper` so caller-supplied bytes never
362    /// reach `format!` or string concatenation — see ADR 0010.
363    pub fn field(mut self, field: AuditField) -> Self {
364        let mut obj = match std::mem::replace(&mut self.inner.detail, JsonValue::Null) {
365            JsonValue::Object(map) => map,
366            JsonValue::Null => Map::new(),
367            other => {
368                // Preserve any preceding non-object detail under a
369                // reserved key rather than dropping it on the floor.
370                let mut m = Map::new();
371                m.insert("legacy_detail".to_string(), other);
372                m
373            }
374        };
375        obj.insert(field.name.to_string(), field.value.into_json_value());
376        self.inner.detail = JsonValue::Object(obj);
377        self
378    }
379
380    /// Bulk variant of [`Self::field`] for multi-field call sites.
381    pub fn fields(mut self, fields: impl IntoIterator<Item = AuditField>) -> Self {
382        for f in fields {
383            self = self.field(f);
384        }
385        self
386    }
387
388    pub fn remote_addr(mut self, addr: impl Into<String>) -> Self {
389        self.inner.remote_addr = Some(addr.into());
390        self
391    }
392
393    pub fn correlation_id(mut self, cid: impl Into<String>) -> Self {
394        self.inner.correlation_id = Some(cid.into());
395        self
396    }
397
398    pub fn build(self) -> AuditEvent {
399        self.inner
400    }
401}
402
403// ---------------------------------------------------------------------------
404// AuditFieldEscaper — typed-field guard (ADR 0010 / issue #177)
405// ---------------------------------------------------------------------------
406
407/// Typed value variants accepted by the audit-field guard. The
408/// serializer (`to_json_line`) owns the framing; an `AuditValue`
409/// cannot smuggle structural bytes past the canonical encoder
410/// (`crate::serde_json::Value::escape_string`, RFC 8259 §7) because
411/// the variant is consumed as a typed value, not as an interpolated
412/// string. Adversarial corpora (CRLF, NUL, quote, semicolon,
413/// JSON-in-JSON, control bytes 0x00..0x20) survive the boundary
414/// because the encoder emits `\u00XX` escapes for every byte below
415/// 0x20.
416#[derive(Debug, Clone)]
417pub enum AuditValue {
418    String(String),
419    /// Arbitrary bytes — base64-encoded on emit so non-UTF-8 payloads
420    /// (binary keys, raw frame bytes) never enter the JSON string
421    /// channel.
422    Bytes(Vec<u8>),
423    Number(i64),
424    Bool(bool),
425    Null,
426}
427
428impl AuditValue {
429    fn into_json_value(self) -> JsonValue {
430        match self {
431            AuditValue::String(s) => JsonValue::String(s),
432            AuditValue::Bytes(bytes) => JsonValue::String(base64_encode(&bytes)),
433            AuditValue::Number(n) => JsonValue::Number(n as f64),
434            AuditValue::Bool(b) => JsonValue::Bool(b),
435            AuditValue::Null => JsonValue::Null,
436        }
437    }
438}
439
440impl From<String> for AuditValue {
441    fn from(value: String) -> Self {
442        AuditValue::String(value)
443    }
444}
445
446impl From<&str> for AuditValue {
447    fn from(value: &str) -> Self {
448        AuditValue::String(value.to_string())
449    }
450}
451
452impl From<&String> for AuditValue {
453    fn from(value: &String) -> Self {
454        AuditValue::String(value.clone())
455    }
456}
457
458impl From<Vec<u8>> for AuditValue {
459    fn from(value: Vec<u8>) -> Self {
460        AuditValue::Bytes(value)
461    }
462}
463
464impl From<&[u8]> for AuditValue {
465    fn from(value: &[u8]) -> Self {
466        AuditValue::Bytes(value.to_vec())
467    }
468}
469
470impl From<i64> for AuditValue {
471    fn from(value: i64) -> Self {
472        AuditValue::Number(value)
473    }
474}
475
476impl From<u64> for AuditValue {
477    fn from(value: u64) -> Self {
478        // Saturate at i64::MAX rather than wrapping; audit fields
479        // are observability data, not arithmetic inputs.
480        AuditValue::Number(if value > i64::MAX as u64 {
481            i64::MAX
482        } else {
483            value as i64
484        })
485    }
486}
487
488impl From<u32> for AuditValue {
489    fn from(value: u32) -> Self {
490        AuditValue::Number(value as i64)
491    }
492}
493
494impl From<usize> for AuditValue {
495    fn from(value: usize) -> Self {
496        AuditValue::Number(if value > i64::MAX as usize {
497            i64::MAX
498        } else {
499            value as i64
500        })
501    }
502}
503
504impl From<bool> for AuditValue {
505    fn from(value: bool) -> Self {
506        AuditValue::Bool(value)
507    }
508}
509
510impl<T: Into<AuditValue>> From<Option<T>> for AuditValue {
511    fn from(value: Option<T>) -> Self {
512        match value {
513            Some(v) => v.into(),
514            None => AuditValue::Null,
515        }
516    }
517}
518
519/// A single typed audit field (key + typed value). Construction is
520/// gated by [`AuditFieldEscaper::field`] so the typed value cannot
521/// be bypassed — there is no `pub` constructor for the field that
522/// accepts a free-form string value.
523#[derive(Debug, Clone)]
524pub struct AuditField {
525    name: &'static str,
526    value: AuditValue,
527}
528
529impl AuditField {
530    pub fn name(&self) -> &'static str {
531        self.name
532    }
533    pub fn value(&self) -> &AuditValue {
534        &self.value
535    }
536}
537
538/// Typed-field guard for audit emission (ADR 0010).
539///
540/// The guard owns the boundary's escape contract: caller-supplied
541/// bytes always round-trip through a typed `AuditValue`, never
542/// through `format!`, never through string concatenation. The
543/// canonical JSON encoder is `crate::serde_json::Value::escape_string`
544/// (RFC 8259 §7 compliant after F-01 hotfix #181) which the
545/// `AuditEvent::to_json_line` serializer uses for every string slot.
546///
547/// The other two encoder paths in the codebase
548/// (`utils::json::JsonValue::write_json` and
549/// `presentation::query_result::write_json_string`) are correct after #181 but
550/// not the canonical owner of the audit boundary. Both are marked
551/// `#[deprecated]` on the audit path and remain in place for
552/// non-audit call sites pending follow-up retirement.
553pub struct AuditFieldEscaper;
554
555impl AuditFieldEscaper {
556    /// Construct a typed audit field. The `name` is `'static` so
557    /// the schema key set is fixed at compile time — only `value`
558    /// can carry attacker-influenced bytes, and `value` is a typed
559    /// enum, not a string sink.
560    pub fn field(name: &'static str, value: impl Into<AuditValue>) -> AuditField {
561        AuditField {
562            name,
563            value: value.into(),
564        }
565    }
566}
567
568// ---------------------------------------------------------------------------
569// Identifier
570// ---------------------------------------------------------------------------
571
572/// Crockford-base32 ULID-like ID: 10-char timestamp prefix + 16-char
573/// random suffix = 26 chars total. Ordered lexicographically by time.
574fn new_event_id() -> String {
575    const ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
576    let now_ms = crate::utils::now_unix_millis();
577    let mut rand_bytes = [0u8; 10];
578    let _ = os_random::fill_bytes(&mut rand_bytes);
579
580    let mut out = String::with_capacity(26);
581    // 48-bit timestamp -> 10 base32 chars (50 bits, top 2 are zero).
582    for i in (0..10).rev() {
583        let shift = (i as u32) * 5;
584        let idx = ((now_ms >> shift) & 0x1f) as usize;
585        out.push(ALPHABET[idx] as char);
586    }
587    // 80 random bits -> 16 base32 chars.
588    let mut acc: u128 = 0;
589    for &b in &rand_bytes {
590        acc = (acc << 8) | (b as u128);
591    }
592    for i in (0..16).rev() {
593        let shift = (i as u32) * 5;
594        let idx = ((acc >> shift) & 0x1f) as usize;
595        out.push(ALPHABET[idx] as char);
596    }
597    out
598}
599
600// ---------------------------------------------------------------------------
601// Fsync mode
602// ---------------------------------------------------------------------------
603
604#[derive(Debug, Clone, Copy, PartialEq, Eq)]
605enum FsyncMode {
606    /// Buffered, fsync only on rotation + shutdown.
607    Off,
608    /// fsync after every event. Safe for HIPAA strong-durability.
609    Every,
610    /// fsync every N ms (default 250) — compliance-appropriate
611    /// without tanking write throughput.
612    Periodic,
613}
614
615impl FsyncMode {
616    fn from_env() -> Self {
617        match std::env::var("RED_AUDIT_FSYNC")
618            .unwrap_or_default()
619            .to_ascii_lowercase()
620            .as_str()
621        {
622            "every" | "strong" | "on" => Self::Every,
623            "off" | "none" => Self::Off,
624            _ => Self::Periodic,
625        }
626    }
627}
628
629// ---------------------------------------------------------------------------
630// Channel message
631// ---------------------------------------------------------------------------
632
633#[allow(clippy::large_enum_variant)]
634enum WriterMsg {
635    Event(AuditEvent),
636    Flush(mpsc::Sender<()>),
637    Shutdown,
638}
639
640// ---------------------------------------------------------------------------
641// AuditLogger
642// ---------------------------------------------------------------------------
643
644#[derive(Debug)]
645pub struct AuditLogger {
646    path: PathBuf,
647    tx: Mutex<Option<mpsc::SyncSender<WriterMsg>>>,
648    /// Direct-write fallback mutex. Used when the writer thread isn't
649    /// running (in-memory tests) or the channel is full and the emit
650    /// site picks the sync path so the event can't be dropped.
651    fallback_lock: Mutex<()>,
652    /// Snapshot of the most recent line's sha256 for tamper-evidence.
653    /// Shared between the writer thread and the direct-write fallback
654    /// so the chain stays consistent across both paths.
655    last_hash: Arc<Mutex<Option<String>>>,
656    max_bytes: u64,
657    fsync_mode: FsyncMode,
658    stream_url: Option<String>,
659    /// Background-write flag; tests use `wait_idle()` to synchronise.
660    writer_alive: Arc<AtomicBool>,
661    pending: Arc<AtomicU64>,
662    handle: Mutex<Option<JoinHandle<()>>>,
663}
664
665impl AuditLogger {
666    /// Place the audit log next to the primary `.rdb` file so backup
667    /// + restore flows can ship it together.
668    pub fn for_data_path(data_path: &Path) -> Self {
669        Self::with_path(reddb_file::layout::legacy_audit_log_path(data_path))
670    }
671
672    /// Resolve a [`crate::storage::layout::LogDestination`] into a concrete
673    /// audit-log sink. `File(p)` writes to that exact path (Performance /
674    /// Max tier route here). `Stderr` and `Syslog` fall back to the legacy
675    /// `.audit.log` next to `fallback_data_path` — emit sites still get
676    /// durable storage while the dedicated stderr / syslog sinks are not
677    /// yet wired (ADR 0018 marks Syslog as a stub-and-warn lane).
678    pub fn for_destination(
679        dest: &crate::storage::layout::LogDestination,
680        fallback_data_path: &Path,
681    ) -> Self {
682        use crate::storage::layout::LogDestination;
683        match dest {
684            LogDestination::File(path) => Self::with_path(path.clone()),
685            LogDestination::Stderr => Self::for_data_path(fallback_data_path),
686            LogDestination::Syslog => {
687                tracing::warn!(
688                    target: "reddb::audit",
689                    "audit LogDestination::Syslog requested; sink not implemented, falling back to file next to data path"
690                );
691                Self::for_data_path(fallback_data_path)
692            }
693        }
694    }
695
696    /// Direct constructor primarily for tests that want a custom path.
697    pub fn with_path(path: PathBuf) -> Self {
698        let max_bytes = std::env::var("RED_AUDIT_MAX_BYTES")
699            .ok()
700            .and_then(|v| v.parse::<u64>().ok())
701            .unwrap_or(64 * 1024 * 1024);
702        let fsync_mode = FsyncMode::from_env();
703        let stream_url = std::env::var("RED_AUDIT_STREAM_URL")
704            .ok()
705            .filter(|s| !s.is_empty());
706        Self::with_settings(path, max_bytes, fsync_mode, stream_url)
707    }
708
709    /// Test/integration constructor with explicit settings — bypasses
710    /// env-var resolution so parallel tests don't race on `set_var`.
711    pub fn with_max_bytes(path: PathBuf, max_bytes: u64) -> Self {
712        Self::with_settings(path, max_bytes, FsyncMode::Periodic, None)
713    }
714
715    fn with_settings(
716        path: PathBuf,
717        max_bytes: u64,
718        fsync_mode: FsyncMode,
719        stream_url: Option<String>,
720    ) -> Self {
721        let writer_alive = Arc::new(AtomicBool::new(false));
722        let pending = Arc::new(AtomicU64::new(0));
723        // Seed the chain from the existing tail so a restart doesn't
724        // break tamper evidence.
725        let mut seed: Option<String> = None;
726        if let Ok(body) = std::fs::read_to_string(&path) {
727            if let Some(line) = body.lines().last() {
728                seed = Some(crate::utils::to_hex(&sha256::sha256(line.as_bytes())));
729            }
730        }
731        let last_hash = Arc::new(Mutex::new(seed));
732        let logger = Self {
733            path,
734            tx: Mutex::new(None),
735            fallback_lock: Mutex::new(()),
736            last_hash,
737            max_bytes,
738            fsync_mode,
739            stream_url,
740            writer_alive: Arc::clone(&writer_alive),
741            pending: Arc::clone(&pending),
742            handle: Mutex::new(None),
743        };
744        logger.start_writer_thread();
745        logger
746    }
747
748    pub fn path(&self) -> &Path {
749        &self.path
750    }
751
752    /// Active rotation threshold (bytes).
753    pub fn max_bytes(&self) -> u64 {
754        self.max_bytes
755    }
756
757    fn start_writer_thread(&self) {
758        let (tx, rx) = mpsc::sync_channel::<WriterMsg>(4096);
759        *self.tx.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx);
760        let path = self.path.clone();
761        let max_bytes = self.max_bytes;
762        let fsync_mode = self.fsync_mode;
763        let stream_url = self.stream_url.clone();
764        let writer_alive = Arc::clone(&self.writer_alive);
765        let pending = Arc::clone(&self.pending);
766        let last_hash = Arc::clone(&self.last_hash);
767        writer_alive.store(true, Ordering::SeqCst);
768        let handle = thread::Builder::new()
769            .name("reddb-audit-writer".to_string())
770            .spawn(move || {
771                writer_loop(
772                    rx,
773                    path,
774                    max_bytes,
775                    fsync_mode,
776                    stream_url,
777                    writer_alive,
778                    pending,
779                    last_hash,
780                );
781            })
782            .expect("spawn audit writer thread");
783        *self.handle.lock().unwrap_or_else(|e| e.into_inner()) = Some(handle);
784    }
785
786    /// Latest tip-hash of the chain (for diagnostics + tests).
787    pub fn current_hash(&self) -> Option<String> {
788        self.last_hash
789            .lock()
790            .unwrap_or_else(|e| e.into_inner())
791            .clone()
792    }
793
794    /// Block until the writer thread has drained every pending event.
795    /// Tests use this to make assertions deterministic.
796    pub fn wait_idle(&self, timeout: Duration) -> bool {
797        let deadline = Instant::now() + timeout;
798        let tx_guard = self.tx.lock().unwrap_or_else(|e| e.into_inner());
799        if let Some(tx) = tx_guard.as_ref() {
800            let (back_tx, back_rx) = mpsc::channel();
801            if tx.send(WriterMsg::Flush(back_tx)).is_err() {
802                return false;
803            }
804            drop(tx_guard);
805            let remaining = deadline.saturating_duration_since(Instant::now());
806            return back_rx.recv_timeout(remaining).is_ok();
807        }
808        false
809    }
810
811    /// Back-compat record signature. Used by every existing emit site.
812    /// Wraps into the new schema and forwards to `record_event`.
813    pub fn record(
814        &self,
815        action: &str,
816        principal: &str,
817        target: &str,
818        result: &str,
819        details: JsonValue,
820    ) {
821        let event = AuditEvent::from_legacy(action, principal, target, result, details);
822        self.record_event(event);
823    }
824
825    /// Primary new entry point. Non-blocking when the channel has
826    /// capacity; falls back to a sync write when full or the writer
827    /// thread has shut down.
828    pub fn record_event(&self, event: AuditEvent) {
829        let tx_guard = self.tx.lock().unwrap_or_else(|e| e.into_inner());
830        let recovered_event: AuditEvent;
831        if let Some(tx) = tx_guard.as_ref() {
832            self.pending.fetch_add(1, Ordering::SeqCst);
833            match tx.try_send(WriterMsg::Event(event)) {
834                Ok(()) => return,
835                Err(mpsc::TrySendError::Full(msg)) => {
836                    self.pending.fetch_sub(1, Ordering::SeqCst);
837                    tracing::warn!(
838                        target: "reddb::audit",
839                        "audit channel saturated; falling back to sync write"
840                    );
841                    recovered_event = match msg {
842                        WriterMsg::Event(ev) => ev,
843                        _ => return,
844                    };
845                }
846                Err(mpsc::TrySendError::Disconnected(msg)) => {
847                    self.pending.fetch_sub(1, Ordering::SeqCst);
848                    recovered_event = match msg {
849                        WriterMsg::Event(ev) => ev,
850                        _ => return,
851                    };
852                }
853            }
854        } else {
855            recovered_event = event;
856        }
857        drop(tx_guard);
858        self.write_direct(recovered_event);
859    }
860
861    /// Fallback path: a direct, synchronous append. Used when the
862    /// background channel is full or the writer thread isn't running.
863    fn write_direct(&self, event: AuditEvent) {
864        let _g = self.fallback_lock.lock().unwrap_or_else(|e| e.into_inner());
865        let prev = self
866            .last_hash
867            .lock()
868            .unwrap_or_else(|e| e.into_inner())
869            .clone();
870        let line = event.to_json_line(prev.as_deref());
871        if let Err(err) = append_line_with_rotation(&self.path, &line, self.max_bytes) {
872            tracing::warn!(
873                target: "reddb::audit",
874                error = %err,
875                path = %self.path.display(),
876                "direct audit append failed"
877            );
878            return;
879        }
880        let new_hash = crate::utils::to_hex(&sha256::sha256(line.as_bytes()));
881        if let Ok(mut g) = self.last_hash.lock() {
882            *g = Some(new_hash);
883        }
884        if let Some(url) = &self.stream_url {
885            stream_post(url, &line);
886        }
887        tracing::info!(target: "reddb::audit", "{line}");
888    }
889}
890
891impl Drop for AuditLogger {
892    fn drop(&mut self) {
893        let mut tx_guard = self.tx.lock().unwrap_or_else(|e| e.into_inner());
894        if let Some(tx) = tx_guard.take() {
895            let _ = tx.send(WriterMsg::Shutdown);
896        }
897        drop(tx_guard);
898        if let Some(handle) = self.handle.lock().unwrap_or_else(|e| e.into_inner()).take() {
899            let _ = handle.join();
900        }
901    }
902}
903
904// ---------------------------------------------------------------------------
905// Writer thread
906// ---------------------------------------------------------------------------
907
908#[allow(clippy::too_many_arguments)]
909fn writer_loop(
910    rx: mpsc::Receiver<WriterMsg>,
911    path: PathBuf,
912    max_bytes: u64,
913    fsync_mode: FsyncMode,
914    stream_url: Option<String>,
915    writer_alive: Arc<AtomicBool>,
916    pending: Arc<AtomicU64>,
917    last_hash: Arc<Mutex<Option<String>>>,
918) {
919    if let Some(parent) = path.parent() {
920        if !parent.as_os_str().is_empty() {
921            let _ = std::fs::create_dir_all(parent);
922        }
923    }
924
925    let mut writer = match open_active(&path) {
926        Ok(w) => w,
927        Err(err) => {
928            tracing::error!(target: "reddb::audit", error = %err, "audit writer init failed");
929            writer_alive.store(false, Ordering::SeqCst);
930            return;
931        }
932    };
933    // Track size in-memory; BufWriter hides the on-disk size until
934    // flush, and we rotate on bytes-actually-written so a slow
935    // flush cadence doesn't run away.
936    let mut bytes_written: u64 = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
937
938    let periodic_interval = Duration::from_millis(250);
939    let mut last_flush = Instant::now();
940    let mut buffered_since_fsync: u64 = 0;
941
942    loop {
943        // Wake up periodically so the periodic-fsync mode can run even
944        // when no events arrive (compliance-driven).
945        let recv_timeout = match fsync_mode {
946            FsyncMode::Periodic => periodic_interval,
947            FsyncMode::Every | FsyncMode::Off => Duration::from_secs(1),
948        };
949        match rx.recv_timeout(recv_timeout) {
950            Ok(WriterMsg::Event(event)) => {
951                let prev = last_hash.lock().unwrap_or_else(|e| e.into_inner()).clone();
952                let line = event.to_json_line(prev.as_deref());
953
954                let line_bytes = line.len() as u64 + 1; // newline
955                if let Err(err) = write_line(&mut writer, &line) {
956                    tracing::warn!(
957                        target: "reddb::audit",
958                        error = %err,
959                        "audit write failed; reopening"
960                    );
961                    if let Ok(w2) = open_active(&path) {
962                        writer = w2;
963                        let _ = write_line(&mut writer, &line);
964                    }
965                }
966                bytes_written = bytes_written.saturating_add(line_bytes);
967                let new_hash = crate::utils::to_hex(&sha256::sha256(line.as_bytes()));
968                if let Ok(mut g) = last_hash.lock() {
969                    *g = Some(new_hash);
970                }
971                if let Some(url) = &stream_url {
972                    stream_post(url, &line);
973                }
974                tracing::info!(target: "reddb::audit", "{line}");
975                pending.fetch_sub(1, Ordering::SeqCst);
976                buffered_since_fsync += 1;
977
978                match fsync_mode {
979                    FsyncMode::Every => {
980                        let _ = writer.flush();
981                        let _ = writer.get_ref().sync_data();
982                        buffered_since_fsync = 0;
983                    }
984                    FsyncMode::Periodic => {
985                        if last_flush.elapsed() >= periodic_interval {
986                            let _ = writer.flush();
987                            let _ = writer.get_ref().sync_data();
988                            last_flush = Instant::now();
989                            buffered_since_fsync = 0;
990                        }
991                    }
992                    FsyncMode::Off => {}
993                }
994
995                // Rotation check based on in-memory accounting; BufWriter
996                // metadata can lag.
997                if bytes_written >= max_bytes {
998                    let _ = writer.flush();
999                    let _ = writer.get_ref().sync_data();
1000                    if let Err(err) = rotate(&path) {
1001                        tracing::warn!(
1002                            target: "reddb::audit",
1003                            error = %err,
1004                            "audit rotation failed; continuing in-place"
1005                        );
1006                    }
1007                    match open_active(&path) {
1008                        Ok(w2) => writer = w2,
1009                        Err(err) => {
1010                            tracing::error!(
1011                                target: "reddb::audit",
1012                                error = %err,
1013                                "audit reopen failed after rotate"
1014                            );
1015                            break;
1016                        }
1017                    }
1018                    last_flush = Instant::now();
1019                    buffered_since_fsync = 0;
1020                    bytes_written = 0;
1021                }
1022            }
1023            Ok(WriterMsg::Flush(ack)) => {
1024                let _ = writer.flush();
1025                let _ = writer.get_ref().sync_data();
1026                last_flush = Instant::now();
1027                buffered_since_fsync = 0;
1028                // Acks are sent only after pending == 0; in this design
1029                // every event sent before Flush has already been
1030                // processed (channel is FIFO), so we can ack now.
1031                let _ = ack.send(());
1032            }
1033            Ok(WriterMsg::Shutdown) => {
1034                let _ = writer.flush();
1035                let _ = writer.get_ref().sync_data();
1036                break;
1037            }
1038            Err(mpsc::RecvTimeoutError::Timeout) => {
1039                if buffered_since_fsync > 0 {
1040                    let _ = writer.flush();
1041                    let _ = writer.get_ref().sync_data();
1042                    last_flush = Instant::now();
1043                    buffered_since_fsync = 0;
1044                }
1045            }
1046            Err(mpsc::RecvTimeoutError::Disconnected) => {
1047                let _ = writer.flush();
1048                let _ = writer.get_ref().sync_data();
1049                break;
1050            }
1051        }
1052    }
1053
1054    writer_alive.store(false, Ordering::SeqCst);
1055}
1056
1057fn open_active(path: &Path) -> std::io::Result<BufWriter<std::fs::File>> {
1058    if let Some(parent) = path.parent() {
1059        if !parent.as_os_str().is_empty() {
1060            std::fs::create_dir_all(parent)?;
1061        }
1062    }
1063    let f = std::fs::OpenOptions::new()
1064        .create(true)
1065        .append(true)
1066        .open(path)?;
1067    Ok(BufWriter::new(f))
1068}
1069
1070fn write_line(writer: &mut BufWriter<std::fs::File>, line: &str) -> std::io::Result<()> {
1071    writer.write_all(line.as_bytes())?;
1072    writer.write_all(b"\n")?;
1073    Ok(())
1074}
1075
1076fn append_line_with_rotation(path: &Path, line: &str, max_bytes: u64) -> std::io::Result<()> {
1077    if let Some(parent) = path.parent() {
1078        if !parent.as_os_str().is_empty() {
1079            std::fs::create_dir_all(parent)?;
1080        }
1081    }
1082    let mut file = std::fs::OpenOptions::new()
1083        .create(true)
1084        .append(true)
1085        .open(path)?;
1086    file.write_all(line.as_bytes())?;
1087    file.write_all(b"\n")?;
1088    file.sync_data()?;
1089    drop(file);
1090    if let Ok(meta) = std::fs::metadata(path) {
1091        if meta.len() >= max_bytes {
1092            let _ = rotate(path);
1093        }
1094    }
1095    Ok(())
1096}
1097
1098/// Rename the active file to `<path>.<ms>.zst` and zstd-compress it
1099/// in-place. The compressed file replaces the renamed plaintext copy
1100/// so the on-disk artefact is `.audit.log.<ms>.zst`.
1101///
1102/// Rotation timestamp uses unix nanos so back-to-back rotations
1103/// under load (or in a tight test loop) don't collide on the same
1104/// filename.
1105fn rotate(active: &Path) -> std::io::Result<()> {
1106    let ts = crate::utils::now_unix_nanos();
1107    let rotation = reddb_file::rotate_audit_log(active, ts)?;
1108    if let Some(err) = rotation.compression_error {
1109        tracing::warn!(
1110            target: "reddb::audit",
1111            error = %err,
1112            "audit rotation: zstd compress failed; leaving plaintext"
1113        );
1114    }
1115    Ok(())
1116}
1117
1118// ---------------------------------------------------------------------------
1119// SIEM streaming (fire-and-forget)
1120// ---------------------------------------------------------------------------
1121
1122fn stream_post(url: &str, line: &str) {
1123    let url = url.to_string();
1124    let line = line.to_string();
1125    // Spawn a one-shot thread; ureq builds a fresh agent per call.
1126    // Best-effort: one attempt, no retry — SIEM ingestion lag is
1127    // not the RedDB hot path's problem.
1128    let _ = thread::Builder::new()
1129        .name("reddb-audit-siem".to_string())
1130        .spawn(move || {
1131            let agent: ureq::Agent = ureq::Agent::config_builder()
1132                .timeout_connect(Some(Duration::from_secs(2)))
1133                .timeout_send_request(Some(Duration::from_secs(3)))
1134                .timeout_recv_response(Some(Duration::from_secs(3)))
1135                .http_status_as_error(false)
1136                .build()
1137                .into();
1138            let _ = agent
1139                .post(&url)
1140                .header("content-type", "application/x-ndjson")
1141                .send(line.as_bytes());
1142        });
1143}
1144
1145// ---------------------------------------------------------------------------
1146// ISO-8601 helper (kept from the previous implementation)
1147// ---------------------------------------------------------------------------
1148
1149fn format_iso8601(ms_since_epoch: u64) -> String {
1150    let secs = ms_since_epoch / 1000;
1151    let ms = ms_since_epoch % 1000;
1152    let days = secs / 86_400;
1153    let rem = secs % 86_400;
1154    let (y, mo, d) = civil_from_days(days as i64);
1155    let h = rem / 3600;
1156    let mi = (rem % 3600) / 60;
1157    let s = rem % 60;
1158    format!(
1159        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
1160        y, mo, d, h, mi, s, ms
1161    )
1162}
1163
1164fn civil_from_days(z: i64) -> (i64, u32, u32) {
1165    let z = z + 719_468;
1166    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1167    let doe = (z - era * 146_097) as u64;
1168    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1169    let y = (yoe as i64) + era * 400;
1170    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1171    let mp = (5 * doy + 2) / 153;
1172    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
1173    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
1174    (if m <= 2 { y + 1 } else { y }, m, d)
1175}
1176
1177// ---------------------------------------------------------------------------
1178// Tests
1179// ---------------------------------------------------------------------------
1180
1181#[cfg(test)]
1182mod tests {
1183    use super::*;
1184
1185    fn temp_data_path(tag: &str) -> PathBuf {
1186        let mut p = std::env::temp_dir();
1187        p.push(format!(
1188            "reddb-audit-{}-{}-{}",
1189            tag,
1190            std::process::id(),
1191            crate::utils::now_unix_nanos()
1192        ));
1193        std::fs::create_dir_all(&p).unwrap();
1194        p.push("data.rdb");
1195        p
1196    }
1197
1198    fn drain(logger: &AuditLogger) {
1199        assert!(logger.wait_idle(Duration::from_secs(2)));
1200    }
1201
1202    #[test]
1203    fn record_writes_one_line_per_call() {
1204        let data = temp_data_path("one-line");
1205        let logger = AuditLogger::for_data_path(&data);
1206        logger.record(
1207            "admin/readonly",
1208            "operator",
1209            "instance",
1210            "ok",
1211            JsonValue::Null,
1212        );
1213        drain(&logger);
1214        let body = std::fs::read_to_string(logger.path()).unwrap();
1215        let lines: Vec<&str> = body.lines().collect();
1216        assert_eq!(lines.len(), 1);
1217        assert!(lines[0].contains("\"action\":\"admin/readonly\""));
1218        assert!(lines[0].contains("\"outcome\":\"success\""));
1219    }
1220
1221    #[test]
1222    fn record_appends_across_calls() {
1223        let data = temp_data_path("append");
1224        let logger = AuditLogger::for_data_path(&data);
1225        logger.record("admin/drain", "op", "instance", "ok", JsonValue::Null);
1226        logger.record("admin/shutdown", "op", "instance", "ok", JsonValue::Null);
1227        drain(&logger);
1228        let lines = std::fs::read_to_string(logger.path()).unwrap();
1229        assert_eq!(lines.lines().count(), 2);
1230    }
1231
1232    #[test]
1233    fn record_event_emits_full_schema() {
1234        let data = temp_data_path("schema");
1235        let logger = AuditLogger::for_data_path(&data);
1236        let mut detail = Map::new();
1237        detail.insert("ms".to_string(), JsonValue::Number(412.0));
1238        let ev = AuditEvent::builder("admin/shutdown")
1239            .principal("alice@acme")
1240            .source(AuditAuthSource::Session)
1241            .tenant("acme")
1242            .resource("instance")
1243            .outcome(Outcome::Success)
1244            .detail(JsonValue::Object(detail))
1245            .remote_addr("203.0.113.5")
1246            .correlation_id("req-42")
1247            .build();
1248        logger.record_event(ev);
1249        drain(&logger);
1250        let body = std::fs::read_to_string(logger.path()).unwrap();
1251        assert!(body.contains("\"action\":\"admin/shutdown\""));
1252        assert!(body.contains("\"principal\":\"alice@acme\""));
1253        assert!(body.contains("\"tenant\":\"acme\""));
1254        assert!(body.contains("\"source\":\"session\""));
1255        assert!(body.contains("\"correlation_id\":\"req-42\""));
1256        assert!(body.contains("\"remote_addr\":\"203.0.113.5\""));
1257        assert!(body.contains("\"event_id\":\""));
1258        assert!(body.contains("\"prev_hash\":") || body.lines().count() == 1);
1259    }
1260
1261    #[test]
1262    fn hash_chain_links_every_event() {
1263        let data = temp_data_path("chain");
1264        let logger = AuditLogger::for_data_path(&data);
1265        for i in 0..5 {
1266            logger.record_event(
1267                AuditEvent::builder(format!("test/event/{i}"))
1268                    .principal("tester")
1269                    .build(),
1270            );
1271        }
1272        drain(&logger);
1273        let body = std::fs::read_to_string(logger.path()).unwrap();
1274        let lines: Vec<&str> = body.lines().collect();
1275        assert_eq!(lines.len(), 5);
1276        let mut prev: Option<String> = None;
1277        for (idx, line) in lines.iter().enumerate() {
1278            let parsed: JsonValue = crate::json::from_str(line).unwrap();
1279            let stored_prev = parsed
1280                .get("prev_hash")
1281                .and_then(|v| v.as_str())
1282                .map(|s| s.to_string());
1283            assert_eq!(stored_prev, prev, "line {idx} prev_hash mismatch");
1284            prev = Some(crate::utils::to_hex(&sha256::sha256(line.as_bytes())));
1285        }
1286    }
1287
1288    #[test]
1289    fn legacy_record_back_compat_maps_outcomes() {
1290        let data = temp_data_path("legacy");
1291        let logger = AuditLogger::for_data_path(&data);
1292        logger.record(
1293            "admin/restore",
1294            "operator",
1295            "instance",
1296            "err: disk full",
1297            JsonValue::Null,
1298        );
1299        drain(&logger);
1300        let body = std::fs::read_to_string(logger.path()).unwrap();
1301        assert!(body.contains("\"outcome\":\"error\""));
1302        assert!(body.contains("\"result_text\":\"err: disk full\""));
1303    }
1304
1305    #[test]
1306    fn iso8601_formats_known_epoch() {
1307        assert_eq!(
1308            format_iso8601(1_709_210_096_789),
1309            "2024-02-29T12:34:56.789Z"
1310        );
1311    }
1312
1313    #[test]
1314    fn rotation_at_threshold() {
1315        let data = temp_data_path("rotate");
1316        let parent = data.parent().unwrap().to_path_buf();
1317        let logger = AuditLogger::with_max_bytes(parent.join(".audit.log"), 1024);
1318        for i in 0..30 {
1319            logger.record_event(
1320                AuditEvent::builder(format!("test/rotate/{i}"))
1321                    .principal("rotator")
1322                    .detail(JsonValue::String(
1323                        "lorem ipsum dolor sit amet consectetur padding padding padding"
1324                            .to_string(),
1325                    ))
1326                    .build(),
1327            );
1328        }
1329        drain(&logger);
1330        let parent = logger.path().parent().unwrap();
1331        let rotated: Vec<_> = std::fs::read_dir(parent)
1332            .unwrap()
1333            .filter_map(|e| e.ok())
1334            .filter(|e| {
1335                e.file_name()
1336                    .to_str()
1337                    .map(|n| n.starts_with(".audit.log.") && n.ends_with(".zst"))
1338                    .unwrap_or(false)
1339            })
1340            .collect();
1341        assert!(
1342            !rotated.is_empty(),
1343            "expected at least one rotated .zst file"
1344        );
1345    }
1346
1347    #[test]
1348    fn parse_line_round_trips() {
1349        let ev = AuditEvent::builder("auth/login.ok")
1350            .principal("alice")
1351            .source(AuditAuthSource::Password)
1352            .tenant("acme")
1353            .outcome(Outcome::Success)
1354            .build();
1355        let line = ev.to_json_line(None);
1356        let parsed = AuditEvent::parse_line(&line).expect("round-trip parse");
1357        assert_eq!(parsed.action, "auth/login.ok");
1358        assert_eq!(parsed.principal.as_deref(), Some("alice"));
1359        assert_eq!(parsed.tenant.as_deref(), Some("acme"));
1360        assert_eq!(parsed.outcome, Outcome::Success);
1361        assert_eq!(parsed.source, AuditAuthSource::Password);
1362    }
1363
1364    #[test]
1365    fn event_id_is_lexicographically_sortable_by_time() {
1366        let a = new_event_id();
1367        std::thread::sleep(Duration::from_millis(2));
1368        let b = new_event_id();
1369        assert!(a < b, "event_id ordering broken: {a} >= {b}");
1370    }
1371
1372    // -------------------------------------------------------------------
1373    // AuditFieldEscaper / AuditValue tests (issue #177)
1374    // -------------------------------------------------------------------
1375
1376    #[test]
1377    fn audit_field_escaper_typed_string() {
1378        let f = AuditFieldEscaper::field("collection", "users");
1379        assert_eq!(f.name(), "collection");
1380        match f.value() {
1381            AuditValue::String(s) => assert_eq!(s, "users"),
1382            other => panic!("expected String, got {:?}", other),
1383        }
1384    }
1385
1386    #[test]
1387    fn audit_field_escaper_bytes_emit_base64() {
1388        let bytes = vec![0xDEu8, 0xAD, 0xBE, 0xEF];
1389        let f = AuditFieldEscaper::field("blob", bytes);
1390        let ev = AuditEvent::builder("test/bytes").field(f).build();
1391        let line = ev.to_json_line(None);
1392        // base64 of 0xDEADBEEF is "3q2+7w=="
1393        assert!(
1394            line.contains("\"blob\":\"3q2+7w==\""),
1395            "expected base64 emission: {line}"
1396        );
1397    }
1398
1399    #[test]
1400    fn audit_field_escaper_number_bool_null() {
1401        let ev = AuditEvent::builder("test/types")
1402            .field(AuditFieldEscaper::field("count", 42i64))
1403            .field(AuditFieldEscaper::field("ok", true))
1404            .field(AuditFieldEscaper::field("missing", AuditValue::Null))
1405            .build();
1406        let line = ev.to_json_line(None);
1407        assert!(line.contains("\"count\":42"));
1408        assert!(line.contains("\"ok\":true"));
1409        assert!(line.contains("\"missing\":null"));
1410    }
1411
1412    #[test]
1413    fn audit_field_escaper_adversarial_corpus_preserves_structure() {
1414        // The full F-01 / F-02 adversarial corpus: CRLF, NUL,
1415        // quote, semicolon, JSON-in-JSON, control bytes 0x00..0x20.
1416        // Every payload must encode to a single JSONL row that
1417        // round-trips through the in-house parser back to the
1418        // original bytes.
1419        let cases: &[(&str, &str)] = &[
1420            ("crlf", "line1\r\nline2"),
1421            ("nul", "before\0after"),
1422            ("quote", "she said \"hi\""),
1423            ("semicolon", "a;b;c"),
1424            ("json_in_json", r#"{"injected":"yes"}"#),
1425            ("low_ctrl", "\x01\x02\x03\x07\x1f"),
1426            ("backslash", "C:\\path\\file"),
1427            ("mixed", "name=\"x\"\n\\path\t\x01end"),
1428            ("empty", ""),
1429            // Note: legal Unicode payloads round-trip through the
1430            // RFC 8259 encoder fine; the in-house parser
1431            // (`utils::json`) used by `crate::json::from_str` has a
1432            // pre-existing byte-oriented bug for multi-byte UTF-8
1433            // sequences, which is orthogonal to the F-01 / F-02
1434            // boundary-smuggling threat model. ASCII-only here.
1435        ];
1436        let mut survivors = 0usize;
1437        for (label, payload) in cases {
1438            let f = AuditFieldEscaper::field("user_input", *payload);
1439            let ev = AuditEvent::builder(format!("test/adv/{label}"))
1440                .principal("attacker")
1441                .field(f)
1442                .build();
1443            let line = ev.to_json_line(None);
1444            // 1. exactly one row, no embedded newline.
1445            assert!(
1446                !line.contains('\n'),
1447                "{label}: embedded newline in JSONL row: {line:?}"
1448            );
1449            // 2. parse back via the canonical decoder and read user_input.
1450            let parsed: JsonValue = crate::json::from_str(&line)
1451                .unwrap_or_else(|err| panic!("{label}: line did not parse: {err} :: {line:?}"));
1452            let detail = parsed.get("detail").expect("detail present");
1453            let recovered = detail.get("user_input").and_then(|v| v.as_str()).unwrap();
1454            assert_eq!(
1455                recovered, *payload,
1456                "{label}: round-trip mismatch: {recovered:?} != {payload:?}"
1457            );
1458            survivors += 1;
1459        }
1460        assert_eq!(
1461            survivors,
1462            cases.len(),
1463            "adversarial corpus survival rate: {survivors}/{}",
1464            cases.len()
1465        );
1466    }
1467
1468    #[test]
1469    fn audit_emission_emits_one_line_per_call_through_guard() {
1470        let data = temp_data_path("guard-emission");
1471        let logger = AuditLogger::for_data_path(&data);
1472        // Smuggle attempt: NUL + CRLF + JSON-injection in collection name.
1473        let attacker = "users\";DROP\r\n{\"x\":1}\0";
1474        logger.record_event(
1475            AuditEvent::builder("admin/scan")
1476                .principal("evil")
1477                .field(AuditFieldEscaper::field("collection", attacker))
1478                .build(),
1479        );
1480        drain(&logger);
1481        let body = std::fs::read_to_string(logger.path()).unwrap();
1482        let lines: Vec<&str> = body.lines().collect();
1483        assert_eq!(lines.len(), 1, "guard must emit exactly one JSONL row");
1484        // The smuggled "{...}" cannot have escaped the JSON string.
1485        let parsed: JsonValue = crate::json::from_str(lines[0]).unwrap();
1486        let recovered = parsed
1487            .get("detail")
1488            .and_then(|d| d.get("collection"))
1489            .and_then(|v| v.as_str())
1490            .unwrap();
1491        assert_eq!(recovered, attacker);
1492    }
1493
1494    #[test]
1495    fn audit_field_escaper_no_format_macro_in_value_path() {
1496        // Compile-time guarantee: AuditField construction goes
1497        // through AuditValue, not through `Display`. This test is
1498        // a documentation anchor — if a contributor adds a `Display`
1499        // impl that bypasses the typed value, the property tests
1500        // below still catch the smuggling, but this test makes the
1501        // intent explicit.
1502        let _ = AuditFieldEscaper::field("name", "value"); // compiles
1503                                                           // No `format!` path, no `to_string()` of attacker bytes:
1504                                                           // the only entry is `Into<AuditValue>`.
1505    }
1506
1507    #[test]
1508    fn audit_field_escaper_chains_via_builder_fields() {
1509        let ev = AuditEvent::builder("test/multi")
1510            .fields([
1511                AuditFieldEscaper::field("a", "x"),
1512                AuditFieldEscaper::field("b", 7i64),
1513                AuditFieldEscaper::field("c", true),
1514            ])
1515            .build();
1516        let line = ev.to_json_line(None);
1517        let parsed: JsonValue = crate::json::from_str(&line).unwrap();
1518        let d = parsed.get("detail").unwrap();
1519        assert_eq!(d.get("a").and_then(|v| v.as_str()), Some("x"));
1520        assert_eq!(d.get("b").and_then(|v| v.as_i64()), Some(7));
1521        assert_eq!(d.get("c").and_then(|v| v.as_bool()), Some(true));
1522    }
1523
1524    proptest::proptest! {
1525        /// Random user-supplied strings must always round-trip through
1526        /// the typed-field guard exactly. No silent drops, no smuggling.
1527        ///
1528        /// ASCII-only here: the encoder is RFC 8259 §7 compliant for
1529        /// arbitrary Unicode, but the in-house decoder shared by
1530        /// `crate::json::from_str` (`utils::json::parse_string`) is
1531        /// byte-oriented for non-escape characters and does not
1532        /// recompose multi-byte UTF-8 sequences. The boundary-smuggling
1533        /// threat model is about control bytes (0x00..0x20) and
1534        /// structural bytes (`"`, `\`, `\r`, `\n`), not legal Unicode,
1535        /// so this restriction does not narrow the security claim.
1536        #[test]
1537        fn prop_audit_field_round_trips_arbitrary_strings(
1538            payload in proptest::string::string_regex("[\\x00-\\x7f]{0,128}").unwrap()
1539        ) {
1540            let f = AuditFieldEscaper::field("p", payload.as_str());
1541            let ev = AuditEvent::builder("prop/test").field(f).build();
1542            let line = ev.to_json_line(None);
1543            // Single-line invariant.
1544            proptest::prop_assert!(!line.contains('\n'));
1545            let parsed: JsonValue = crate::json::from_str(&line)
1546                .expect("emission must always parse");
1547            let recovered = parsed
1548                .get("detail")
1549                .and_then(|d| d.get("p"))
1550                .and_then(|v| v.as_str())
1551                .unwrap();
1552            proptest::prop_assert_eq!(recovered, payload.as_str());
1553        }
1554
1555        /// Random byte sequences via base64 round-trip — non-UTF-8
1556        /// payloads must never enter the JSON string channel raw.
1557        #[test]
1558        fn prop_audit_field_bytes_base64_round_trip(
1559            bytes in proptest::collection::vec(proptest::bits::u8::ANY, 0..64)
1560        ) {
1561            let f = AuditFieldEscaper::field("b", bytes.clone());
1562            let ev = AuditEvent::builder("prop/bytes").field(f).build();
1563            let line = ev.to_json_line(None);
1564            proptest::prop_assert!(!line.contains('\n'));
1565            let parsed: JsonValue = crate::json::from_str(&line).unwrap();
1566            let recovered_b64 = parsed
1567                .get("detail")
1568                .and_then(|d| d.get("b"))
1569                .and_then(|v| v.as_str())
1570                .unwrap()
1571                .to_string();
1572            proptest::prop_assert_eq!(recovered_b64, base64_encode(&bytes));
1573        }
1574    }
1575}