agentguard_core/decision/
log.rs

1//! Append-only JSONL decision log writer with optional HMAC hash chaining.
2//!
3//! Crash safety: when chained, each record is written to a sibling temp file,
4//! fsynced, and atomically renamed into place before the in-memory chain head
5//! is advanced. On restart the head is restored from the last record on disk,
6//! so a crash between compute and write loses the in-flight record but never
7//! leaves the chain head ahead of the file.
8
9use crate::authorize::Decision;
10use crate::decision::canonical::canonical_json;
11use crate::decision::chain::{ChainId, HashChain, HASH_LEN};
12use crate::decision::record::DecisionRecord;
13use crate::error::{Error, Result};
14use std::fs::{File, OpenOptions};
15use std::io::{BufRead, BufReader, BufWriter, Write};
16use std::path::{Path, PathBuf};
17use std::sync::Mutex;
18
19/// Rotation policy for a [`DecisionLog`].
20///
21/// When the active file's size exceeds `max_bytes`, the next append
22/// renames it to a timestamped sibling and opens a fresh file. The
23/// chain id sidecar is preserved per file so a verifier can walk
24/// rotated logs in order. Default behaviour (no rotation) is the
25/// backward-compatible path; supply `Some(RotationConfig { .. })` to
26/// `open_with_chain` / `open` to enable it.
27#[derive(Debug, Clone)]
28pub struct RotationConfig {
29    /// Rotate when the active file exceeds this many bytes.
30    pub max_bytes: u64,
31}
32
33impl RotationConfig {
34    /// Parse a positive audit-log rotation threshold in bytes.
35    pub fn parse(value: &str) -> std::result::Result<Self, String> {
36        match value.parse::<u64>() {
37            Ok(max_bytes) if max_bytes > 0 => Ok(Self { max_bytes }),
38            _ => Err("must be a positive integer when set".to_owned()),
39        }
40    }
41
42    /// Read `AGENTGUARD_AUDIT_MAX_BYTES` from the environment, rejecting
43    /// malformed or non-Unicode values instead of silently disabling rotation.
44    pub fn try_from_env() -> std::result::Result<Option<Self>, String> {
45        match std::env::var("AGENTGUARD_AUDIT_MAX_BYTES") {
46            Ok(value) => Self::parse(&value).map(Some),
47            Err(std::env::VarError::NotPresent) => Ok(None),
48            Err(std::env::VarError::NotUnicode(_)) => Err("must be valid Unicode".to_owned()),
49        }
50    }
51
52    /// Lossy legacy environment parser. Prefer [`Self::try_from_env`].
53    #[deprecated(note = "use try_from_env to report invalid configuration")]
54    pub fn from_env() -> Option<Self> {
55        Self::try_from_env().ok().flatten()
56    }
57}
58
59/// Thread-safe append-only JSONL log.
60///
61/// When constructed via [`DecisionLog::open`], the log is plain JSONL.
62/// When constructed via [`DecisionLog::open_with_chain`], each record is
63/// chained to the previous via HMAC-SHA256.
64pub struct DecisionLog {
65    mode: LogMode,
66    /// Resolved path of the audit log file. Used by tracing
67    /// instrumentation on every append and exposed via [`Self::path`]
68    /// for diagnostics and tests.
69    path: PathBuf,
70    /// Sidecar file holding the chain id (UUID). Persisted on first
71    /// use so the chain's identity survives process restarts.
72    chain_id_path: PathBuf,
73    /// Optional rotation policy. When `Some`, append() rotates the
74    /// active file once its size exceeds `max_bytes`.
75    rotation: Option<RotationConfig>,
76    /// Serializes threshold checks and renames so concurrent appenders cannot
77    /// both attempt to rotate the same active file.
78    rotation_lock: Mutex<()>,
79}
80
81enum LogMode {
82    Plain(Mutex<Option<File>>),
83    Chained {
84        file: Mutex<Option<File>>,
85        chain: HashChain,
86    },
87}
88
89impl DecisionLog {
90    /// Open a plain (un-chained) JSONL log at `path`.
91    ///
92    /// # Errors
93    /// Returns `Error::Io` if the path cannot be opened for append or if
94    /// the parent directory cannot be created.
95    ///
96    /// # Examples
97    /// ```
98    /// use agentguard_core::decision::DecisionLog;
99    /// let log = DecisionLog::open("/tmp/audit.jsonl").unwrap();
100    /// ```
101    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
102        Self::open_internal(path.into(), None, None)
103    }
104
105    /// Open a hash-chained JSONL log at `path` with the given root key.
106    ///
107    /// # Errors
108    /// Returns `Error::Io` if the path cannot be opened or the parent
109    /// directory cannot be created.
110    ///
111    /// # Examples
112    /// ```
113    /// use agentguard_core::decision::DecisionLog;
114    /// let log = DecisionLog::open_with_chain("/tmp/audit.jsonl", b"root-key").unwrap();
115    /// ```
116    pub fn open_with_chain(path: impl Into<PathBuf>, root_key: &[u8]) -> Result<Self> {
117        Self::open_internal(path.into(), Some(root_key.to_vec()), None)
118    }
119
120    /// Open with an explicit rotation policy. When the active file
121    /// exceeds `rotation.max_bytes`, the next append renames it to a
122    /// timestamped sibling and opens a fresh active file.
123    pub fn open_with_rotation(
124        path: impl Into<PathBuf>,
125        root_key: Option<&[u8]>,
126        rotation: RotationConfig,
127    ) -> Result<Self> {
128        Self::open_internal(path.into(), root_key.map(|k| k.to_vec()), Some(rotation))
129    }
130
131    fn open_internal(
132        path: PathBuf,
133        root_key: Option<Vec<u8>>,
134        rotation: Option<RotationConfig>,
135    ) -> Result<Self> {
136        if let Some(parent) = path.parent() {
137            std::fs::create_dir_all(parent)?;
138        }
139        let f = open_audit_file(&path)?;
140        let chain_id_path = chain_id_sidecar_path(&path);
141        match root_key {
142            None => Ok(Self {
143                mode: LogMode::Plain(Mutex::new(Some(f))),
144                path,
145                chain_id_path,
146                rotation,
147                rotation_lock: Mutex::new(()),
148            }),
149            Some(key) => {
150                let chain = HashChain::new(&key);
151                // Authenticate all persisted segments before accepting new
152                // writes. Restoring only the final hash would let a validly
153                // formatted edit survive startup and be extended as if it
154                // were trustworthy.
155                let mut persisted_paths = rotated_logs(&path)?;
156                persisted_paths.push(path.clone());
157                verify_chain_paths(&persisted_paths, &key).map_err(|e| {
158                    Error::Other(format!(
159                        "refusing to open invalid chained audit log {}: {e}",
160                        path.display()
161                    ))
162                })?;
163                // Resume from the active file's last record. A crash can
164                // happen after rotation renamed the old file but before the
165                // next append populated the new active file; in that case,
166                // recover from the newest rotated segment instead of
167                // silently starting a disconnected chain.
168                let head_path = if std::fs::metadata(&path)
169                    .map(|metadata| metadata.len() == 0)
170                    .unwrap_or(true)
171                {
172                    latest_rotated_log(&path)?.unwrap_or_else(|| path.clone())
173                } else {
174                    path.clone()
175                };
176                // Corruption is reported (not silently ignored) so the
177                // operator is alerted to tampering or partial writes.
178                chain.load_head_from_file(&head_path).map_err(|e| {
179                    Error::Other(format!(
180                        "refusing to open corrupt chained audit log {}: {e}",
181                        head_path.display()
182                    ))
183                })?;
184                // Adopt the chain_id from the sidecar file (if present)
185                // BEFORE any append, so that the very first record's
186                // chain_id matches the persisted one and verify_chain
187                // works across restarts. If the sidecar is missing we
188                // eagerly persist a freshly generated id so subsequent
189                // restarts converge on the same id even before the
190                // first append lands.
191                let prior_sidecar = if head_path == path {
192                    chain_id_path.clone()
193                } else {
194                    chain_id_sidecar_path(&head_path)
195                };
196                if let Some(id) = read_chain_id_sidecar(&prior_sidecar) {
197                    chain.adopt_id(id);
198                } else {
199                    let id = chain.id();
200                    write_chain_id_sidecar(&chain_id_path, id).map_err(|error| {
201                        Error::Io(format!(
202                            "persist audit chain id {}: {error}",
203                            chain_id_path.display()
204                        ))
205                    })?;
206                }
207                Ok(Self {
208                    mode: LogMode::Chained {
209                        file: Mutex::new(Some(f)),
210                        chain,
211                    },
212                    path,
213                    chain_id_path,
214                    rotation,
215                    rotation_lock: Mutex::new(()),
216                })
217            }
218        }
219    }
220
221    pub fn default_path() -> PathBuf {
222        PathBuf::from(".audit/decisions.jsonl")
223    }
224
225    /// Rotate the active file when it crosses `rotation.max_bytes`.
226    /// Cheap no-op when rotation is disabled or the threshold has not
227    /// been reached. Called at the top of [`Self::append`].
228    fn rotate_if_needed(&self) -> Result<()> {
229        let _rotation_guard = self.rotation_lock.lock().unwrap_or_else(|e| e.into_inner());
230        let rotation = match &self.rotation {
231            Some(r) => r,
232            None => return Ok(()),
233        };
234        let size = std::fs::metadata(&self.path).map(|m| m.len()).unwrap_or(0);
235        if size < rotation.max_bytes {
236            return Ok(());
237        }
238        self.rotate_inner()
239    }
240
241    /// Force a rotation: rename the active file to a timestamped
242    /// sibling in the same directory and reopen the active file. The
243    /// chain id sidecar is moved alongside so the rotated file's
244    /// chain id is preserved.
245    pub fn rotate(&self) -> Result<()> {
246        let _rotation_guard = self.rotation_lock.lock().unwrap_or_else(|e| e.into_inner());
247        self.rotate_inner()
248    }
249
250    fn rotate_inner(&self) -> Result<()> {
251        let now = chrono::Utc::now();
252        let ts = format!(
253            "{}{:06}Z",
254            now.format("%Y%m%dT%H%M%S"),
255            now.timestamp_subsec_micros()
256        );
257        let parent = self.path.parent().unwrap_or_else(|| Path::new("."));
258        let stem = self
259            .path
260            .file_stem()
261            .and_then(|s| s.to_str())
262            .unwrap_or("decisions");
263        let ext = self
264            .path
265            .extension()
266            .and_then(|s| s.to_str())
267            .unwrap_or("jsonl");
268        let mut rotated = parent.join(format!("{stem}-{ts}.{ext}"));
269        let mut suffix = 1u32;
270        while rotated.exists() {
271            rotated = parent.join(format!("{stem}-{ts}-{suffix}.{ext}"));
272            suffix = suffix.saturating_add(1);
273        }
274
275        // Hold the file lock across close + rename + reopen. Otherwise a
276        // concurrent append can observe the temporary `None` and silently
277        // discard its record while rotation is in progress.
278        match &self.mode {
279            LogMode::Plain(file) => {
280                let mut guard = file.lock().unwrap_or_else(|e| e.into_inner());
281                *guard = None;
282                *guard = Some(self.reopen_rotated(&rotated)?);
283            }
284            LogMode::Chained { file, chain } => {
285                let mut guard = file.lock().unwrap_or_else(|e| e.into_inner());
286                *guard = None;
287                let f = self.reopen_rotated(&rotated)?;
288                // Re-load chain head from the rotated file so the new
289                // active file chains from where the rotated one left
290                // off. The chain id is preserved through the sidecar
291                // rename above.
292                chain
293                    .load_head_from_file(&rotated)
294                    .map_err(|e| Error::Io(format!("reload chain after rotate: {e}")))?;
295                if let Some(id) = read_chain_id_sidecar(&chain_id_sidecar_path(&rotated)) {
296                    chain.adopt_id(id);
297                }
298                *guard = Some(f);
299            }
300        }
301        tracing::info!(
302            active = %self.path.display(),
303            rotated = %rotated.display(),
304            "rotated audit log"
305        );
306        Ok(())
307    }
308
309    fn reopen_rotated(&self, rotated: &Path) -> Result<File> {
310        let rotated_sidecar = chain_id_sidecar_path(rotated);
311        let require_chain_id = matches!(&self.mode, LogMode::Chained { .. });
312        copy_chain_id_sidecar(&self.chain_id_path, &rotated_sidecar, require_chain_id)?;
313        std::fs::rename(&self.path, rotated).map_err(|e| {
314            Error::Io(format!(
315                "rotate {} -> {}: {}",
316                self.path.display(),
317                rotated.display(),
318                e
319            ))
320        })?;
321        open_audit_file(&self.path).map_err(Error::from)
322    }
323
324    /// Path the log was opened at. Useful for `agentguard doctor` and
325    /// for tests that need to assert where the audit was written.
326    pub fn path(&self) -> &Path {
327        &self.path
328    }
329
330    pub fn chain_id(&self) -> Option<ChainId> {
331        match &self.mode {
332            LogMode::Plain(_) => None,
333            LogMode::Chained { chain, .. } => Some(chain.id()),
334        }
335    }
336
337    /// Whether the writer still has a usable active file handle. This turns
338    /// permanent append failures into an observable readiness signal instead
339    /// of leaving the service healthy while every decision fails to audit.
340    pub fn is_healthy(&self) -> bool {
341        match &self.mode {
342            LogMode::Plain(file) | LogMode::Chained { file, .. } => {
343                file.lock().map(|guard| guard.is_some()).unwrap_or(false)
344            }
345        }
346    }
347
348    /// Append a record. When the log is chained, the record is signed.
349    ///
350    /// # Crash safety (chained mode)
351    /// The file lock is held for the entire critical section:
352    /// 1. Compute the chained payload (prev + new hash).
353    /// 2. `write_all` + `flush` + `sync_all` the JSON line to the log
354    ///    file. The next startup validates the complete tail before
355    ///    accepting new writes, so a torn or malformed final record fails
356    ///    closed instead of silently starting a disconnected chain.
357    /// 3. The chain head advances only after step 2 succeeds. On
358    ///    failure, the chain stays at the previous head and the next
359    ///    caller retries from the same state.
360    ///
361    /// On restart, `load_head_from_file` reads the on-disk head and
362    /// adopts it — even if we crashed between the `sync_all` and the
363    /// head advance, the record is fully on disk and the chain
364    /// matches.
365    ///
366    /// # Errors
367    /// Returns `Error::Json` if `rec` cannot be serialized, or
368    /// `Error::Io` if the write/fsync fails.
369    #[tracing::instrument(skip_all, fields(path = %self.path.display()))]
370    pub fn append(&self, rec: &DecisionRecord) -> Result<()> {
371        let canonical = canonical_json(rec)?;
372        // Rotate before writing if the active file has crossed the
373        // size threshold. Cheap no-op when nothing is configured.
374        self.rotate_if_needed()?;
375        match &self.mode {
376            LogMode::Plain(file) => {
377                let mut line = serde_json::to_vec(rec)?;
378                line.push(b'\n');
379                let mut guard = file.lock().unwrap_or_else(|e| e.into_inner());
380                let Some(f) = guard.as_mut() else {
381                    return Err(Error::Io(
382                        "audit log is unavailable after a prior storage failure".into(),
383                    ));
384                };
385                let write_result = write_line_durably(f, &line);
386                if let Err(error) = write_result {
387                    // A failed write may have left a partial JSONL record.
388                    // Poison this handle so no later caller can mistake a
389                    // missing or ambiguous record for a successful append.
390                    *guard = None;
391                    return Err(Error::from(error));
392                }
393            }
394            LogMode::Chained { file, chain } => {
395                // Lock the file first (the chain head lock is acquired
396                // inside `try_append_with_io`); ordering is
397                // file -> chain so the call graph cannot deadlock.
398                let chain_id = chain.id();
399                // Rotation moves the sidecar alongside the previous segment,
400                // so create it for the new active segment. Avoid an extra
401                // file and directory fsync for every normal append.
402                if read_chain_id_sidecar(&self.chain_id_path) != Some(chain_id) {
403                    if let Err(error) = write_chain_id_sidecar(&self.chain_id_path, chain_id) {
404                        let mut guard = file.lock().unwrap_or_else(|e| e.into_inner());
405                        *guard = None;
406                        return Err(Error::Io(format!(
407                            "persist audit chain id {}: {error}",
408                            self.chain_id_path.display()
409                        )));
410                    }
411                }
412                let mut guard = file.lock().unwrap_or_else(|e| e.into_inner());
413                let Some(f) = guard.as_mut() else {
414                    return Err(Error::Io(
415                        "audit log is unavailable after a prior storage failure".into(),
416                    ));
417                };
418                // Atomic: chain head advances only on a successful
419                // write_all + sync_all. A failed or uncertain write poisons
420                // this handle, preventing later appends from silently
421                // diverging from the on-disk chain.
422                if let Err(error) =
423                    chain.try_append_with_io(&canonical, |prev, new_hash| -> std::io::Result<()> {
424                        let chained = ChainedRecord {
425                            prev_hash: hex::encode(prev),
426                            record_hash: hex::encode(new_hash),
427                            chain_id,
428                            record: rec.clone(),
429                        };
430                        let mut line_with_newline = serde_json::to_vec(&chained)?;
431                        line_with_newline.push(b'\n');
432                        // Write directly to the File; the kernel
433                        // page cache is the buffering layer. A
434                        // per-call BufWriter::new allocation was
435                        // dropped (50-80 ns saved per append).
436                        write_line_durably(f, &line_with_newline)
437                    })
438                {
439                    *guard = None;
440                    return Err(Error::from(error));
441                }
442            }
443        }
444        Ok(())
445    }
446
447    /// Convenience wrapper around [`Self::append`] that constructs the
448    /// `DecisionRecord` from a `Decision` with empty session/chain
449    /// metadata. Use [`Self::append`] directly to attach session
450    /// ID or delegation chain.
451    ///
452    /// # Errors
453    /// Same as [`Self::append`].
454    pub fn append_decision(&self, d: &Decision) -> Result<()> {
455        let rec = DecisionRecord::from_decision(d, None, None);
456        self.append(&rec)
457    }
458
459    /// Read every record from the audit log and its timestamped rotation
460    /// siblings in chronological order, accepting either plain or chained
461    /// records (mixed log files are supported; older records may pre-date
462    /// chain metadata).
463    ///
464    /// # Errors
465    /// Returns `Error::Io` if the file cannot be read, `Error::Json`
466    /// if a record cannot be parsed.
467    pub fn read_all(path: impl AsRef<Path>) -> Result<Vec<DecisionRecord>> {
468        let path = path.as_ref();
469        let mut paths = rotated_logs(path)?;
470        paths.push(path.to_path_buf());
471        Self::read_all_mixed_paths(&paths)
472    }
473
474    /// Read every record from the audit log, requiring all records to
475    /// carry chain metadata. A plain (non-chained) record in the log
476    /// surfaces as `Error::Json` with the row number.
477    ///
478    /// # Errors
479    /// Returns `Error::Io` if the file cannot be read, `Error::Json`
480    /// if a record cannot be parsed or is missing chain metadata.
481    pub fn read_all_chained(path: impl AsRef<Path>) -> Result<Vec<ChainedRecord>> {
482        let f = open_existing_audit_file(path.as_ref())?;
483        let r = BufReader::new(f);
484        let mut out = Vec::new();
485        for line in r.lines() {
486            let line = line?;
487            if line.trim().is_empty() {
488                continue;
489            }
490            let rec: ChainedRecord = serde_json::from_str(&line)?;
491            out.push(rec);
492        }
493        Ok(out)
494    }
495
496    /// Read a JSONL audit log that may contain either plain
497    /// `DecisionRecord` lines or `ChainedRecord` lines (with the chain
498    /// metadata flattened). The format is auto-detected per line.
499    fn read_all_mixed_paths(paths: &[PathBuf]) -> Result<Vec<DecisionRecord>> {
500        let mut out = Vec::new();
501        for path in paths {
502            let f = open_existing_audit_file(path)?;
503            let r = BufReader::new(f);
504            for (idx, line) in r.lines().enumerate() {
505                let line = line?;
506                if line.trim().is_empty() {
507                    continue;
508                }
509                // Try as DecisionRecord first; on failure, treat as a
510                // ChainedRecord and extract the embedded record. This order
511                // is correct because ChainedRecord has additional fields
512                // (`prev_hash`, `record_hash`, `chain_id`) that would make
513                // DecisionRecord parsing fail.
514                let rec: DecisionRecord = match serde_json::from_str(&line) {
515                    Ok(r) => r,
516                    Err(plain_err) => match serde_json::from_str::<ChainedRecord>(&line) {
517                        Ok(chained) => chained.record,
518                        Err(chained_err) => {
519                            return Err(Error::Json(format!(
520                                "{} line {}: not a DecisionRecord ({plain_err}); \
521                                 also not a ChainedRecord ({chained_err})",
522                                path.display(),
523                                idx + 1
524                            )));
525                        }
526                    },
527                };
528                out.push(rec);
529            }
530        }
531        Ok(out)
532    }
533
534    /// Verify the entire audit log against the root key.
535    ///
536    /// Reads every record from `path` and its timestamped rotation siblings,
537    /// checks the continuous HMAC chain, and returns the chain id. Returns
538    /// an error if a record is malformed, tampered, or belongs to another
539    /// chain.
540    ///
541    /// # Errors
542    /// Returns `Error::Other` (formatted string) on parse failure, hash
543    /// mismatch, or chain head mismatch.
544    ///
545    /// # Examples
546    /// ```
547    /// use agentguard_core::decision::DecisionLog;
548    /// let path = std::env::temp_dir().join("agentguard-verify-chain.jsonl");
549    /// let _ = std::fs::remove_file(&path);
550    /// let log = DecisionLog::open_with_chain(&path, b"secret").unwrap();
551    /// // ... write some decisions ...
552    /// drop(log);
553    /// let chain_id = DecisionLog::verify_chain(&path, b"secret").unwrap();
554    /// println!("verified chain: {}", chain_id);
555    /// let _ = std::fs::remove_file(&path);
556    /// ```
557    pub fn verify_chain(path: impl AsRef<Path>, root_key: &[u8]) -> Result<ChainId> {
558        let path = path.as_ref();
559        let mut paths = rotated_logs(path)?;
560        paths.push(path.to_path_buf());
561        verify_chain_paths(&paths, root_key)
562    }
563}
564
565fn verify_chain_paths(paths: &[PathBuf], root_key: &[u8]) -> Result<ChainId> {
566    let mut chain_id = None;
567    let mut expected_prev = [0u8; HASH_LEN];
568    let mut record_number = 0usize;
569    let chain = HashChain::new(root_key);
570    for path in paths {
571        let file = open_existing_audit_file(path)?;
572        let reader = BufReader::new(file);
573        for (line_number, line) in reader.lines().enumerate() {
574            let line = line?;
575            if line.trim().is_empty() {
576                continue;
577            }
578            let record: ChainedRecord = serde_json::from_str(&line).map_err(|error| {
579                Error::Json(format!(
580                    "{} line {}: {error}",
581                    path.display(),
582                    line_number + 1
583                ))
584            })?;
585            if let Some(expected) = chain_id {
586                if record.chain_id != expected {
587                    return Err(Error::Other(format!(
588                        "chain id mismatch in {}: expected {}, got {}",
589                        path.display(),
590                        expected,
591                        record.chain_id
592                    )));
593                }
594            } else {
595                chain_id = Some(record.chain_id);
596            }
597            let canonical = canonical_json(&record.record)?;
598            let prev = parse_hex32(&record.prev_hash)?;
599            let hash = parse_hex32(&record.record_hash)?;
600            if prev != expected_prev {
601                return Err(Error::Other(format!(
602                    "record {}: prev_hash mismatch: expected {}, got {}",
603                    record_number,
604                    hex::encode(expected_prev),
605                    hex::encode(prev)
606                )));
607            }
608            chain
609                .verify(&canonical, &prev, &hash)
610                .map_err(|error| Error::Other(format!("record {}: {error}", record_number)))?;
611            expected_prev = hash;
612            record_number += 1;
613        }
614    }
615    Ok(chain_id.unwrap_or_default())
616}
617
618fn parse_hex32(s: &str) -> Result<[u8; HASH_LEN]> {
619    let bytes = hex::decode(s).map_err(|e| Error::Other(format!("hex: {}", e)))?;
620    if bytes.len() != HASH_LEN {
621        return Err(Error::Other(format!(
622            "expected {} bytes, got {}",
623            HASH_LEN,
624            bytes.len()
625        )));
626    }
627    let mut arr = [0u8; HASH_LEN];
628    arr.copy_from_slice(&bytes);
629    Ok(arr)
630}
631
632/// Path of the sidecar file that persists the chain id across restarts.
633///
634/// We avoid putting the chain_id inside the JSONL log itself because
635/// the JSONL file uses atomic-rename; a sentinel "header" record would
636/// be overwritten by the first real append. A sibling file is simpler.
637fn chain_id_sidecar_path(log_path: &Path) -> PathBuf {
638    let parent = log_path.parent().unwrap_or_else(|| Path::new("."));
639    let name = log_path
640        .file_name()
641        .and_then(|s| s.to_str())
642        .unwrap_or("audit");
643    parent.join(format!(".{}.chainid", name))
644}
645
646/// Persist the active chain identity beside a rotated segment before renaming
647/// the log itself. Keep the active sidecar in place so the new active segment
648/// can continue the same chain. Chained logs require this metadata; plain logs
649/// may not have a sidecar at all.
650fn copy_chain_id_sidecar(active: &Path, rotated: &Path, required: bool) -> Result<()> {
651    match std::fs::symlink_metadata(active) {
652        Ok(metadata) if metadata.file_type().is_file() => {}
653        Ok(_) => {
654            return Err(Error::Io(format!(
655                "chain identity sidecar must be a regular file: {}",
656                active.display()
657            )))
658        }
659        Err(error) if error.kind() == std::io::ErrorKind::NotFound && !required => return Ok(()),
660        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
661            return Err(Error::Io(format!(
662                "required chain identity sidecar is missing: {}",
663                active.display()
664            )))
665        }
666        Err(error) => {
667            return Err(Error::Io(format!(
668                "inspect chain identity sidecar {}: {error}",
669                active.display()
670            )))
671        }
672    }
673
674    let id = read_chain_id_sidecar(active).ok_or_else(|| {
675        Error::Io(format!(
676            "chain identity sidecar is invalid: {}",
677            active.display()
678        ))
679    })?;
680    write_chain_id_sidecar(rotated, id).map_err(|error| {
681        Error::Io(format!(
682            "persist rotated chain identity sidecar {}: {error}",
683            rotated.display()
684        ))
685    })
686}
687
688/// List timestamped rotations for `log_path` in chronological order. Rotation
689/// timestamps are UTC and fixed-width; collision suffixes are sorted
690/// numerically so `-10` follows `-9`, not `-1`.
691fn rotated_logs(log_path: &Path) -> Result<Vec<PathBuf>> {
692    let parent = log_path.parent().unwrap_or_else(|| Path::new("."));
693    let stem = log_path
694        .file_stem()
695        .and_then(|s| s.to_str())
696        .unwrap_or("decisions");
697    let extension = log_path
698        .extension()
699        .and_then(|s| s.to_str())
700        .unwrap_or("jsonl");
701    let prefix = format!("{stem}-");
702    let mut candidates = Vec::new();
703    for entry in std::fs::read_dir(parent)? {
704        let entry = entry?;
705        let path = entry.path();
706        let rotation_suffix = path
707            .file_stem()
708            .and_then(|s| s.to_str())
709            .and_then(|stem| stem.strip_prefix(&prefix));
710        if rotation_suffix.is_some_and(is_rotation_filename)
711            && path.extension().and_then(|s| s.to_str()) == Some(extension)
712            && entry.file_type()?.is_file()
713        {
714            candidates.push(path);
715        }
716    }
717    candidates.sort_by_key(|path| rotation_order(path, &prefix));
718    Ok(candidates)
719}
720
721fn rotation_order(path: &Path, prefix: &str) -> (String, u32) {
722    let suffix = path
723        .file_stem()
724        .and_then(|stem| stem.to_str())
725        .and_then(|stem| stem.strip_prefix(prefix))
726        .unwrap_or_default();
727    let (timestamp, collision) = suffix
728        .split_once('-')
729        .map_or((suffix, None), |(timestamp, collision)| {
730            (timestamp, Some(collision))
731        });
732    let collision = collision
733        .and_then(|value| value.parse::<u32>().ok())
734        .unwrap_or(0);
735    (timestamp.to_string(), collision)
736}
737
738fn latest_rotated_log(log_path: &Path) -> Result<Option<PathBuf>> {
739    Ok(rotated_logs(log_path)?.pop())
740}
741
742/// Open an existing audit path only when it is a regular file. Checking the
743/// directory entry before opening prevents FIFOs and device nodes from
744/// blocking startup or audit inspection; the descriptor check also catches
745/// replacement races that resolve to a non-regular file.
746fn open_existing_audit_file(path: &Path) -> std::io::Result<File> {
747    validate_audit_path(path)?;
748    let file = File::open(path)?;
749    if !file.metadata()?.is_file() {
750        return Err(std::io::Error::new(
751            std::io::ErrorKind::InvalidInput,
752            format!("audit path must be a regular file: {}", path.display()),
753        ));
754    }
755    Ok(file)
756}
757
758/// Open (or create) the active append-only audit file, rejecting symlinks,
759/// FIFOs, sockets, and device nodes instead of treating them as durable files.
760fn open_audit_file(path: &Path) -> std::io::Result<File> {
761    validate_audit_path(path)?;
762    let mut options = OpenOptions::new();
763    options.create(true).append(true);
764    #[cfg(unix)]
765    {
766        use std::os::unix::fs::OpenOptionsExt;
767        options.mode(0o600);
768    }
769    let file = options.open(path)?;
770    if !file.metadata()?.is_file() {
771        return Err(std::io::Error::new(
772            std::io::ErrorKind::InvalidInput,
773            format!("audit path must be a regular file: {}", path.display()),
774        ));
775    }
776    #[cfg(unix)]
777    {
778        use std::os::unix::fs::PermissionsExt;
779        file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
780    }
781    Ok(file)
782}
783
784fn validate_audit_path(path: &Path) -> std::io::Result<()> {
785    match std::fs::symlink_metadata(path) {
786        Ok(metadata) if metadata.file_type().is_file() => Ok(()),
787        Ok(_) => Err(std::io::Error::new(
788            std::io::ErrorKind::InvalidInput,
789            format!(
790                "audit path must be a regular file, not a symlink or special file: {}",
791                path.display()
792            ),
793        )),
794        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
795        Err(error) => Err(error),
796    }
797}
798
799/// Append one complete JSONL record and durably persist it. If a write or
800/// sync fails after a partial append, truncate back to the previous boundary
801/// before returning the original error. Callers poison the handle regardless,
802/// since a failed fsync leaves durability uncertain even after rollback.
803fn write_line_durably(file: &mut File, line: &[u8]) -> std::io::Result<()> {
804    let original_len = file.metadata()?.len();
805    if let Err(write_error) = file.write_all(line).and_then(|()| file.sync_all()) {
806        return match file.set_len(original_len).and_then(|()| file.sync_all()) {
807            Ok(()) => Err(write_error),
808            Err(rollback_error) => Err(std::io::Error::new(
809                rollback_error.kind(),
810                format!("audit append failed ({write_error}); rollback failed ({rollback_error})"),
811            )),
812        };
813    }
814    Ok(())
815}
816
817fn is_rotation_filename(suffix: &str) -> bool {
818    let timestamp = suffix
819        .split_once('-')
820        .map_or(suffix, |(timestamp, _)| timestamp);
821    let bytes = timestamp.as_bytes();
822    if bytes.len() != 22 || bytes[8] != b'T' || bytes[21] != b'Z' {
823        return false;
824    }
825    if !bytes
826        .iter()
827        .enumerate()
828        .all(|(index, byte)| index == 8 || index == 21 || byte.is_ascii_digit())
829    {
830        return false;
831    }
832    match suffix.split_once('-') {
833        None => true,
834        Some((_, collision_suffix)) => collision_suffix
835            .parse::<u32>()
836            .is_ok_and(|number| number > 0),
837    }
838}
839
840/// Best-effort read of the persisted chain id. Returns `None` if the
841/// file is missing, malformed, or unreadable; callers treat all three
842/// as "no prior chain" and fall back to a freshly generated id.
843fn read_chain_id_sidecar(path: &Path) -> Option<ChainId> {
844    let text = std::fs::read_to_string(path).ok()?;
845    let trimmed = text.trim();
846    let uuid = uuid::Uuid::parse_str(trimmed).ok()?;
847    Some(ChainId(uuid))
848}
849
850/// Atomically and durably write the chain id sidecar. Failure is fatal to
851/// opening/appending a chained log: without this metadata a restart could
852/// resume the hash head under a different chain identity.
853fn write_chain_id_sidecar(path: &Path, id: ChainId) -> std::io::Result<()> {
854    let parent = path.parent().unwrap_or_else(|| Path::new("."));
855    let tmp = parent.join(format!(
856        ".{}.{}.new",
857        path.file_name()
858            .and_then(|s| s.to_str())
859            .unwrap_or("chainid"),
860        uuid::Uuid::now_v7(),
861    ));
862    let body = format!("{}\n", id.0);
863    {
864        let mut options = OpenOptions::new();
865        options.create_new(true).write(true);
866        #[cfg(unix)]
867        {
868            use std::os::unix::fs::OpenOptionsExt;
869            options.mode(0o600);
870        }
871        let f = options.open(&tmp)?;
872        let mut w = BufWriter::new(f);
873        std::io::Write::write_all(&mut w, body.as_bytes())?;
874        w.flush()?;
875        let inner = w
876            .into_inner()
877            .map_err(|e| std::io::Error::other(e.to_string()))?;
878        inner.sync_all()?;
879    }
880    if let Err(error) = std::fs::rename(&tmp, path) {
881        let _ = std::fs::remove_file(&tmp);
882        return Err(error);
883    }
884    #[cfg(unix)]
885    File::open(parent)?.sync_all()?;
886    Ok(())
887}
888
889/// A record with chain metadata. On disk, the chain fields are at the top
890/// level alongside the record fields.
891#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
892pub struct ChainedRecord {
893    pub prev_hash: String,
894    pub record_hash: String,
895    pub chain_id: ChainId,
896    #[serde(flatten)]
897    pub record: DecisionRecord,
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903
904    #[test]
905    fn plain_log_writes_no_chain_fields() {
906        let dir = tempfile::tempdir().unwrap();
907        let path = dir.path().join("plain.jsonl");
908        let log = DecisionLog::open(&path).unwrap();
909        assert!(log.chain_id().is_none());
910    }
911
912    #[test]
913    fn chained_log_assigns_chain_id() {
914        let dir = tempfile::tempdir().unwrap();
915        let path = dir.path().join("chained.jsonl");
916        let log = DecisionLog::open_with_chain(&path, b"root").unwrap();
917        assert!(log.chain_id().is_some());
918    }
919
920    #[test]
921    fn chained_log_refuses_to_open_when_chain_id_cannot_be_persisted() {
922        let dir = tempfile::tempdir().unwrap();
923        let path = dir.path().join("audit.jsonl");
924        std::fs::create_dir(chain_id_sidecar_path(&path)).unwrap();
925
926        let result = DecisionLog::open_with_chain(&path, b"root");
927
928        assert!(
929            result.is_err(),
930            "a chained log must not open without durable chain identity"
931        );
932    }
933
934    #[test]
935    fn rotation_sidecar_copy_fails_closed_and_cleans_temporary_file() {
936        let dir = tempfile::tempdir().unwrap();
937        let active = dir.path().join(".audit.jsonl.chainid");
938        let rotated = dir.path().join(".audit-rotated.jsonl.chainid");
939        let id = ChainId::new();
940        write_chain_id_sidecar(&active, id).unwrap();
941        std::fs::create_dir(&rotated).unwrap();
942
943        assert!(copy_chain_id_sidecar(&active, &rotated, true).is_err());
944        assert_eq!(read_chain_id_sidecar(&active), Some(id));
945        assert_eq!(
946            std::fs::read_dir(dir.path())
947                .unwrap()
948                .filter_map(|entry| entry.ok())
949                .filter(|entry| entry.file_name().to_string_lossy().ends_with(".new"))
950                .count(),
951            0,
952            "failed sidecar persistence should remove its temporary file"
953        );
954    }
955
956    #[test]
957    fn rotation_sidecar_copy_allows_missing_metadata_only_for_plain_logs() {
958        let dir = tempfile::tempdir().unwrap();
959        let active = dir.path().join("missing.chainid");
960        let rotated = dir.path().join("rotated.chainid");
961
962        assert!(copy_chain_id_sidecar(&active, &rotated, false).is_ok());
963        assert!(copy_chain_id_sidecar(&active, &rotated, true).is_err());
964        assert!(!rotated.exists());
965    }
966
967    #[test]
968    fn chained_append_with_unwritable_chain_id_sidecar_fails_closed() {
969        let dir = tempfile::tempdir().unwrap();
970        let path = dir.path().join("audit.jsonl");
971        let log = DecisionLog::open_with_chain(&path, b"root").unwrap();
972        let sidecar = chain_id_sidecar_path(&path);
973        std::fs::remove_file(&sidecar).unwrap();
974        std::fs::create_dir(sidecar).unwrap();
975        let rec = DecisionRecord {
976            id: "a".into(),
977            timestamp: chrono::Utc::now(),
978            effect: "allow".into(),
979            policies: vec![],
980            request_id: None,
981            principal: "alice".into(),
982            action: "send".into(),
983            resource: "doc".into(),
984            reasons: vec![],
985            session_id: None,
986            agent_chain: None,
987            trace_id: None,
988            span_id: None,
989            tenant_id: None,
990            subject_id: None,
991            authenticated_actor: None,
992        };
993
994        assert!(log.append(&rec).is_err());
995        assert!(
996            !log.is_healthy(),
997            "failed metadata persistence withdraws readiness"
998        );
999        assert!(log.append(&rec).is_err());
1000        assert_eq!(std::fs::metadata(path).unwrap().len(), 0);
1001    }
1002
1003    #[test]
1004    fn chained_log_refuses_corrupt_existing_tail() {
1005        let dir = tempfile::tempdir().unwrap();
1006        let path = dir.path().join("corrupt.jsonl");
1007        std::fs::write(&path, "{\"not\":\"a chained record\"}\n").unwrap();
1008
1009        let err = match DecisionLog::open_with_chain(&path, b"root") {
1010            Ok(_) => panic!("corrupt chained audit log must be rejected"),
1011            Err(err) => err,
1012        };
1013        assert!(err
1014            .to_string()
1015            .contains("refusing to open invalid chained audit log"));
1016    }
1017
1018    #[test]
1019    fn chained_log_refuses_valid_json_tampering_at_startup() {
1020        let dir = tempfile::tempdir().unwrap();
1021        let path = dir.path().join("tampered.jsonl");
1022        let rec = DecisionRecord {
1023            id: "a".into(),
1024            timestamp: chrono::Utc::now(),
1025            effect: "allow".into(),
1026            policies: vec![],
1027            request_id: None,
1028            principal: "alice".into(),
1029            action: "send".into(),
1030            resource: "doc".into(),
1031            reasons: vec![],
1032            session_id: None,
1033            agent_chain: None,
1034            trace_id: None,
1035            span_id: None,
1036            tenant_id: None,
1037            subject_id: None,
1038            authenticated_actor: None,
1039        };
1040        {
1041            let log = DecisionLog::open_with_chain(&path, b"root").unwrap();
1042            log.append(&rec).unwrap();
1043        }
1044
1045        let mut line: serde_json::Value =
1046            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1047        line["principal"] = serde_json::Value::String("mallory".into());
1048        std::fs::write(&path, format!("{line}\n")).unwrap();
1049
1050        let err = match DecisionLog::open_with_chain(&path, b"root") {
1051            Ok(_) => panic!("tampered chained audit log must be rejected"),
1052            Err(err) => err,
1053        };
1054        assert!(err
1055            .to_string()
1056            .contains("refusing to open invalid chained audit log"));
1057    }
1058
1059    #[test]
1060    fn chain_id_persists_across_restart() {
1061        // Two DecisionLog instances over the same path must observe the
1062        // same chain id (the persisted id is adopted on the second open).
1063        let dir = tempfile::tempdir().unwrap();
1064        let path = dir.path().join("chained.jsonl");
1065        let log1 = DecisionLog::open_with_chain(&path, b"root").unwrap();
1066        let id1 = log1.chain_id().unwrap();
1067        drop(log1);
1068        let log2 = DecisionLog::open_with_chain(&path, b"root").unwrap();
1069        let id2 = log2.chain_id().unwrap();
1070        assert_eq!(id1, id2, "chain_id must persist across restarts");
1071    }
1072
1073    #[test]
1074    fn chained_log_recovers_identity_when_sidecar_is_missing() {
1075        let dir = tempfile::tempdir().unwrap();
1076        let path = dir.path().join("recover-sidecar.jsonl");
1077        let sidecar = chain_id_sidecar_path(&path);
1078        let record = |id: &str| DecisionRecord {
1079            id: id.into(),
1080            timestamp: chrono::Utc::now(),
1081            effect: "allow".into(),
1082            policies: vec![],
1083            request_id: None,
1084            principal: "alice".into(),
1085            action: "read".into(),
1086            resource: "document".into(),
1087            reasons: vec![],
1088            session_id: None,
1089            agent_chain: None,
1090            trace_id: None,
1091            span_id: None,
1092            tenant_id: None,
1093            subject_id: None,
1094            authenticated_actor: None,
1095        };
1096
1097        let original_id = {
1098            let log = DecisionLog::open_with_chain(&path, b"root").unwrap();
1099            log.append(&record("before-recovery")).unwrap();
1100            log.chain_id().unwrap()
1101        };
1102        std::fs::remove_file(&sidecar).unwrap();
1103
1104        let recovered = DecisionLog::open_with_chain(&path, b"root").unwrap();
1105        assert_eq!(recovered.chain_id(), Some(original_id));
1106        assert!(
1107            sidecar.exists(),
1108            "recovery must persist the restored identity"
1109        );
1110        recovered.append(&record("after-recovery")).unwrap();
1111        drop(recovered);
1112
1113        assert_eq!(
1114            DecisionLog::verify_chain(&path, b"root").unwrap(),
1115            original_id
1116        );
1117        assert_eq!(DecisionLog::read_all(&path).unwrap().len(), 2);
1118    }
1119
1120    #[test]
1121    fn chained_log_recovers_identity_from_rotated_segment_without_sidecar() {
1122        let dir = tempfile::tempdir().unwrap();
1123        let path = dir.path().join("recover-rotated-sidecar.jsonl");
1124        let record = |id: &str| DecisionRecord {
1125            id: id.into(),
1126            timestamp: chrono::Utc::now(),
1127            effect: "allow".into(),
1128            policies: vec![],
1129            request_id: None,
1130            principal: "alice".into(),
1131            action: "read".into(),
1132            resource: "document".into(),
1133            reasons: vec![],
1134            session_id: None,
1135            agent_chain: None,
1136            trace_id: None,
1137            span_id: None,
1138            tenant_id: None,
1139            subject_id: None,
1140            authenticated_actor: None,
1141        };
1142
1143        let original_id = {
1144            let log = DecisionLog::open_with_chain(&path, b"root").unwrap();
1145            log.append(&record("before-rotation")).unwrap();
1146            let id = log.chain_id().unwrap();
1147            log.rotate().unwrap();
1148            id
1149        };
1150        let rotated = latest_rotated_log(&path).unwrap().unwrap();
1151        std::fs::remove_file(chain_id_sidecar_path(&rotated)).unwrap();
1152
1153        let recovered = DecisionLog::open_with_chain(&path, b"root").unwrap();
1154        assert_eq!(recovered.chain_id(), Some(original_id));
1155        assert!(
1156            chain_id_sidecar_path(&path).exists(),
1157            "recovery must persist the restored identity for the active segment"
1158        );
1159        recovered.append(&record("after-rotation")).unwrap();
1160        drop(recovered);
1161
1162        assert_eq!(
1163            DecisionLog::verify_chain(&path, b"root").unwrap(),
1164            original_id
1165        );
1166        assert_eq!(DecisionLog::read_all(&path).unwrap().len(), 2);
1167    }
1168
1169    #[test]
1170    fn chained_append_advances_head() {
1171        let dir = tempfile::tempdir().unwrap();
1172        let path = dir.path().join("chained.jsonl");
1173        let log = DecisionLog::open_with_chain(&path, b"root").unwrap();
1174        let rec = DecisionRecord {
1175            id: uuid::Uuid::new_v4().to_string(),
1176            timestamp: chrono::Utc::now(),
1177            effect: "allow".into(),
1178            policies: vec![],
1179            request_id: None,
1180            principal: "alice".into(),
1181            action: "send_email".into(),
1182            resource: "doc-1".into(),
1183            reasons: vec![],
1184            session_id: None,
1185            agent_chain: None,
1186            trace_id: None,
1187            span_id: None,
1188            tenant_id: None,
1189            subject_id: None,
1190            authenticated_actor: None,
1191        };
1192        log.append(&rec).unwrap();
1193        log.append(&rec).unwrap();
1194        // Verify the chain end-to-end.
1195        let id = DecisionLog::verify_chain(&path, b"root").unwrap();
1196        assert_eq!(id, log.chain_id().unwrap());
1197    }
1198
1199    #[test]
1200    fn streaming_chain_verification_handles_a_large_log_and_resumes() {
1201        let dir = tempfile::tempdir().unwrap();
1202        let path = dir.path().join("large-audit.jsonl");
1203        let root_key = b"large-log-test-root";
1204        let chain = HashChain::new(root_key);
1205        let chain_id = chain.id();
1206        let mut writer = BufWriter::new(File::create(&path).unwrap());
1207        let timestamp = chrono::Utc::now();
1208
1209        for index in 0..5_000 {
1210            let record = DecisionRecord {
1211                id: index.to_string(),
1212                timestamp,
1213                effect: "deny".into(),
1214                policies: vec![],
1215                request_id: None,
1216                principal: "load-test-agent".into(),
1217                action: "ToolCall::read".into(),
1218                resource: format!("document-{index}"),
1219                reasons: vec![],
1220                session_id: None,
1221                agent_chain: None,
1222                trace_id: None,
1223                span_id: None,
1224                tenant_id: None,
1225                subject_id: None,
1226                authenticated_actor: None,
1227            };
1228            let canonical = canonical_json(&record).unwrap();
1229            let (prev, hash) = chain.append(&canonical);
1230            serde_json::to_writer(
1231                &mut writer,
1232                &ChainedRecord {
1233                    prev_hash: hex::encode(prev),
1234                    record_hash: hex::encode(hash),
1235                    chain_id,
1236                    record,
1237                },
1238            )
1239            .unwrap();
1240            writer.write_all(b"\n").unwrap();
1241        }
1242        writer.flush().unwrap();
1243        drop(writer);
1244
1245        assert_eq!(
1246            DecisionLog::verify_chain(&path, root_key).unwrap(),
1247            chain_id
1248        );
1249        let resumed = DecisionLog::open_with_chain(&path, root_key).unwrap();
1250        assert_eq!(resumed.chain_id(), Some(chain_id));
1251    }
1252
1253    #[cfg(unix)]
1254    #[test]
1255    fn failed_audit_write_poisoning_is_reported_not_silently_dropped() {
1256        let full = Path::new("/dev/full");
1257        if !full.exists() {
1258            return;
1259        }
1260        let dir = tempfile::tempdir().unwrap();
1261        let rec = DecisionRecord {
1262            id: "storage-failure".into(),
1263            timestamp: chrono::Utc::now(),
1264            effect: "allow".into(),
1265            policies: vec![],
1266            request_id: None,
1267            principal: "alice".into(),
1268            action: "send".into(),
1269            resource: "doc".into(),
1270            reasons: vec![],
1271            session_id: None,
1272            agent_chain: None,
1273            trace_id: None,
1274            span_id: None,
1275            tenant_id: None,
1276            subject_id: None,
1277            authenticated_actor: None,
1278        };
1279
1280        for chained in [false, true] {
1281            let path = dir.path().join(format!("write-failure-{chained}.jsonl"));
1282            let log = if chained {
1283                DecisionLog::open_with_chain(&path, b"test-root").unwrap()
1284            } else {
1285                DecisionLog::open(&path).unwrap()
1286            };
1287            // Inject the special device only after normal startup has
1288            // validated and opened a regular audit file. Reading /dev/full
1289            // yields an unbounded zero stream on Linux, so using it as the
1290            // chained startup path would test an infinite scan, not a failed
1291            // append.
1292            let failing_writer = OpenOptions::new().write(true).open(full).unwrap();
1293            match &log.mode {
1294                LogMode::Plain(file) | LogMode::Chained { file, .. } => {
1295                    *file.lock().unwrap() = Some(failing_writer);
1296                }
1297            }
1298
1299            assert!(
1300                log.append(&rec).is_err(),
1301                "first storage failure must surface"
1302            );
1303            assert!(!log.is_healthy(), "failed writer must become unhealthy");
1304            let retry = log.append(&rec).unwrap_err();
1305            assert!(retry
1306                .to_string()
1307                .contains("unavailable after a prior storage failure"));
1308        }
1309    }
1310
1311    #[cfg(unix)]
1312    #[test]
1313    fn audit_open_rejects_special_files_and_symlinks_before_reading_them() {
1314        let full = Path::new("/dev/full");
1315        if !full.exists() {
1316            return;
1317        }
1318        let plain_error = DecisionLog::open(full)
1319            .err()
1320            .expect("special files must not be accepted as plain audit logs");
1321        assert!(plain_error.to_string().contains("regular file"));
1322
1323        let chained_error = DecisionLog::open_with_chain(full, b"test-root")
1324            .err()
1325            .expect("special files must be rejected before verification");
1326        assert!(chained_error.to_string().contains("regular file"));
1327
1328        let dir = tempfile::tempdir().unwrap();
1329        let link = dir.path().join("audit.jsonl");
1330        std::os::unix::fs::symlink(full, &link).unwrap();
1331        let symlink_error = DecisionLog::open_with_chain(&link, b"test-root")
1332            .err()
1333            .expect("symlinks must be rejected before verification");
1334        assert!(symlink_error.to_string().contains("regular file"));
1335    }
1336
1337    #[cfg(unix)]
1338    #[test]
1339    fn audit_log_permissions_are_private_on_open() {
1340        use std::os::unix::fs::PermissionsExt;
1341
1342        let dir = tempfile::tempdir().unwrap();
1343        let path = dir.path().join("audit.jsonl");
1344        std::fs::write(&path, b"").unwrap();
1345        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1346
1347        let _log = DecisionLog::open(&path).unwrap();
1348
1349        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1350        assert_eq!(
1351            mode, 0o600,
1352            "audit records must not be group/world readable"
1353        );
1354    }
1355
1356    /// T5: read_all handles a mixed-format log (plain + chained
1357    /// records interleaved). Each line is parsed independently.
1358    #[test]
1359    fn read_all_handles_mixed_plain_and_chained() {
1360        let dir = tempfile::tempdir().unwrap();
1361        let path = dir.path().join("mixed.jsonl");
1362        // Write a plain line, then a chained line, then a plain line.
1363        let rec = DecisionRecord {
1364            id: "a".into(),
1365            timestamp: chrono::Utc::now(),
1366            effect: "allow".into(),
1367            policies: vec![],
1368            request_id: None,
1369            principal: "alice".into(),
1370            action: "send".into(),
1371            resource: "doc".into(),
1372            reasons: vec![],
1373            session_id: None,
1374            agent_chain: None,
1375            trace_id: None,
1376            span_id: None,
1377            tenant_id: None,
1378            subject_id: None,
1379            authenticated_actor: None,
1380        };
1381        // Build one plain and one chained line independently, then combine
1382        // them. A chained writer must reject an existing plain tail; mixed
1383        // logs remain supported for read-only inspection/export.
1384        {
1385            let log = DecisionLog::open(&path).unwrap();
1386            log.append(&rec).unwrap();
1387        }
1388        {
1389            let chained_path = dir.path().join("chained-only.jsonl");
1390            let log = DecisionLog::open_with_chain(&chained_path, b"root").unwrap();
1391            log.append(&rec).unwrap();
1392            let chained_line = std::fs::read_to_string(chained_path).unwrap();
1393            std::fs::OpenOptions::new()
1394                .append(true)
1395                .open(&path)
1396                .unwrap()
1397                .write_all(chained_line.as_bytes())
1398                .unwrap();
1399        }
1400        {
1401            let log = DecisionLog::open(&path).unwrap();
1402            log.append(&rec).unwrap();
1403        }
1404        let records = DecisionLog::read_all(&path).unwrap();
1405        assert_eq!(records.len(), 3, "all 3 records must be readable");
1406        for r in &records {
1407            assert_eq!(r.principal, "alice");
1408        }
1409    }
1410
1411    #[test]
1412    fn read_all_mixed_surfaces_both_errors() {
1413        let dir = tempfile::tempdir().unwrap();
1414        let path = dir.path().join("corrupt.jsonl");
1415        // One valid plain record, then a truncated line that is neither
1416        // a DecisionRecord nor a ChainedRecord.
1417        let rec = DecisionRecord {
1418            id: "a".into(),
1419            timestamp: chrono::Utc::now(),
1420            effect: "allow".into(),
1421            policies: vec![],
1422            request_id: None,
1423            principal: "alice".into(),
1424            action: "send".into(),
1425            resource: "doc".into(),
1426            reasons: vec![],
1427            session_id: None,
1428            agent_chain: None,
1429            trace_id: None,
1430            span_id: None,
1431            tenant_id: None,
1432            subject_id: None,
1433            authenticated_actor: None,
1434        };
1435        let log = DecisionLog::open(&path).unwrap();
1436        log.append(&rec).unwrap();
1437        // Append a corrupt line.
1438        std::fs::OpenOptions::new()
1439            .append(true)
1440            .open(&path)
1441            .unwrap()
1442            .write_all(b"{\"this\":\"is broken")
1443            .unwrap();
1444        let err = DecisionLog::read_all(&path).unwrap_err();
1445        let msg = err.to_string();
1446        assert!(
1447            msg.contains("line 2"),
1448            "error must reference the offending line number: {msg}"
1449        );
1450        assert!(
1451            msg.contains("DecisionRecord"),
1452            "error must mention the DecisionRecord parse failure: {msg}"
1453        );
1454        assert!(
1455            msg.contains("ChainedRecord"),
1456            "error must mention the ChainedRecord parse failure: {msg}"
1457        );
1458    }
1459
1460    #[test]
1461    fn rotation_config_requires_a_positive_integer() {
1462        assert_eq!(
1463            RotationConfig::parse("1048576").unwrap().max_bytes,
1464            1_048_576
1465        );
1466        for value in ["0", "-1", "many", ""] {
1467            assert!(RotationConfig::parse(value).is_err(), "accepted {value:?}");
1468        }
1469    }
1470
1471    #[test]
1472    fn rotation_creates_new_file() {
1473        let dir = tempfile::tempdir().unwrap();
1474        let path = dir.path().join("rotating.jsonl");
1475        let log =
1476            DecisionLog::open_with_rotation(&path, None::<&[u8]>, RotationConfig { max_bytes: 64 })
1477                .unwrap();
1478        let rec = DecisionRecord {
1479            id: "a".into(),
1480            timestamp: chrono::Utc::now(),
1481            effect: "allow".into(),
1482            policies: vec![],
1483            request_id: None,
1484            principal: "alice".into(),
1485            action: "send".into(),
1486            resource: "doc".into(),
1487            reasons: vec![],
1488            session_id: None,
1489            agent_chain: None,
1490            trace_id: None,
1491            span_id: None,
1492            tenant_id: None,
1493            subject_id: None,
1494            authenticated_actor: None,
1495        };
1496        for _ in 0..5 {
1497            log.append(&rec).unwrap();
1498        }
1499        // 5 records * ~300 bytes each > 64 bytes threshold; at least
1500        // one rotation must have happened.
1501        let rotated: Vec<_> = std::fs::read_dir(dir.path())
1502            .unwrap()
1503            .filter_map(|e| e.ok())
1504            .filter(|e| e.file_name().to_string_lossy().starts_with("rotating-"))
1505            .collect();
1506        assert!(
1507            !rotated.is_empty(),
1508            "expected at least one rotated file in {}",
1509            dir.path().display()
1510        );
1511        assert!(path.exists(), "active file must still be open");
1512    }
1513
1514    #[test]
1515    fn rotated_log_discovery_ignores_lookalike_files() {
1516        let dir = tempfile::tempdir().unwrap();
1517        let active = dir.path().join("decisions.jsonl");
1518        let real = dir.path().join("decisions-20260923T123456000000Z.jsonl");
1519        std::fs::write(dir.path().join("decisions-backup.jsonl"), b"not a rotation").unwrap();
1520        std::fs::write(
1521            dir.path().join("decisions-20260923T123456Z.jsonl"),
1522            b"bad timestamp",
1523        )
1524        .unwrap();
1525        std::fs::write(&real, b"rotation").unwrap();
1526
1527        assert_eq!(rotated_logs(&active).unwrap(), vec![real]);
1528    }
1529
1530    #[test]
1531    fn rotated_log_discovery_orders_collision_suffixes_numerically() {
1532        let dir = tempfile::tempdir().unwrap();
1533        let active = dir.path().join("decisions.jsonl");
1534        let expected = [
1535            "decisions-20260923T123456000000Z.jsonl",
1536            "decisions-20260923T123456000000Z-2.jsonl",
1537            "decisions-20260923T123456000000Z-10.jsonl",
1538        ]
1539        .map(|name| dir.path().join(name));
1540        for path in expected.iter().rev() {
1541            std::fs::write(path, b"segment").unwrap();
1542        }
1543
1544        assert_eq!(rotated_logs(&active).unwrap(), expected);
1545    }
1546
1547    #[test]
1548    fn chained_rotation_recovers_head_after_restart_before_next_append() {
1549        let dir = tempfile::tempdir().unwrap();
1550        let path = dir.path().join("chained.jsonl");
1551        let rec = DecisionRecord {
1552            id: "a".into(),
1553            timestamp: chrono::Utc::now(),
1554            effect: "allow".into(),
1555            policies: vec![],
1556            request_id: None,
1557            principal: "alice".into(),
1558            action: "send".into(),
1559            resource: "doc".into(),
1560            reasons: vec![],
1561            session_id: None,
1562            agent_chain: None,
1563            trace_id: None,
1564            span_id: None,
1565            tenant_id: None,
1566            subject_id: None,
1567            authenticated_actor: None,
1568        };
1569
1570        let first_id = {
1571            let log = DecisionLog::open_with_rotation(
1572                &path,
1573                Some(b"root"),
1574                RotationConfig {
1575                    max_bytes: u64::MAX,
1576                },
1577            )
1578            .unwrap();
1579            log.append(&rec).unwrap();
1580            let id = log.chain_id().unwrap();
1581            log.rotate().unwrap();
1582            id
1583        };
1584
1585        // The process has restarted while the active file is still empty.
1586        let log = DecisionLog::open_with_rotation(
1587            &path,
1588            Some(b"root"),
1589            RotationConfig {
1590                max_bytes: u64::MAX,
1591            },
1592        )
1593        .unwrap();
1594        assert_eq!(log.chain_id().unwrap(), first_id);
1595        log.append(&rec).unwrap();
1596
1597        let rotated = latest_rotated_log(&path).unwrap().unwrap();
1598        assert_eq!(
1599            read_chain_id_sidecar(&chain_id_sidecar_path(&rotated)),
1600            Some(first_id)
1601        );
1602        assert_eq!(
1603            read_chain_id_sidecar(&chain_id_sidecar_path(&path)),
1604            Some(first_id)
1605        );
1606        let before = DecisionLog::read_all_chained(&rotated).unwrap();
1607        let after = DecisionLog::read_all_chained(&path).unwrap();
1608        assert_eq!(before.len(), 1);
1609        assert_eq!(after.len(), 1);
1610        assert_eq!(before[0].chain_id, after[0].chain_id);
1611        assert_eq!(before[0].record_hash, after[0].prev_hash);
1612        assert_eq!(DecisionLog::verify_chain(&path, b"root").unwrap(), first_id);
1613        assert_eq!(DecisionLog::read_all(&path).unwrap().len(), 2);
1614    }
1615
1616    #[test]
1617    fn concurrent_rotation_does_not_lose_appends() {
1618        let dir = tempfile::tempdir().unwrap();
1619        let path = dir.path().join("concurrent.jsonl");
1620        let log = std::sync::Arc::new(
1621            DecisionLog::open_with_rotation(&path, None::<&[u8]>, RotationConfig { max_bytes: 64 })
1622                .unwrap(),
1623        );
1624        let rec = DecisionRecord {
1625            id: "concurrent".into(),
1626            timestamp: chrono::Utc::now(),
1627            effect: "allow".into(),
1628            policies: vec![],
1629            request_id: None,
1630            principal: "alice".into(),
1631            action: "send".into(),
1632            resource: "doc".into(),
1633            reasons: vec![],
1634            session_id: None,
1635            agent_chain: None,
1636            trace_id: None,
1637            span_id: None,
1638            tenant_id: None,
1639            subject_id: None,
1640            authenticated_actor: None,
1641        };
1642        let mut workers = Vec::new();
1643        for _ in 0..4 {
1644            let log = std::sync::Arc::clone(&log);
1645            let rec = rec.clone();
1646            workers.push(std::thread::spawn(move || {
1647                for _ in 0..25 {
1648                    log.append(&rec).unwrap();
1649                }
1650            }));
1651        }
1652        for worker in workers {
1653            worker.join().unwrap();
1654        }
1655
1656        let total_records: usize = std::fs::read_dir(dir.path())
1657            .unwrap()
1658            .filter_map(|entry| entry.ok())
1659            .filter(|entry| entry.file_name().to_string_lossy().ends_with(".jsonl"))
1660            .map(|entry| {
1661                std::fs::read_to_string(entry.path())
1662                    .unwrap()
1663                    .lines()
1664                    .filter(|line| !line.trim().is_empty())
1665                    .count()
1666            })
1667            .sum();
1668        assert_eq!(total_records, 100);
1669    }
1670}