use std::collections::BTreeMap;
use std::fs::{File, OpenOptions};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use anyhow::Context as _;
use serde::{Deserialize, Serialize};
use sha2::{Digest as _, Sha256};
pub const SCHEMA_VERSION: u32 = 2;
const FAILURE_LOG_INTERVAL: Duration = Duration::from_secs(60);
const MAX_ROTATE_KEEP: u32 = 10_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailMode {
Open,
ClosedWritesDenials,
ClosedAll,
}
fn default_rotate_max_bytes() -> u64 {
64 * 1024 * 1024
}
fn default_rotate_keep() -> u32 {
8
}
fn default_suppressed_ids() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuditConfig {
pub path: Option<PathBuf>,
#[serde(default)]
pub fsync: bool,
#[serde(default)]
pub fail_mode: Option<FailMode>,
#[serde(default = "default_rotate_max_bytes")]
pub rotate_max_bytes: u64,
#[serde(default = "default_rotate_keep")]
pub rotate_keep: u32,
#[serde(default = "default_suppressed_ids")]
pub suppressed_ids: bool,
}
impl AuditConfig {
#[must_use]
pub fn fileless() -> AuditConfig {
AuditConfig {
path: None,
fsync: false,
fail_mode: None,
rotate_max_bytes: default_rotate_max_bytes(),
rotate_keep: default_rotate_keep(),
suppressed_ids: default_suppressed_ids(),
}
}
pub fn from_toml_str(s: &str) -> anyhow::Result<AuditConfig> {
let cfg: AuditConfig =
toml::from_str(s).context("failed to parse audit configuration TOML")?;
if cfg.path.is_none() {
anyhow::bail!("audit configuration names no `path`");
}
cfg.validate()?;
Ok(cfg)
}
pub fn load(path: &Path) -> anyhow::Result<AuditConfig> {
let s = std::fs::read_to_string(path).with_context(|| {
format!("failed to read audit configuration file {}", path.display())
})?;
Self::from_toml_str(&s)
.with_context(|| format!("invalid audit configuration file {}", path.display()))
}
fn validate(&self) -> anyhow::Result<()> {
if self.rotate_max_bytes > 0 && self.rotate_keep == 0 {
anyhow::bail!(
"rotate_keep must be at least 1 while rotate_max_bytes is non-zero: \
rotation renames the live file to <path>.1, and keeping zero rotated \
files would delete the records it just wrote; set rotate_max_bytes = 0 \
instead to hand rotation to an external tool"
);
}
if self.rotate_keep > MAX_ROTATE_KEEP {
anyhow::bail!(
"rotate_keep must be at most {MAX_ROTATE_KEEP}: every rotation shifts \
each kept file with its own rename syscall while all audit writes \
wait, so a larger window would stall the server; raise \
rotate_max_bytes instead to retain more history"
);
}
Ok(())
}
}
pub const AUDIT_CONFIG_NONE: &str = "none";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SinkSelection {
NoAudit,
FileOnly,
Both,
OtlpOnly,
}
pub fn select_sinks(
audit_config: Option<&Path>,
otlp_configured: bool,
) -> anyhow::Result<SinkSelection> {
let file = match audit_config {
None => None,
Some(path) if path.as_os_str() == AUDIT_CONFIG_NONE => Some(false),
Some(_) => Some(true),
};
match (file, otlp_configured) {
(None, false) => Ok(SinkSelection::NoAudit),
(Some(true), false) => Ok(SinkSelection::FileOnly),
(Some(true), true) => Ok(SinkSelection::Both),
(Some(false), true) => Ok(SinkSelection::OtlpOnly),
(Some(false), false) => anyhow::bail!(
"--audit-config/BUGWARDEN_AUDIT_CONFIG is `{AUDIT_CONFIG_NONE}` but no OTLP \
endpoint is configured, which would leave no audit sink at all; set \
OTEL_EXPORTER_OTLP_ENDPOINT for OTLP-only auditing, or unset \
BUGWARDEN_AUDIT_CONFIG entirely if this deployment is meant to run \
without an audit trail"
),
(None, true) => anyhow::bail!(
"OTEL_EXPORTER_OTLP_ENDPOINT is set, so audit records are exported and \
their delivery gates serving — but no audit file is configured; point \
--audit-config/BUGWARDEN_AUDIT_CONFIG at an audit configuration to run \
file and OTLP together, or set it to `{AUDIT_CONFIG_NONE}` for OTLP-only \
auditing"
),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AuditEvent {
pub v: u32,
pub ts: String,
pub seq: u64,
pub session: SessionInfo,
#[serde(flatten)]
pub kind: AuditEventKind,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum AuditEventKind {
ToolCall(Box<ToolCallEvent>),
Initialize(InitializeEvent),
AuditGap(AuditGapEvent),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolCallEvent {
pub client: ClientInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace: Option<TraceContext>,
pub request: RequestInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub guard: Option<GuardInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub upstream: Option<UpstreamInfo>,
pub outcome: OutcomeInfo,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InitializeEvent {
pub client: ClientInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub protocol_version: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuditGapEvent {
pub dropped: u64,
pub reason: GapReason,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GapReason {
WriteError,
RotationError,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub transport: TransportKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub remote: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TransportKind {
Stdio,
Http,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClientInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub principal: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub work_context: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TraceContext {
pub trace_id: String,
pub span_id: String,
}
impl TraceContext {
pub(crate) fn from_traceparent(value: &str) -> Option<TraceContext> {
fn lower_hex(bytes: &[u8]) -> bool {
bytes
.iter()
.all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f'))
}
let bytes = value.as_bytes();
if bytes.len() != 55 {
return None;
}
if &bytes[0..2] != b"00" {
return None;
}
if bytes[2] != b'-' || bytes[35] != b'-' || bytes[52] != b'-' {
return None;
}
let trace_id = &bytes[3..35];
let span_id = &bytes[36..52];
let flags = &bytes[53..55];
if !lower_hex(trace_id) || !lower_hex(span_id) || !lower_hex(flags) {
return None;
}
if trace_id.iter().all(|&b| b == b'0') || span_id.iter().all(|&b| b == b'0') {
return None;
}
Some(TraceContext {
trace_id: std::str::from_utf8(trace_id).ok()?.to_owned(),
span_id: std::str::from_utf8(span_id).ok()?.to_owned(),
})
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RequestInfo {
pub tool: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub params: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GuardInfo {
pub verdict: Verdict,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub policy_hash: Option<String>,
pub suppressed_ids_count: u64,
pub suppressed_other_count: u64,
pub suppressed_ids: Vec<u64>,
pub redacted_fields: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scan: Option<ScanInfo>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScanInfo {
pub scanned: u64,
pub dropped: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Served,
ServedFiltered,
Denied,
Refused,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct UpstreamInfo {
pub requests: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<u16>,
pub latency_ms: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OutcomeInfo {
pub class: OutcomeClass,
pub duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_bytes: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OutcomeClass {
Ok,
Refused,
Error,
}
#[derive(Debug, thiserror::Error)]
pub enum AuditError {
#[error("audit record serialization failed")]
Serialize(#[source] serde_json::Error),
#[error("audit write failed")]
Write(#[source] std::io::Error),
#[error("audit rotation failed")]
Rotation(#[source] std::io::Error),
#[error("audit export refused the record")]
Export,
}
impl AuditError {
fn gap_reason(&self) -> GapReason {
match self {
AuditError::Serialize(_) | AuditError::Write(_) | AuditError::Export => {
GapReason::WriteError
}
AuditError::Rotation(_) => GapReason::RotationError,
}
}
}
pub trait AuditExport: Send + Sync + std::fmt::Debug {
fn accept(&self, event: &AuditEvent, line: &[u8]) -> Result<(), ExportRefused>;
fn delivery_failing(&self) -> bool;
fn take_lost(&self) -> u64;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ExportRefused;
#[derive(Debug)]
struct SinkState {
file: Option<File>,
size: u64,
seq: u64,
dropped: u64,
gap_reason: GapReason,
torn: bool,
last_failure_log: Option<Instant>,
}
#[derive(Debug)]
pub struct AuditSink {
cfg: AuditConfig,
state: Mutex<SinkState>,
export: Option<std::sync::Arc<dyn AuditExport>>,
#[cfg(test)]
fail_writes: std::sync::atomic::AtomicBool,
#[cfg(test)]
fail_writes_partial: std::sync::atomic::AtomicBool,
#[cfg(test)]
fail_rotation: std::sync::atomic::AtomicBool,
}
impl AuditSink {
pub fn open(cfg: AuditConfig) -> anyhow::Result<AuditSink> {
cfg.validate()?;
let (file, size) = match &cfg.path {
None => (None, 0),
Some(path) => {
if let Ok(meta) = std::fs::symlink_metadata(path) {
if meta.file_type().is_symlink() {
anyhow::bail!(
"audit file {} is a symlink; refusing to follow it — point \
`path` at a regular file in an operator-owned directory",
path.display()
);
}
}
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
builder.mode(0o700);
}
builder.create(parent).with_context(|| {
format!("failed to create audit directory {}", parent.display())
})?;
}
}
let file = open_live_file(path)
.with_context(|| format!("failed to open audit file {}", path.display()))?;
let size = file
.metadata()
.with_context(|| format!("failed to stat audit file {}", path.display()))?
.len();
(Some(file), size)
}
};
Ok(AuditSink {
cfg,
export: None,
state: Mutex::new(SinkState {
file,
size,
seq: 0,
dropped: 0,
gap_reason: GapReason::WriteError,
torn: false,
last_failure_log: None,
}),
#[cfg(test)]
fail_writes: std::sync::atomic::AtomicBool::new(false),
#[cfg(test)]
fail_writes_partial: std::sync::atomic::AtomicBool::new(false),
#[cfg(test)]
fail_rotation: std::sync::atomic::AtomicBool::new(false),
})
}
#[must_use]
pub fn with_export(mut self, export: std::sync::Arc<dyn AuditExport>) -> AuditSink {
self.export = Some(export);
self
}
pub fn record(&self, kind: AuditEventKind, session: SessionInfo) -> Result<u64, AuditError> {
debug_assert!(
self.cfg.path.is_some() || self.export.is_some(),
"a fileless audit sink must have an exporter; records would vanish"
);
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.absorb_export_losses(&mut state);
if state.dropped > 0 {
let gap = AuditEventKind::AuditGap(AuditGapEvent {
dropped: state.dropped,
reason: state.gap_reason,
});
match self.write_event(&mut state, gap, session.clone()) {
Ok(_) => state.dropped = 0,
Err(err) => {
state.dropped = state.dropped.saturating_add(1);
state.gap_reason = err.gap_reason();
self.log_failure(&mut state, &err);
return Err(err);
}
}
}
match self.write_event(&mut state, kind, session) {
Ok(seq) => Ok(seq),
Err(err) => {
state.dropped = state.dropped.saturating_add(1);
state.gap_reason = err.gap_reason();
self.log_failure(&mut state, &err);
Err(err)
}
}
}
fn write_event(
&self,
state: &mut SinkState,
kind: AuditEventKind,
session: SessionInfo,
) -> Result<u64, AuditError> {
let seq = state.seq + 1;
let event = AuditEvent {
v: SCHEMA_VERSION,
ts: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
seq,
session,
kind,
};
let mut line = Vec::new();
let healed = state.torn;
if healed {
line.push(b'\n');
}
serde_json::to_writer(&mut line, &event).map_err(AuditError::Serialize)?;
line.push(b'\n');
let len = line.len() as u64;
if self.cfg.rotate_max_bytes > 0
&& state.size > 0
&& state.size + len > self.cfg.rotate_max_bytes
{
self.rotate(state).map_err(AuditError::Rotation)?;
}
#[cfg(test)]
if self.fail_writes.load(std::sync::atomic::Ordering::Relaxed) {
return Err(AuditError::Write(std::io::Error::other(
"injected write failure (test)",
)));
}
#[cfg(test)]
if self
.fail_writes_partial
.load(std::sync::atomic::Ordering::Relaxed)
{
if let Some(file) = state.file.as_mut() {
file.write_all(&line[..line.len() / 2])
.map_err(AuditError::Write)?;
}
state.torn = true;
return Err(AuditError::Write(std::io::Error::other(
"injected partial write failure (test)",
)));
}
if let Some(file) = state.file.as_mut() {
file.write_all(&line).map_err(|e| {
state.torn = true;
AuditError::Write(e)
})?;
if self.cfg.fsync {
file.sync_data().map_err(|e| {
state.torn = true;
AuditError::Write(e)
})?;
}
state.size += len;
}
state.torn = false;
state.seq = seq;
if let Some(export) = &self.export {
let start = usize::from(healed);
export
.accept(&event, &line[start..line.len() - 1])
.map_err(|ExportRefused| AuditError::Export)?;
}
Ok(seq)
}
fn rotate(&self, state: &mut SinkState) -> std::io::Result<()> {
#[cfg(test)]
if self
.fail_rotation
.load(std::sync::atomic::Ordering::Relaxed)
{
return Err(std::io::Error::other("injected rotation failure (test)"));
}
let Some(live) = &self.cfg.path else {
return Ok(());
};
match std::fs::remove_file(rotated_path(live, self.cfg.rotate_keep)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
for n in (2..=self.cfg.rotate_keep).rev() {
match std::fs::rename(rotated_path(live, n - 1), rotated_path(live, n)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
}
match std::fs::rename(live, rotated_path(live, 1)) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
state.file = Some(open_live_file(live)?);
state.size = 0;
#[cfg(unix)]
if let Some(parent) = live.parent() {
let dir = if parent.as_os_str().is_empty() {
Path::new(".")
} else {
parent
};
File::open(dir)?.sync_all()?;
}
Ok(())
}
fn log_failure(&self, state: &mut SinkState, err: &AuditError) {
let due = state
.last_failure_log
.is_none_or(|last| last.elapsed() >= FAILURE_LOG_INTERVAL);
if due {
state.last_failure_log = Some(Instant::now());
tracing::error!(
error = ?err,
dropped = state.dropped,
path = self.cfg.path.as_ref().map(|p| p.display().to_string()),
"audit record could not be persisted; records are being dropped"
);
}
}
pub fn failing(&self) -> bool {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.absorb_export_losses(&mut state);
state.dropped > 0
|| self
.export
.as_ref()
.is_some_and(|export| export.delivery_failing())
}
fn absorb_export_losses(&self, state: &mut SinkState) {
let Some(export) = &self.export else {
return;
};
let lost = export.take_lost();
if lost > 0 {
state.dropped = state.dropped.saturating_add(lost);
state.gap_reason = GapReason::WriteError;
}
}
pub fn suppressed_ids(&self) -> bool {
self.cfg.suppressed_ids
}
#[cfg(test)]
pub(crate) fn set_fail_writes(&self, fail: bool) {
self.fail_writes
.store(fail, std::sync::atomic::Ordering::Relaxed);
}
#[cfg(test)]
fn set_fail_writes_partial(&self, fail: bool) {
self.fail_writes_partial
.store(fail, std::sync::atomic::Ordering::Relaxed);
}
#[cfg(test)]
fn set_fail_rotation(&self, fail: bool) {
self.fail_rotation
.store(fail, std::sync::atomic::Ordering::Relaxed);
}
}
fn open_live_file(path: &Path) -> std::io::Result<File> {
let mut opts = OpenOptions::new();
opts.append(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
opts.mode(0o600);
}
opts.open(path)
}
fn rotated_path(live: &Path, n: u32) -> PathBuf {
let mut os = live.as_os_str().to_os_string();
os.push(format!(".{n}"));
PathBuf::from(os)
}
impl FailMode {
pub fn resolve(explicit: Option<FailMode>, transport: TransportKind) -> FailMode {
explicit.unwrap_or_else(|| FailMode::default_for(transport))
}
pub fn default_for(transport: TransportKind) -> FailMode {
match transport {
TransportKind::Stdio => FailMode::Open,
TransportKind::Http => FailMode::ClosedAll,
}
}
}
#[derive(Debug)]
pub struct AuditState {
pub sink: AuditSink,
pub fail_mode: FailMode,
pub policy_hash: Option<String>,
pub stdio_session_id: String,
}
pub fn policy_hash_of(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let digest = Sha256::digest(bytes);
let mut hash = String::with_capacity("sha256:".len() + digest.len() * 2);
hash.push_str("sha256:");
for byte in &digest {
let _ = write!(hash, "{byte:02x}");
}
hash
}
impl AuditState {
pub fn new(sink: AuditSink, fail_mode: FailMode, policy_hash: Option<String>) -> AuditState {
let epoch_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
AuditState {
sink,
fail_mode,
policy_hash,
stdio_session_id: format!("{}-{epoch_secs}", std::process::id()),
}
}
}
fn verdict_rank(verdict: Verdict) -> u8 {
match verdict {
Verdict::Served => 0,
Verdict::ServedFiltered => 1,
Verdict::Refused => 2,
Verdict::Denied => 3,
}
}
#[derive(Debug, Default)]
struct CellState {
verdict: Option<Verdict>,
rule: Option<String>,
suppressed_ids: std::collections::BTreeSet<u64>,
suppressed_extra: u64,
redacted_fields: std::collections::BTreeSet<String>,
scan: Option<ScanInfo>,
upstream: Option<UpstreamInfo>,
}
#[derive(Debug, Default)]
pub struct AuditCell {
state: Mutex<CellState>,
}
impl AuditCell {
fn lock(&self) -> std::sync::MutexGuard<'_, CellState> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn merge(&self, verdict: Verdict, rule: Option<&str>) {
self.merge_inner(verdict, rule, false);
}
fn merge_inner(&self, verdict: Verdict, rule: Option<&str>, keep_rule: bool) {
let mut state = self.lock();
let rank = verdict_rank(verdict);
match state.verdict.map(verdict_rank) {
Some(current) if rank < current => {}
Some(current) if rank == current => {
if state.rule.is_none() && !keep_rule {
state.rule = rule.map(str::to_owned);
}
}
_ => {
state.verdict = Some(verdict);
if !keep_rule {
state.rule = rule.map(str::to_owned);
}
}
}
}
pub fn note_verdict(&self, verdict: Verdict) {
self.merge(verdict, None);
}
pub fn note_verdict_rule(&self, verdict: Verdict, rule: &str) {
self.merge(verdict, Some(rule));
}
pub fn note_suppressed(&self, ids: impl IntoIterator<Item = u64>) {
self.merge(Verdict::ServedFiltered, None);
self.lock().suppressed_ids.extend(ids);
}
pub fn note_suppressed_count(&self, n: u64) {
if n == 0 {
return;
}
self.merge(Verdict::ServedFiltered, None);
let mut state = self.lock();
state.suppressed_extra = state.suppressed_extra.saturating_add(n);
}
pub fn note_scan(&self, scanned: u64, dropped: u64) {
if dropped > 0 {
self.merge(Verdict::ServedFiltered, None);
}
self.lock().scan = Some(ScanInfo { scanned, dropped });
}
pub fn note_redacted(&self, field: &str) {
self.merge(Verdict::ServedFiltered, None);
self.lock().redacted_fields.insert(field.to_owned());
}
pub fn note_redacted_client_window(&self, field: &str) {
self.merge_inner(Verdict::ServedFiltered, None, true);
self.lock().redacted_fields.insert(field.to_owned());
}
pub fn note_upstream(&self, upstream: UpstreamInfo) {
self.lock().upstream = Some(upstream);
}
pub fn take_upstream(&self) -> Option<UpstreamInfo> {
self.lock().upstream.take()
}
pub fn into_guard_info(
&self,
policy_hash: Option<&str>,
suppressed_ids_cfg: bool,
) -> Option<GuardInfo> {
let state = std::mem::take(&mut *self.lock());
let verdict = state.verdict?;
let suppressed_ids_count = state.suppressed_ids.len() as u64;
Some(GuardInfo {
verdict,
rule: state.rule,
policy_hash: policy_hash.map(str::to_owned),
suppressed_ids_count,
suppressed_other_count: state.suppressed_extra,
suppressed_ids: if suppressed_ids_cfg {
state.suppressed_ids.into_iter().collect()
} else {
Vec::new()
},
redacted_fields: state.redacted_fields.into_iter().collect(),
scan: state.scan,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const TS: &str = "2026-02-03T04:05:06.789Z";
fn session_stdio() -> SessionInfo {
SessionInfo {
id: None,
transport: TransportKind::Stdio,
remote: None,
}
}
fn session_http() -> SessionInfo {
SessionInfo {
id: Some("sess-1".into()),
transport: TransportKind::Http,
remote: Some("192.0.2.7:52611".into()),
}
}
fn sample_tool_call() -> AuditEventKind {
AuditEventKind::ToolCall(Box::new(ToolCallEvent {
client: ClientInfo {
name: Some("example-agent".into()),
version: Some("1.4.2".into()),
principal: Some("alice@example.org".into()),
work_context: Some("SUSE:Maintenance:12345:678901".into()),
},
trace: Some(TraceContext {
trace_id: "0af7651916cd43dd8448eb211c80319c".into(),
span_id: "b7ad6b7169203331".into(),
}),
request: RequestInfo {
tool: "bug_info".into(),
id: Some("3".into()),
params: BTreeMap::from([
("id".to_string(), json!(1290042)),
("include_private".to_string(), json!(false)),
]),
},
guard: Some(GuardInfo {
verdict: Verdict::ServedFiltered,
rule: Some("group-restricted".into()),
policy_hash: Some("2b6e8f5d1c9a4e70".into()),
suppressed_ids_count: 2,
suppressed_other_count: 3,
suppressed_ids: vec![1290040, 1290041],
redacted_fields: vec!["cc".into(), "flags".into()],
scan: Some(ScanInfo {
scanned: 200,
dropped: 2,
}),
}),
upstream: Some(UpstreamInfo {
requests: 1,
status: Some(200),
latency_ms: 48,
}),
outcome: OutcomeInfo {
class: OutcomeClass::Ok,
duration_ms: 52,
response_bytes: Some(1462),
},
}))
}
fn sample_initialize() -> AuditEventKind {
AuditEventKind::Initialize(InitializeEvent {
client: ClientInfo {
name: Some("example-agent".into()),
version: Some("1.4.2".into()),
principal: None,
work_context: None,
},
protocol_version: Some("2025-06-18".into()),
})
}
fn sample_gap() -> AuditEventKind {
AuditEventKind::AuditGap(AuditGapEvent {
dropped: 3,
reason: GapReason::WriteError,
})
}
fn envelope(seq: u64, session: SessionInfo, kind: AuditEventKind) -> AuditEvent {
AuditEvent {
v: SCHEMA_VERSION,
ts: TS.to_string(),
seq,
session,
kind,
}
}
fn test_cfg(path: PathBuf) -> AuditConfig {
AuditConfig {
path: Some(path),
fsync: false,
fail_mode: None,
rotate_max_bytes: 0,
rotate_keep: 8,
suppressed_ids: true,
}
}
fn live_path(cfg: &AuditConfig) -> &Path {
cfg.path.as_deref().expect("test config names a file")
}
fn read_events(path: &Path) -> Vec<AuditEvent> {
let s = std::fs::read_to_string(path).expect("audit file must be readable");
s.lines()
.filter(|l| !l.is_empty())
.map(|l| serde_json::from_str(l).expect("every audit line must parse as AuditEvent"))
.collect()
}
fn assert_files_within_rotate_limit(cfg: &AuditConfig) {
let mut paths = vec![live_path(cfg).to_path_buf()];
paths.extend((1..=cfg.rotate_keep).map(|n| rotated_path(live_path(cfg), n)));
for p in paths {
if let Ok(meta) = std::fs::metadata(&p) {
assert!(
meta.len() <= cfg.rotate_max_bytes,
"{} is {} bytes, over rotate_max_bytes = {}",
p.display(),
meta.len(),
cfg.rotate_max_bytes
);
}
}
}
const GOLDEN_TOOL_CALL: &str = concat!(
r#"{"v":2,"ts":"2026-02-03T04:05:06.789Z","seq":7,"#,
r#""session":{"id":"sess-1","transport":"http","remote":"192.0.2.7:52611"},"#,
r#""event":"tool_call","#,
r#""client":{"name":"example-agent","version":"1.4.2","principal":"alice@example.org","work_context":"SUSE:Maintenance:12345:678901"},"#,
r#""trace":{"trace_id":"0af7651916cd43dd8448eb211c80319c","span_id":"b7ad6b7169203331"},"#,
r#""request":{"tool":"bug_info","id":"3","params":{"id":1290042,"include_private":false}},"#,
r#""guard":{"verdict":"served_filtered","rule":"group-restricted","policy_hash":"2b6e8f5d1c9a4e70","#,
r#""suppressed_ids_count":2,"suppressed_other_count":3,"suppressed_ids":[1290040,1290041],"#,
r#""redacted_fields":["cc","flags"],"scan":{"scanned":200,"dropped":2}},"#,
r#""upstream":{"requests":1,"status":200,"latency_ms":48},"#,
r#""outcome":{"class":"ok","duration_ms":52,"response_bytes":1462}}"#,
);
const GOLDEN_TOOL_CALL_V1: &str = concat!(
r#"{"v":1,"ts":"2026-02-03T04:05:06.789Z","seq":7,"#,
r#""session":{"id":"sess-1","transport":"http","remote":"192.0.2.7:52611"},"#,
r#""event":"tool_call","#,
r#""client":{"name":"example-agent","version":"1.4.2","principal":"alice@example.org"},"#,
r#""trace":{"trace_id":"0af7651916cd43dd8448eb211c80319c","span_id":"b7ad6b7169203331"},"#,
r#""request":{"tool":"bug_info","id":"3","params":{"id":1290042,"include_private":false}},"#,
r#""guard":{"verdict":"served_filtered","rule":"group-restricted","policy_hash":"2b6e8f5d1c9a4e70","#,
r#""suppressed_count":2,"suppressed_ids":[1290040,1290041],"redacted_fields":["cc","flags"],"#,
r#""scan":{"scanned":200,"dropped":2}},"#,
r#""upstream":{"requests":1,"status":200,"latency_ms":48},"#,
r#""outcome":{"class":"ok","duration_ms":52,"response_bytes":1462}}"#,
);
const V1_LINES_OUTSIDE_THE_BREAK: [&str; 3] = [
concat!(
r#"{"v":1,"ts":"2026-02-03T04:05:06.789Z","seq":7,"#,
r#""session":{"id":"sess-1","transport":"http","remote":"192.0.2.7:52611"},"#,
r#""event":"tool_call","#,
r#""client":{"name":"example-agent","version":"1.4.2"},"#,
r#""request":{"tool":"bug_url","id":"3","params":{"id":1290042}},"#,
r#""outcome":{"class":"ok","duration_ms":1}}"#,
),
concat!(
r#"{"v":1,"ts":"2026-02-03T04:05:06.789Z","seq":1,"session":{"transport":"stdio"},"#,
r#""event":"initialize","client":{"name":"example-agent","version":"1.4.2"},"#,
r#""protocol_version":"2025-06-18"}"#,
),
concat!(
r#"{"v":1,"ts":"2026-02-03T04:05:06.789Z","seq":9,"session":{"transport":"stdio"},"#,
r#""event":"audit_gap","dropped":3,"reason":"write_error"}"#,
),
];
const GOLDEN_INITIALIZE: &str = concat!(
r#"{"v":2,"ts":"2026-02-03T04:05:06.789Z","seq":1,"session":{"transport":"stdio"},"#,
r#""event":"initialize","client":{"name":"example-agent","version":"1.4.2"},"#,
r#""protocol_version":"2025-06-18"}"#,
);
const GOLDEN_AUDIT_GAP: &str = concat!(
r#"{"v":2,"ts":"2026-02-03T04:05:06.789Z","seq":9,"session":{"transport":"stdio"},"#,
r#""event":"audit_gap","dropped":3,"reason":"write_error"}"#,
);
#[test]
fn golden_tool_call_json() {
let event = envelope(7, session_http(), sample_tool_call());
assert_eq!(serde_json::to_string(&event).unwrap(), GOLDEN_TOOL_CALL);
}
#[test]
fn golden_initialize_json() {
let event = envelope(1, session_stdio(), sample_initialize());
assert_eq!(serde_json::to_string(&event).unwrap(), GOLDEN_INITIALIZE);
}
#[test]
fn golden_audit_gap_json() {
let event = envelope(9, session_stdio(), sample_gap());
assert_eq!(serde_json::to_string(&event).unwrap(), GOLDEN_AUDIT_GAP);
}
#[test]
fn v1_tool_call_with_a_guard_is_rejected() {
let err = serde_json::from_str::<AuditEvent>(GOLDEN_TOOL_CALL_V1)
.expect_err("a v1 tool_call carrying a guard must not parse under v2");
assert!(
err.to_string().contains("suppressed_count"),
"the rejection must name the removed field: {err}"
);
}
#[test]
fn v1_lines_outside_the_break_still_parse_as_v1() {
let parsed: Vec<AuditEvent> = V1_LINES_OUTSIDE_THE_BREAK
.iter()
.map(|line| {
serde_json::from_str(line).unwrap_or_else(|e| {
panic!("a v1 line outside the break stays readable: {line}: {e}")
})
})
.collect();
for event in &parsed {
assert_eq!(event.v, 1, "the version is read back, never rewritten");
}
let AuditEventKind::ToolCall(tc) = &parsed[0].kind else {
panic!("expected a tool_call event");
};
assert_eq!(tc.guard, None, "the guard-less shape is what survives");
assert_eq!(tc.request.tool, "bug_url");
assert!(matches!(parsed[1].kind, AuditEventKind::Initialize(_)));
assert!(matches!(parsed[2].kind, AuditEventKind::AuditGap(_)));
}
#[test]
fn reserved_identity_slots_are_absent_when_none() {
let line = serde_json::to_string(&envelope(1, session_stdio(), sample_initialize()))
.expect("the fixture serializes");
for key in ["principal", "work_context"] {
assert!(
!line.contains(key),
"a reserved slot holding None must emit no key at all: {line}"
);
}
}
#[test]
fn a_written_line_carries_the_current_schema_version() {
let dir = tempfile::tempdir().unwrap();
let cfg = test_cfg(dir.path().join("audit.jsonl"));
let sink = AuditSink::open(cfg.clone()).unwrap();
sink.record(sample_tool_call(), session_http()).unwrap();
let contents = std::fs::read_to_string(live_path(&cfg)).unwrap();
assert!(
contents.starts_with(r#"{"v":2,"#),
"a written record must be stamped v2: {contents}"
);
}
#[test]
fn events_round_trip() {
for event in [
envelope(7, session_http(), sample_tool_call()),
envelope(1, session_stdio(), sample_initialize()),
envelope(9, session_stdio(), sample_gap()),
] {
let s = serde_json::to_string(&event).unwrap();
let back: AuditEvent = serde_json::from_str(&s).unwrap();
assert_eq!(back, event);
}
}
const CANONICAL_TRACEPARENT: &str = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
#[test]
fn traceparent_canonical_value_parses_into_its_ids() {
let trace = TraceContext::from_traceparent(CANONICAL_TRACEPARENT)
.expect("the canonical W3C example must parse");
assert_eq!(trace.trace_id, "0af7651916cd43dd8448eb211c80319c");
assert_eq!(trace.span_id, "b7ad6b7169203331");
}
#[test]
fn traceparent_flags_00_parses_and_flags_are_not_stored() {
let unsampled = CANONICAL_TRACEPARENT.replace("-01", "-00");
let trace = TraceContext::from_traceparent(&unsampled).expect("flags 00 must parse");
assert_eq!(trace.trace_id, "0af7651916cd43dd8448eb211c80319c");
assert_eq!(trace.span_id, "b7ad6b7169203331");
}
#[test]
fn traceparent_rejects_wrong_lengths() {
assert_eq!(TraceContext::from_traceparent(""), None);
let short = &CANONICAL_TRACEPARENT[..54];
assert_eq!(TraceContext::from_traceparent(short), None);
let long = format!("{CANONICAL_TRACEPARENT}0");
assert_eq!(TraceContext::from_traceparent(&long), None);
let huge = "a".repeat(1 << 20);
assert_eq!(TraceContext::from_traceparent(&huge), None);
}
#[test]
fn traceparent_rejects_uppercase_hex() {
let upper_trace = "00-0AF7651916CD43DD8448EB211C80319C-b7ad6b7169203331-01";
assert_eq!(TraceContext::from_traceparent(upper_trace), None);
let upper_span = "00-0af7651916cd43dd8448eb211c80319c-B7AD6B7169203331-01";
assert_eq!(TraceContext::from_traceparent(upper_span), None);
let upper_version = "0A-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
assert_eq!(TraceContext::from_traceparent(upper_version), None);
}
#[test]
fn traceparent_rejects_versions_other_than_00() {
let ff = "ff-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
assert_eq!(TraceContext::from_traceparent(ff), None);
let v01 = "01-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
assert_eq!(TraceContext::from_traceparent(v01), None);
let forward = "01-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01-extra";
assert_eq!(TraceContext::from_traceparent(forward), None);
}
#[test]
fn traceparent_rejects_all_zero_ids() {
let zero_trace = "00-00000000000000000000000000000000-b7ad6b7169203331-01";
assert_eq!(TraceContext::from_traceparent(zero_trace), None);
let zero_span = "00-0af7651916cd43dd8448eb211c80319c-0000000000000000-01";
assert_eq!(TraceContext::from_traceparent(zero_span), None);
}
#[test]
fn traceparent_rejects_non_hex_in_every_field() {
for bad in [
"g0-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"00-gaf7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"00-0af7651916cd43dd8448eb211c80319c-g7ad6b7169203331-01",
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-g1",
" 0-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"00- af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"00-0af7651916cd43dd8448eb211c80319c- 7ad6b7169203331-01",
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331- 1",
] {
assert_eq!(
TraceContext::from_traceparent(bad),
None,
"must reject {bad:?}"
);
}
}
#[test]
fn traceparent_rejects_misplaced_separators() {
for bad in [
"00-0af7651916cd43dd8448eb211c80319cb-7ad6b7169203331-01",
"0000af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331001",
] {
assert_eq!(bad.len(), 55, "fixture must stay 55 bytes");
assert_eq!(
TraceContext::from_traceparent(bad),
None,
"must reject {bad:?}"
);
}
}
#[test]
fn traceparent_rejects_surrounding_whitespace() {
let lead_55 = format!(" {}", &CANONICAL_TRACEPARENT[..54]);
assert_eq!(TraceContext::from_traceparent(&lead_55), None);
let lead_56 = format!(" {CANONICAL_TRACEPARENT}");
assert_eq!(TraceContext::from_traceparent(&lead_56), None);
let trail_56 = format!("{CANONICAL_TRACEPARENT}\n");
assert_eq!(TraceContext::from_traceparent(&trail_56), None);
}
#[test]
fn traceparent_multibyte_unicode_returns_none_without_panicking() {
let multibyte = format!("{}\u{e9}", &CANONICAL_TRACEPARENT[..53]);
assert_eq!(multibyte.len(), 55, "fixture must be 55 bytes");
assert_eq!(TraceContext::from_traceparent(&multibyte), None);
}
#[test]
fn closed_vocabularies_have_stable_wire_names() {
assert_eq!(json!(TransportKind::Stdio), json!("stdio"));
assert_eq!(json!(TransportKind::Http), json!("http"));
assert_eq!(json!(Verdict::Served), json!("served"));
assert_eq!(json!(Verdict::ServedFiltered), json!("served_filtered"));
assert_eq!(json!(Verdict::Denied), json!("denied"));
assert_eq!(json!(Verdict::Refused), json!("refused"));
assert_eq!(json!(OutcomeClass::Ok), json!("ok"));
assert_eq!(json!(OutcomeClass::Refused), json!("refused"));
assert_eq!(json!(OutcomeClass::Error), json!("error"));
assert_eq!(json!(GapReason::WriteError), json!("write_error"));
assert_eq!(json!(GapReason::RotationError), json!("rotation_error"));
}
#[test]
fn unknown_envelope_key_rejected() {
let with_extra = GOLDEN_AUDIT_GAP.replace("\"dropped\"", "\"bogus\":1,\"dropped\"");
assert!(serde_json::from_str::<AuditEvent>(&with_extra).is_err());
}
#[test]
fn config_minimal_file_applies_defaults() {
let cfg = AuditConfig::from_toml_str("path = \"/var/log/bugwarden/audit.jsonl\"").unwrap();
assert_eq!(
cfg.path.as_deref(),
Some(Path::new("/var/log/bugwarden/audit.jsonl"))
);
assert!(!cfg.fsync);
assert_eq!(cfg.fail_mode, None);
assert_eq!(cfg.rotate_max_bytes, 64 * 1024 * 1024);
assert_eq!(cfg.rotate_keep, 8);
assert!(cfg.suppressed_ids);
}
#[test]
fn config_unknown_key_rejected_by_name() {
let err = AuditConfig::from_toml_str("path = \"/a\"\nfrobnicate = true\n").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("frobnicate"), "unexpected error: {msg}");
}
#[test]
fn config_rotate_keep_zero_with_rotation_rejected() {
let err = AuditConfig::from_toml_str("path = \"/a\"\nrotate_keep = 0\n").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("rotate_keep"), "unexpected error: {msg}");
}
#[test]
fn config_rotate_keep_zero_without_rotation_accepted() {
let cfg =
AuditConfig::from_toml_str("path = \"/a\"\nrotate_max_bytes = 0\nrotate_keep = 0\n")
.unwrap();
assert_eq!(cfg.rotate_max_bytes, 0);
assert_eq!(cfg.rotate_keep, 0);
}
#[test]
fn config_rotate_keep_above_cap_rejected() {
let err = AuditConfig::from_toml_str("path = \"/a\"\nrotate_keep = 10001\n").unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("rotate_keep") && msg.contains("10000"),
"unexpected error: {msg}"
);
}
#[test]
fn config_rotate_keep_at_cap_accepted() {
let cfg = AuditConfig::from_toml_str("path = \"/a\"\nrotate_keep = 10000\n").unwrap();
assert_eq!(cfg.rotate_keep, 10_000);
}
#[test]
fn config_every_fail_mode_parses() {
for (raw, want) in [
("open", FailMode::Open),
("closed_writes_denials", FailMode::ClosedWritesDenials),
("closed_all", FailMode::ClosedAll),
] {
let cfg =
AuditConfig::from_toml_str(&format!("path = \"/a\"\nfail_mode = \"{raw}\"\n"))
.unwrap();
assert_eq!(cfg.fail_mode, Some(want), "fail_mode = {raw}");
}
assert!(AuditConfig::from_toml_str("path = \"/a\"\nfail_mode = \"closed\"\n").is_err());
}
#[test]
fn config_missing_path_rejected() {
let err = AuditConfig::from_toml_str("fsync = true\n").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("path"), "unexpected error: {msg}");
}
#[test]
fn shipped_example_audit_config_parses() {
let path =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/audit.toml");
let cfg = AuditConfig::load(&path).expect("example audit config must exist and parse");
assert!(!cfg.fsync);
assert_eq!(cfg.fail_mode, None);
assert_eq!(cfg.rotate_max_bytes, 64 * 1024 * 1024);
assert_eq!(cfg.rotate_keep, 8);
assert!(cfg.suppressed_ids);
}
#[test]
#[cfg(unix)]
fn sink_creates_file_0600_and_parent_0700() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sub/audit.jsonl");
let sink = AuditSink::open(test_cfg(path.clone())).unwrap();
sink.record(sample_initialize(), session_stdio()).unwrap();
let file_mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(file_mode & 0o777, 0o600, "audit file must be 0600");
let dir_mode = std::fs::metadata(dir.path().join("sub"))
.unwrap()
.permissions()
.mode();
assert_eq!(dir_mode & 0o777, 0o700, "audit directory must be 0700");
}
#[test]
fn sink_appends_across_open_calls_and_seq_restarts() {
let dir = tempfile::tempdir().unwrap();
let cfg = test_cfg(dir.path().join("audit.jsonl"));
let first = AuditSink::open(cfg.clone()).unwrap();
assert_eq!(
first.record(sample_initialize(), session_stdio()).unwrap(),
1
);
assert_eq!(
first.record(sample_initialize(), session_stdio()).unwrap(),
2
);
drop(first);
let second = AuditSink::open(cfg.clone()).unwrap();
assert_eq!(
second.record(sample_initialize(), session_stdio()).unwrap(),
1
);
let seqs: Vec<u64> = read_events(live_path(&cfg)).iter().map(|e| e.seq).collect();
assert_eq!(seqs, vec![1, 2, 1]);
}
#[test]
#[cfg(unix)]
fn sink_refuses_symlink_path() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target.jsonl");
std::fs::write(&target, "").unwrap();
let link = dir.path().join("link.jsonl");
std::os::unix::fs::symlink(&target, &link).unwrap();
let err = AuditSink::open(test_cfg(link)).unwrap_err();
assert!(format!("{err:#}").contains("symlink"));
}
#[test]
fn rotation_shifts_files_and_loses_no_event() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = test_cfg(dir.path().join("audit.jsonl"));
cfg.rotate_max_bytes = 400;
cfg.rotate_keep = 5;
let sink = AuditSink::open(cfg.clone()).unwrap();
for _ in 0..8 {
sink.record(sample_initialize(), session_stdio()).unwrap();
}
let mut seqs: Vec<u64> = Vec::new();
let mut rotated = 0;
for n in (1..=cfg.rotate_keep).rev() {
let p = rotated_path(live_path(&cfg), n);
if p.exists() {
rotated += 1;
seqs.extend(read_events(&p).iter().map(|e| e.seq));
}
}
seqs.extend(read_events(live_path(&cfg)).iter().map(|e| e.seq));
assert!(rotated >= 2, "expected multiple rotations, saw {rotated}");
assert!(
rotated <= cfg.rotate_keep,
"rotated files exceed rotate_keep"
);
assert_eq!(seqs, (1..=8).collect::<Vec<u64>>());
assert_files_within_rotate_limit(&cfg);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(live_path(&cfg))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "recreated live file must be 0600");
}
}
#[test]
fn rotation_deletes_files_beyond_rotate_keep() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = test_cfg(dir.path().join("audit.jsonl"));
cfg.rotate_max_bytes = 400;
cfg.rotate_keep = 1;
let sink = AuditSink::open(cfg.clone()).unwrap();
for _ in 0..8 {
sink.record(sample_initialize(), session_stdio()).unwrap();
}
assert!(rotated_path(live_path(&cfg), 1).exists());
assert!(
!rotated_path(live_path(&cfg), 2).exists(),
"rotation must not keep more than rotate_keep files"
);
let mut events = read_events(&rotated_path(live_path(&cfg), 1));
events.extend(read_events(live_path(&cfg)));
let seqs: Vec<u64> = events.iter().map(|e| e.seq).collect();
assert!(
seqs.len() < 8,
"deletion beyond rotate_keep must have happened"
);
assert!(
seqs.windows(2).all(|w| w[1] == w[0] + 1),
"seqs stay contiguous: {seqs:?}"
);
assert_eq!(seqs.last(), Some(&8));
assert_files_within_rotate_limit(&cfg);
}
#[test]
fn single_oversized_record_written_alone_then_rotated_out() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = test_cfg(dir.path().join("audit.jsonl"));
cfg.rotate_max_bytes = 100;
cfg.rotate_keep = 3;
let sink = AuditSink::open(cfg.clone()).unwrap();
assert_eq!(
sink.record(sample_initialize(), session_stdio()).unwrap(),
1
);
assert!(
std::fs::metadata(live_path(&cfg)).unwrap().len() > cfg.rotate_max_bytes,
"a single oversized record must be written despite the limit"
);
assert!(!rotated_path(live_path(&cfg), 1).exists());
assert_eq!(
sink.record(sample_initialize(), session_stdio()).unwrap(),
2
);
let rotated = read_events(&rotated_path(live_path(&cfg), 1));
assert_eq!(rotated.len(), 1, "rotated file holds the first record");
assert_eq!(rotated[0].seq, 1);
let live = read_events(live_path(&cfg));
assert_eq!(live.len(), 1, "live file holds only the new record");
assert_eq!(live[0].seq, 2);
}
#[test]
fn reopened_sink_counts_existing_bytes_toward_rotation() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = test_cfg(dir.path().join("audit.jsonl"));
cfg.rotate_max_bytes = 200;
cfg.rotate_keep = 3;
let first = AuditSink::open(cfg.clone()).unwrap();
first.record(sample_initialize(), session_stdio()).unwrap();
drop(first);
let second = AuditSink::open(cfg.clone()).unwrap();
second.record(sample_initialize(), session_stdio()).unwrap();
assert!(
rotated_path(live_path(&cfg), 1).exists(),
"reopen must count existing bytes toward the rotation limit"
);
let live = read_events(live_path(&cfg));
assert_eq!(live.len(), 1, "live file holds only the new record");
assert_eq!(live[0].seq, 1, "a fresh sink assigns seq from 1");
assert_eq!(read_events(&rotated_path(live_path(&cfg), 1)).len(), 1);
}
#[test]
fn concurrent_records_are_contiguous_and_in_file_order() {
let dir = tempfile::tempdir().unwrap();
let cfg = test_cfg(dir.path().join("audit.jsonl"));
let sink = AuditSink::open(cfg.clone()).unwrap();
std::thread::scope(|s| {
for _ in 0..4 {
s.spawn(|| {
for _ in 0..25 {
sink.record(sample_initialize(), session_stdio())
.expect("concurrent record must succeed");
}
});
}
});
let seqs: Vec<u64> = read_events(live_path(&cfg)).iter().map(|e| e.seq).collect();
assert_eq!(seqs, (1..=100).collect::<Vec<u64>>());
}
#[test]
fn gap_marker_precedes_next_record_after_write_failures() {
let dir = tempfile::tempdir().unwrap();
let cfg = test_cfg(dir.path().join("audit.jsonl"));
let sink = AuditSink::open(cfg.clone()).unwrap();
assert_eq!(
sink.record(sample_initialize(), session_stdio()).unwrap(),
1
);
sink.set_fail_writes(true);
for _ in 0..2 {
let err = sink
.record(sample_initialize(), session_stdio())
.unwrap_err();
assert!(matches!(err, AuditError::Write(_)));
}
sink.set_fail_writes(false);
assert_eq!(
sink.record(sample_initialize(), session_stdio()).unwrap(),
3
);
let events = read_events(live_path(&cfg));
assert_eq!(events.len(), 3);
assert_eq!(events[1].seq, 2);
match &events[1].kind {
AuditEventKind::AuditGap(gap) => {
assert_eq!(gap.dropped, 2);
assert_eq!(gap.reason, GapReason::WriteError);
}
other => panic!("expected audit_gap, got {other:?}"),
}
assert_eq!(events[2].seq, 3);
}
#[test]
fn gap_marker_after_rotation_failure_reports_rotation_error() {
let dir = tempfile::tempdir().unwrap();
let mut cfg = test_cfg(dir.path().join("audit.jsonl"));
cfg.rotate_max_bytes = 200;
cfg.rotate_keep = 4;
let sink = AuditSink::open(cfg.clone()).unwrap();
assert_eq!(
sink.record(sample_initialize(), session_stdio()).unwrap(),
1
);
sink.set_fail_rotation(true);
for _ in 0..2 {
let err = sink
.record(sample_initialize(), session_stdio())
.unwrap_err();
assert!(matches!(err, AuditError::Rotation(_)));
}
sink.set_fail_rotation(false);
assert_eq!(
sink.record(sample_initialize(), session_stdio()).unwrap(),
3
);
let mut raw = String::new();
let mut events = Vec::new();
for n in (1..=cfg.rotate_keep).rev() {
let p = rotated_path(live_path(&cfg), n);
if p.exists() {
raw.push_str(&std::fs::read_to_string(&p).unwrap());
events.extend(read_events(&p));
}
}
raw.push_str(&std::fs::read_to_string(live_path(&cfg)).unwrap());
events.extend(read_events(live_path(&cfg)));
let seqs: Vec<u64> = events.iter().map(|e| e.seq).collect();
assert_eq!(seqs, vec![1, 2, 3]);
match &events[1].kind {
AuditEventKind::AuditGap(gap) => {
assert_eq!(gap.dropped, 2);
assert_eq!(gap.reason, GapReason::RotationError);
}
other => panic!("expected audit_gap, got {other:?}"),
}
assert!(
raw.contains(r#""reason":"rotation_error""#),
"the gap record on disk must name the rotation failure"
);
}
#[test]
fn partial_write_leaves_torn_line_and_stream_self_heals() {
let dir = tempfile::tempdir().unwrap();
let cfg = test_cfg(dir.path().join("audit.jsonl"));
let sink = AuditSink::open(cfg.clone()).unwrap();
assert_eq!(
sink.record(sample_initialize(), session_stdio()).unwrap(),
1
);
sink.set_fail_writes_partial(true);
let err = sink
.record(sample_initialize(), session_stdio())
.unwrap_err();
assert!(matches!(err, AuditError::Write(_)));
sink.set_fail_writes_partial(false);
let raw = std::fs::read_to_string(live_path(&cfg)).unwrap();
assert!(
!raw.ends_with('\n'),
"the partial write must have left a torn, unterminated line"
);
assert_eq!(
sink.record(sample_initialize(), session_stdio()).unwrap(),
3
);
let raw = std::fs::read_to_string(live_path(&cfg)).unwrap();
let mut torn_lines = 0;
let mut events: Vec<AuditEvent> = Vec::new();
for line in raw.lines().filter(|l| !l.is_empty()) {
match serde_json::from_str(line) {
Ok(event) => events.push(event),
Err(_) => torn_lines += 1,
}
}
assert_eq!(torn_lines, 1, "at most one torn line per outage");
let seqs: Vec<u64> = events.iter().map(|e| e.seq).collect();
assert_eq!(seqs, vec![1, 2, 3], "complete records stay contiguous");
match &events[1].kind {
AuditEventKind::AuditGap(gap) => {
assert_eq!(gap.dropped, 1);
assert_eq!(gap.reason, GapReason::WriteError);
}
other => panic!("expected audit_gap, got {other:?}"),
}
}
#[test]
fn real_sink_stamps_ts_rfc3339_utc_millis() {
let dir = tempfile::tempdir().unwrap();
let cfg = test_cfg(dir.path().join("audit.jsonl"));
let sink = AuditSink::open(cfg.clone()).unwrap();
sink.record(sample_initialize(), session_stdio()).unwrap();
let events = read_events(live_path(&cfg));
let ts = &events[0].ts;
const PATTERN: &[u8] = b"dddd-dd-ddTdd:dd:dd.dddZ";
assert_eq!(ts.len(), PATTERN.len(), "unexpected ts length: {ts:?}");
for (i, (&got, &want)) in ts.as_bytes().iter().zip(PATTERN).enumerate() {
match want {
b'd' => assert!(got.is_ascii_digit(), "byte {i} of ts {ts:?} not a digit"),
_ => assert_eq!(got, want, "byte {i} of ts {ts:?}"),
}
}
}
#[test]
fn api_key_canary_never_reaches_the_file() {
const CANARY: &str = "bugwarden-canary-3f6d1a-api-key-value";
let dir = tempfile::tempdir().unwrap();
let cfg = test_cfg(dir.path().join("audit.jsonl"));
let sink = AuditSink::open(cfg.clone()).unwrap();
sink.record(sample_tool_call(), session_http()).unwrap();
sink.record(sample_initialize(), session_stdio()).unwrap();
sink.record(sample_gap(), session_stdio()).unwrap();
let contents = std::fs::read_to_string(live_path(&cfg)).unwrap();
assert_eq!(contents.lines().count(), 3);
assert!(
!contents.contains(CANARY),
"the API key canary leaked into the audit file"
);
}
#[test]
fn fail_mode_transport_defaults_bind_the_documented_wording() {
assert_eq!(FailMode::default_for(TransportKind::Stdio), FailMode::Open);
assert_eq!(
FailMode::default_for(TransportKind::Http),
FailMode::ClosedAll
);
for transport in [TransportKind::Stdio, TransportKind::Http] {
assert_eq!(
FailMode::resolve(Some(FailMode::ClosedWritesDenials), transport),
FailMode::ClosedWritesDenials
);
}
assert_eq!(
FailMode::resolve(None, TransportKind::Stdio),
FailMode::Open
);
assert_eq!(
FailMode::resolve(None, TransportKind::Http),
FailMode::ClosedAll
);
}
#[test]
fn audit_state_session_id_is_pid_dash_epoch() {
let dir = tempfile::tempdir().unwrap();
let sink = AuditSink::open(test_cfg(dir.path().join("audit.jsonl"))).unwrap();
let state = AuditState::new(sink, FailMode::Open, None);
let (pid, epoch) = state
.stdio_session_id
.split_once('-')
.expect("session id is <pid>-<epoch>");
assert_eq!(pid, std::process::id().to_string());
assert!(epoch.parse::<u64>().is_ok(), "epoch part is seconds");
}
#[test]
fn cell_verdict_merge_keeps_the_worst_and_its_rule() {
let cell = AuditCell::default();
cell.note_verdict_rule(Verdict::Served, "allow-most");
cell.note_verdict_rule(Verdict::Denied, "embargo");
cell.note_verdict_rule(Verdict::ServedFiltered, "peek");
let info = cell.into_guard_info(None, true).expect("verdict noted");
assert_eq!(info.verdict, Verdict::Denied);
assert_eq!(info.rule.as_deref(), Some("embargo"));
}
#[test]
fn cell_upgrade_by_a_rule_less_verdict_clears_the_rule() {
let cell = AuditCell::default();
cell.note_verdict_rule(Verdict::Served, "allow-most");
cell.note_verdict(Verdict::Denied);
let info = cell.into_guard_info(None, true).expect("verdict noted");
assert_eq!(info.verdict, Verdict::Denied);
assert_eq!(info.rule, None);
}
#[test]
fn cell_first_rule_wins_at_equal_severity() {
let cell = AuditCell::default();
cell.note_verdict(Verdict::Served);
cell.note_verdict_rule(Verdict::Served, "first");
cell.note_verdict_rule(Verdict::Served, "second");
let info = cell.into_guard_info(None, true).expect("verdict noted");
assert_eq!(info.rule.as_deref(), Some("first"));
}
#[test]
fn cell_suppression_implies_a_filtered_serve() {
let cell = AuditCell::default();
cell.note_verdict_rule(Verdict::Served, "allow-most");
cell.note_suppressed([1290040, 1290041, 1290040]);
let info = cell.into_guard_info(Some("sha256:ab"), true).unwrap();
assert_eq!(info.verdict, Verdict::ServedFiltered);
assert_eq!(info.suppressed_ids, vec![1290040, 1290041]);
assert_eq!(info.suppressed_ids_count, 2);
assert_eq!(info.suppressed_other_count, 0);
assert_eq!(info.policy_hash.as_deref(), Some("sha256:ab"));
}
#[test]
fn cell_split_counters_report_their_own_populations() {
let cell = AuditCell::default();
cell.note_suppressed([7]);
cell.note_suppressed_count(2);
cell.note_suppressed_count(1);
let info = cell.into_guard_info(None, true).unwrap();
assert_eq!(info.suppressed_ids_count, 1, "one named id");
assert_eq!(
info.suppressed_other_count, 3,
"two id-less suppressions plus one more, added not replaced"
);
assert_eq!(info.suppressed_ids, vec![7]);
}
#[test]
fn cell_split_survives_the_ids_config() {
let cell = AuditCell::default();
cell.note_suppressed([40, 41]);
cell.note_suppressed_count(3);
let info = cell.into_guard_info(None, false).unwrap();
assert!(info.suppressed_ids.is_empty(), "ids elided by config");
assert_eq!(
info.suppressed_ids_count, 2,
"counted from the set, not from the vector the config emptied"
);
assert_eq!(
info.suppressed_other_count, 3,
"the id-less tally is untouched"
);
}
#[test]
fn cell_untouched_yields_no_guard_info() {
let cell = AuditCell::default();
assert_eq!(cell.into_guard_info(Some("sha256:ab"), true), None);
let cell = AuditCell::default();
cell.note_upstream(UpstreamInfo {
requests: 1,
status: Some(200),
latency_ms: 3,
});
assert!(cell.take_upstream().is_some());
assert_eq!(cell.into_guard_info(None, true), None);
}
#[test]
fn cell_redaction_is_recorded_and_upgrades_the_verdict() {
let cell = AuditCell::default();
cell.note_verdict(Verdict::Served);
cell.note_redacted("summary_view");
cell.note_redacted("summary_view");
let info = cell.into_guard_info(None, true).unwrap();
assert_eq!(info.verdict, Verdict::ServedFiltered);
assert_eq!(info.redacted_fields, vec!["summary_view".to_string()]);
}
#[test]
fn cell_client_window_redaction_keeps_the_granting_rule() {
let cell = AuditCell::default();
cell.note_verdict_rule(Verdict::Served, "allow-desktop");
cell.note_redacted_client_window("attachment_window");
let info = cell.into_guard_info(None, true).unwrap();
assert_eq!(info.verdict, Verdict::ServedFiltered);
assert_eq!(
info.redacted_fields,
vec!["attachment_window".to_string()],
"the window is still named"
);
assert_eq!(
info.rule.as_deref(),
Some("allow-desktop"),
"the rule that granted the call survives the client's window"
);
let cell = AuditCell::default();
cell.note_verdict_rule(Verdict::Served, "allow-desktop");
cell.note_redacted("summary_view");
let info = cell.into_guard_info(None, true).unwrap();
assert_eq!(info.verdict, Verdict::ServedFiltered);
assert_eq!(
info.rule, None,
"while a guard redaction that UPGRADES the verdict clears it"
);
let cell = AuditCell::default();
cell.note_verdict_rule(Verdict::ServedFiltered, "summaries-only");
cell.note_redacted("summary_view");
let info = cell.into_guard_info(None, true).unwrap();
assert_eq!(info.verdict, Verdict::ServedFiltered);
assert_eq!(info.rule.as_deref(), Some("summaries-only"));
let cell = AuditCell::default();
cell.note_verdict_rule(Verdict::Served, "allow-desktop");
cell.note_redacted_client_window("comments_window");
cell.note_redacted("summary_view");
let info = cell.into_guard_info(None, true).unwrap();
assert_eq!(info.rule.as_deref(), Some("allow-desktop"));
}
#[test]
fn cell_scan_with_drops_is_a_filtered_serve() {
let cell = AuditCell::default();
cell.note_verdict(Verdict::Served);
cell.note_scan(200, 3);
let info = cell.into_guard_info(None, true).unwrap();
assert_eq!(
info.verdict,
Verdict::ServedFiltered,
"a search that withheld rows is a filtered serve"
);
assert_eq!(
info.scan,
Some(ScanInfo {
scanned: 200,
dropped: 3
})
);
}
#[test]
fn cell_clean_scan_records_numbers_without_touching_the_verdict() {
let cell = AuditCell::default();
cell.note_verdict(Verdict::Served);
cell.note_scan(30, 0);
let info = cell.into_guard_info(None, true).unwrap();
assert_eq!(info.verdict, Verdict::Served, "a clean scan stays served");
assert_eq!(
info.scan,
Some(ScanInfo {
scanned: 30,
dropped: 0
})
);
}
#[test]
fn cell_scan_noted_twice_keeps_the_last_note() {
let cell = AuditCell::default();
cell.note_scan(200, 3);
cell.note_scan(400, 0);
let info = cell.into_guard_info(None, true).unwrap();
assert_eq!(
info.scan,
Some(ScanInfo {
scanned: 400,
dropped: 0
})
);
assert_eq!(info.verdict, Verdict::ServedFiltered);
}
#[test]
fn cell_scan_counts_survive_the_suppressed_ids_config() {
let cell = AuditCell::default();
cell.note_scan(30, 2);
cell.note_suppressed([12, 19]);
let info = cell.into_guard_info(None, false).unwrap();
assert!(info.suppressed_ids.is_empty(), "ids elided by config");
assert_eq!(info.suppressed_ids_count, 2, "the count still ships");
assert_eq!(info.suppressed_other_count, 0);
assert_eq!(
info.scan,
Some(ScanInfo {
scanned: 30,
dropped: 2
}),
"the scan accounting is counts, never ids"
);
}
#[test]
fn policy_hash_of_digests_the_given_bytes() {
assert_eq!(
policy_hash_of(b"abc"),
"sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn select_sinks_resolves_the_four_deployments() {
assert_eq!(select_sinks(None, false).unwrap(), SinkSelection::NoAudit);
assert_eq!(
select_sinks(Some(Path::new("/etc/bugwarden/audit.toml")), false).unwrap(),
SinkSelection::FileOnly
);
assert_eq!(
select_sinks(Some(Path::new("/etc/bugwarden/audit.toml")), true).unwrap(),
SinkSelection::Both
);
assert_eq!(
select_sinks(Some(Path::new(AUDIT_CONFIG_NONE)), true).unwrap(),
SinkSelection::OtlpOnly
);
}
#[test]
fn select_sinks_refuses_the_two_ambiguous_spellings() {
let err = format!(
"{}",
select_sinks(Some(Path::new("none")), false).unwrap_err()
);
assert!(
err.contains("no audit sink") && err.contains("OTEL_EXPORTER_OTLP_ENDPOINT"),
"the refusal must explain both ways out: {err}"
);
let err = format!("{}", select_sinks(None, true).unwrap_err());
assert!(
err.contains("BUGWARDEN_AUDIT_CONFIG") && err.contains("`none`"),
"the refusal must name the explicit spellings: {err}"
);
}
#[test]
fn the_none_sentinel_is_exact_bytes() {
assert_eq!(
select_sinks(Some(Path::new("None")), true).unwrap(),
SinkSelection::Both
);
assert_eq!(
select_sinks(Some(Path::new("./none")), true).unwrap(),
SinkSelection::Both
);
}
#[derive(Debug, Default)]
struct StubExport {
refuse: std::sync::atomic::AtomicBool,
failing: std::sync::atomic::AtomicBool,
lost: std::sync::atomic::AtomicU64,
accepted: Mutex<Vec<Vec<u8>>>,
}
impl AuditExport for StubExport {
fn accept(&self, _event: &AuditEvent, line: &[u8]) -> Result<(), ExportRefused> {
if self.refuse.load(std::sync::atomic::Ordering::Relaxed) {
return Err(ExportRefused);
}
self.accepted.lock().expect("stub lock").push(line.to_vec());
Ok(())
}
fn delivery_failing(&self) -> bool {
self.failing.load(std::sync::atomic::Ordering::Relaxed)
}
fn take_lost(&self) -> u64 {
self.lost.swap(0, std::sync::atomic::Ordering::Relaxed)
}
}
#[test]
fn a_fileless_sink_records_through_the_exporter_alone() {
let cfg = AuditConfig::fileless();
assert!(cfg.path.is_none(), "fileless names no file");
let export = std::sync::Arc::new(StubExport::default());
let sink = AuditSink::open(cfg).unwrap().with_export(export.clone());
assert_eq!(
sink.record(sample_initialize(), session_stdio()).unwrap(),
1
);
assert_eq!(sink.record(sample_tool_call(), session_http()).unwrap(), 2);
let accepted = export.accepted.lock().expect("stub lock").clone();
assert_eq!(accepted.len(), 2, "every record reaches the exporter");
let events: Vec<AuditEvent> = accepted
.iter()
.map(|line| serde_json::from_slice(line).expect("a parseable record line"))
.collect();
assert_eq!(events.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2]);
assert!(!sink.failing());
}
#[test]
fn an_export_refusal_fails_the_record_and_the_file_still_keeps_it() {
let dir = tempfile::tempdir().unwrap();
let cfg = test_cfg(dir.path().join("audit.jsonl"));
let export = std::sync::Arc::new(StubExport::default());
let sink = AuditSink::open(cfg.clone())
.unwrap()
.with_export(export.clone());
export
.refuse
.store(true, std::sync::atomic::Ordering::Relaxed);
let err = sink
.record(sample_initialize(), session_stdio())
.unwrap_err();
assert!(
matches!(err, AuditError::Export),
"a refused hand-off is an export failure: {err:?}"
);
let events = read_events(live_path(&cfg));
assert_eq!(events.len(), 1);
assert_eq!(events[0].seq, 1);
assert!(sink.failing(), "a refused record puts the sink in failure");
export
.refuse
.store(false, std::sync::atomic::Ordering::Relaxed);
assert_eq!(
sink.record(sample_initialize(), session_stdio()).unwrap(),
3
);
let events = read_events(live_path(&cfg));
assert_eq!(events.len(), 3);
match &events[1].kind {
AuditEventKind::AuditGap(gap) => {
assert_eq!(gap.dropped, 1);
assert_eq!(gap.reason, GapReason::WriteError);
}
other => panic!("expected audit_gap, got {other:?}"),
}
}
#[test]
fn export_delivery_failure_alone_puts_the_sink_in_failure() {
let export = std::sync::Arc::new(StubExport::default());
let sink = AuditSink::open(AuditConfig::fileless())
.unwrap()
.with_export(export.clone());
assert!(!sink.failing());
export
.failing
.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(
sink.failing(),
"failing delivery must gate even with nothing dropped"
);
export
.failing
.store(false, std::sync::atomic::Ordering::Relaxed);
assert!(!sink.failing(), "recovered delivery must clear the gate");
}
#[test]
fn undelivered_export_losses_surface_as_an_audit_gap() {
let dir = tempfile::tempdir().unwrap();
let cfg = test_cfg(dir.path().join("audit.jsonl"));
let export = std::sync::Arc::new(StubExport::default());
let sink = AuditSink::open(cfg.clone())
.unwrap()
.with_export(export.clone());
export.lost.store(3, std::sync::atomic::Ordering::Relaxed);
assert!(sink.failing(), "known losses put the sink in failure");
assert_eq!(
sink.record(sample_initialize(), session_stdio()).unwrap(),
2
);
let events = read_events(live_path(&cfg));
assert_eq!(events.len(), 2);
match &events[0].kind {
AuditEventKind::AuditGap(gap) => {
assert_eq!(gap.dropped, 3, "the gap carries the loss count");
assert_eq!(gap.reason, GapReason::WriteError);
}
other => panic!("expected audit_gap, got {other:?}"),
}
let accepted = export.accepted.lock().expect("stub lock").clone();
assert_eq!(accepted.len(), 2, "gap and record both reach the exporter");
}
#[derive(Debug)]
struct FileCheckingExport {
path: PathBuf,
checked: std::sync::atomic::AtomicU64,
}
impl AuditExport for FileCheckingExport {
fn accept(&self, _event: &AuditEvent, line: &[u8]) -> Result<(), ExportRefused> {
let contents = std::fs::read(&self.path).expect("the audit file exists at accept");
assert!(
contents.windows(line.len()).any(|window| window == line),
"the file must already hold the record when the exporter sees it"
);
self.checked
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(())
}
fn delivery_failing(&self) -> bool {
false
}
fn take_lost(&self) -> u64 {
0
}
}
#[test]
fn with_both_sinks_the_file_write_precedes_the_export() {
let dir = tempfile::tempdir().unwrap();
let cfg = test_cfg(dir.path().join("audit.jsonl"));
let export = std::sync::Arc::new(FileCheckingExport {
path: live_path(&cfg).to_path_buf(),
checked: std::sync::atomic::AtomicU64::new(0),
});
let sink = AuditSink::open(cfg.clone())
.unwrap()
.with_export(export.clone());
sink.record(sample_initialize(), session_stdio()).unwrap();
sink.record(sample_tool_call(), session_http()).unwrap();
assert_eq!(
export.checked.load(std::sync::atomic::Ordering::Relaxed),
2,
"the ordering assertion must actually have run"
);
}
#[test]
fn policy_hash_of_is_fixed_width_lowercase_hex() {
for len in 0..=64 {
let hash = policy_hash_of(&vec![b'x'; len]);
let hex = hash
.strip_prefix("sha256:")
.expect("every hash carries the algorithm prefix");
assert_eq!(hex.len(), 64, "input of {len} bytes: {hash}");
assert!(
hex.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()),
"input of {len} bytes: {hash}"
);
}
}
}