Audit writer: open a record-less log, share the log-name rule, keep the lock file
M3a review findings 1, 2, 4 and part of 5. One empty log file made brokerd panic at startup (files[len - 2]); it now opens as an empty log. brokerd's name check tested one month digit, so a file bxctl ignored could become brokerd's latest file; both now use proto::is_audit_log_name. Writer no longer unlinks audit/.lock, which opened a two-writer window. No unwrap in short_check. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+26
-45
@@ -11,7 +11,8 @@ use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use proto::{
|
||||
AuditEvent, AuditRecord, ChainFailure, ChainReport, ChainVerifier, Hash32, Timestamp, sha256,
|
||||
AuditEvent, AuditRecord, ChainFailure, ChainReport, ChainVerifier, Hash32, Timestamp,
|
||||
is_audit_log_name, sha256,
|
||||
};
|
||||
|
||||
/// The notice appended to the log when a torn final line is recovered, as the RUNBOOK entry names.
|
||||
@@ -83,21 +84,6 @@ impl From<io::Error> for AuditError {
|
||||
}
|
||||
}
|
||||
|
||||
/// A name is a log file when it is `<YYYY-MM-DD>.jsonl`: 16 chars, `-` at 4 and 7, `.jsonl` at 10
|
||||
/// to 15, digits elsewhere.
|
||||
fn is_log_name(name: &str) -> bool {
|
||||
let b = name.as_bytes();
|
||||
if b.len() != 16 {
|
||||
return false;
|
||||
}
|
||||
b[4] == b'-'
|
||||
&& b[7] == b'-'
|
||||
&& &b[10..16] == b".jsonl"
|
||||
&& b[0..4].iter().all(|c| c.is_ascii_digit())
|
||||
&& b[5..6].iter().all(|c| c.is_ascii_digit())
|
||||
&& b[8..10].iter().all(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// The log file name for a record's time: the day of the timestamp, `.jsonl`.
|
||||
fn day_name(time: Timestamp) -> String {
|
||||
let when = time.to_rfc3339();
|
||||
@@ -138,7 +124,7 @@ fn log_files(dir: &Path) -> Result<Vec<String>, AuditError> {
|
||||
let entry = entry.map_err(|e| io("read audit log", dir, e))?;
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if is_log_name(&name) {
|
||||
if is_audit_log_name(&name) {
|
||||
names.push(name.into_owned());
|
||||
}
|
||||
}
|
||||
@@ -162,11 +148,9 @@ pub fn verify_dir(dir: &Path) -> Result<ChainReport, AuditError> {
|
||||
/// is a record. Otherwise the whole log is verified.
|
||||
fn short_check(dir: &Path) -> Result<ChainReport, AuditError> {
|
||||
let files = log_files(dir)?;
|
||||
if files.len() < 2 {
|
||||
let (Some(latest), Some(before)) = (files.last(), files.iter().rev().nth(1)) else {
|
||||
return verify_dir(dir);
|
||||
}
|
||||
let latest = files.last().unwrap();
|
||||
let before = &files[files.len() - 2];
|
||||
};
|
||||
let path_before = dir.join(before);
|
||||
let bytes = fs::read(&path_before).map_err(|e| io("read audit log", &path_before, e))?;
|
||||
let line = match last_line(&bytes) {
|
||||
@@ -192,6 +176,21 @@ fn short_check(dir: &Path) -> Result<ChainReport, AuditError> {
|
||||
Ok(verifier.finish())
|
||||
}
|
||||
|
||||
/// The hash of the last line of the file before the latest, or zero if there is no such file or
|
||||
/// it has no line.
|
||||
fn tail_of_file_before_latest(dir: &Path) -> Result<Hash32, AuditError> {
|
||||
let files = log_files(dir)?;
|
||||
let Some(before) = files.iter().rev().nth(1) else {
|
||||
return Ok(Hash32::ZERO);
|
||||
};
|
||||
let path = dir.join(before);
|
||||
let bytes = fs::read(&path).map_err(|e| io("read audit log", &path, e))?;
|
||||
match last_line(&bytes) {
|
||||
Some(line) => Ok(sha256(line).map_err(io::Error::other)?),
|
||||
None => Ok(Hash32::ZERO),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one record: serialise it, open the target file, write the line, and return the seq and
|
||||
/// the hash of the line it wrote. A fresh file is created mode 0600 and the directory synced.
|
||||
fn write_record(
|
||||
@@ -251,7 +250,8 @@ impl std::fmt::Debug for Lock {
|
||||
/// refused.
|
||||
#[derive(Debug)]
|
||||
pub struct Writer {
|
||||
lock_file: Lock,
|
||||
/// Held, never read: closing the file is what releases the lock. The file itself stays.
|
||||
_lock: Lock,
|
||||
path: PathBuf,
|
||||
latest: Option<String>,
|
||||
prev: Hash32,
|
||||
@@ -336,22 +336,11 @@ impl Writer {
|
||||
)?;
|
||||
(hash, seq.checked_add(1).unwrap_or(report.next_seq))
|
||||
} else {
|
||||
// No record verified: the chain goes on from the last line of the file before the
|
||||
// latest, or starts at zero when there is none (a log whose only file is empty).
|
||||
let prev = match report.head {
|
||||
Some(head) => head,
|
||||
None => {
|
||||
let files = log_files(dir)?;
|
||||
if files.is_empty() {
|
||||
Hash32::ZERO
|
||||
} else {
|
||||
let before = &files[files.len() - 2];
|
||||
let path = dir.join(before);
|
||||
let bytes = fs::read(&path).map_err(|e| io("read audit log", &path, e))?;
|
||||
match last_line(&bytes) {
|
||||
Some(line) => sha256(line).map_err(io::Error::other)?,
|
||||
None => Hash32::ZERO,
|
||||
}
|
||||
}
|
||||
}
|
||||
None => tail_of_file_before_latest(dir)?,
|
||||
};
|
||||
(prev, report.next_seq)
|
||||
};
|
||||
@@ -359,7 +348,7 @@ impl Writer {
|
||||
// 10. Hand back the writer.
|
||||
Ok(Opened {
|
||||
writer: Writer {
|
||||
lock_file: lock,
|
||||
_lock: lock,
|
||||
path: dir.to_path_buf(),
|
||||
latest,
|
||||
prev,
|
||||
@@ -406,14 +395,6 @@ impl Writer {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Writer {
|
||||
fn drop(&mut self) {
|
||||
// This field holds the .lock handle open, so the lock lasts as long as the writer.
|
||||
let _ = &self.lock_file;
|
||||
let _ = fs::remove_file(self.path.join(".lock"));
|
||||
}
|
||||
}
|
||||
|
||||
/// What `open` hands back: the writer plus what it recovered or accepted.
|
||||
#[derive(Debug)]
|
||||
pub struct Opened {
|
||||
|
||||
Reference in New Issue
Block a user