use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use chrono::{DateTime, SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use crate::utils::env::{non_empty_var, truthy_var, EnvSource, SystemEnv};
const LOG_FILE_NAME: &str = "log.jsonl";
const AUDIT_FILE_NAME: &str = "audit.jsonl";
#[cfg(unix)]
const DEFAULT_KEEP_FILES: u32 = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RecordKind {
#[default]
Invocation,
Http,
Gh,
Worktree,
DriveMutation,
Audit,
#[serde(other)]
Unknown,
}
impl RecordKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Invocation => "invocation",
Self::Http => "http",
Self::Gh => "gh",
Self::Worktree => "worktree",
Self::DriveMutation => "drivemutation",
Self::Audit => "audit",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Source {
#[default]
Cli,
Mcp,
Daemon,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LogRecord {
#[serde(default)]
pub id: String,
#[serde(default)]
pub invocation_id: String,
#[serde(default)]
pub kind: RecordKind,
#[serde(default)]
pub timestamp: String,
#[serde(default)]
pub hostname: String,
#[serde(default)]
pub pid: u32,
#[serde(default)]
pub omni_dev_version: String,
#[serde(default)]
pub cwd: String,
#[serde(default)]
pub system_user: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command_line: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<Source>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_tool: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub service: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status_code: Option<u16>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub elapsed_ms: Option<u64>,
#[serde(default, skip_serializing_if = "is_false")]
pub via_daemon: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub daemon_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_principal: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub request_headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub response_headers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_body: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response_body: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub context: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool {
!*b
}
impl LogRecord {
fn new(kind: RecordKind, invocation_id: String) -> Self {
Self {
id: new_id(),
invocation_id,
kind,
timestamp: now_rfc3339_millis(),
hostname: hostname(),
pid: std::process::id(),
omni_dev_version: crate::VERSION.to_string(),
cwd: cwd(),
system_user: system_user(),
..Self::default()
}
}
}
#[derive(Debug, Clone)]
pub struct RequestLogContext {
pub invocation_id: String,
pub source: Source,
pub mcp_tool: Option<String>,
}
impl Default for RequestLogContext {
fn default() -> Self {
Self {
invocation_id: new_id(),
source: Source::Cli,
mcp_tool: None,
}
}
}
impl RequestLogContext {
pub fn cli() -> Self {
Self {
invocation_id: new_id(),
source: Source::Cli,
mcp_tool: None,
}
}
pub fn mcp(tool: impl Into<String>) -> Self {
Self {
invocation_id: new_id(),
source: Source::Mcp,
mcp_tool: Some(tool.into()),
}
}
}
static GLOBAL: OnceLock<RequestLogContext> = OnceLock::new();
tokio::task_local! {
pub static CTX: RequestLogContext;
}
pub fn set_global(ctx: RequestLogContext) {
let _ = GLOBAL.set(ctx);
}
pub fn current_context() -> RequestLogContext {
if let Ok(ctx) = CTX.try_with(RequestLogContext::clone) {
return ctx;
}
if let Some(ctx) = GLOBAL.get() {
return ctx.clone();
}
RequestLogContext::default()
}
pub async fn scope_origin_id<F, T>(origin_id: String, fut: F) -> T
where
F: std::future::Future<Output = T>,
{
let mut ctx = current_context();
ctx.invocation_id = origin_id;
CTX.scope(ctx, fut).await
}
pub fn disabled() -> bool {
disabled_with(&SystemEnv)
}
fn disabled_with(env: &impl EnvSource) -> bool {
truthy_var(env, "OMNI_DEV_LOG_DISABLE")
}
pub fn bodies_enabled() -> bool {
bodies_enabled_with(&SystemEnv)
}
fn bodies_enabled_with(env: &impl EnvSource) -> bool {
truthy_var(env, "OMNI_DEV_LOG_BODIES")
}
pub fn headers_enabled() -> bool {
truthy_var(&SystemEnv, "OMNI_DEV_LOG_HEADERS")
}
pub(crate) fn omni_dev_state_subpath(component: &str) -> Option<PathBuf> {
let base = dirs::state_dir().or_else(dirs::data_dir)?;
Some(base.join("omni-dev").join(component))
}
pub fn log_file_path() -> Option<PathBuf> {
log_file_path_with(&SystemEnv)
}
pub(crate) fn log_file_path_with(env: &impl EnvSource) -> Option<PathBuf> {
let path = non_empty_var(env, "OMNI_DEV_LOG_FILE")
.map(PathBuf::from)
.or_else(|| omni_dev_state_subpath(LOG_FILE_NAME))?;
if resolves_to_audit_file_with(&path, env) {
tracing::warn!(
"request_log: OMNI_DEV_LOG_FILE resolves to the audit log ({}); refusing to use it \
as the request log path — set OMNI_DEV_LOG_FILE and/or OMNI_DEV_AUDIT_LOG_FILE to \
distinct paths",
path.display()
);
return None;
}
Some(path)
}
pub fn audit_file_path() -> Option<PathBuf> {
test_audit_file_override().or_else(|| audit_file_path_with(&SystemEnv))
}
pub(crate) fn audit_file_path_with(env: &impl EnvSource) -> Option<PathBuf> {
non_empty_var(env, "OMNI_DEV_AUDIT_LOG_FILE")
.map(PathBuf::from)
.or_else(default_audit_file_path)
}
fn lexically_normalize(path: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => match out.components().next_back() {
Some(Component::Normal(_)) => {
out.pop();
}
_ => out.push(".."),
},
other => out.push(other.as_os_str()),
}
}
out
}
const MAX_HAND_FOLLOWED_SYMLINK_HOPS: u32 = 8;
fn normalize(path: &Path) -> PathBuf {
normalize_with_budget(&lexically_normalize(path), MAX_HAND_FOLLOWED_SYMLINK_HOPS)
}
fn normalize_with_budget(path: &Path, hops: u32) -> PathBuf {
if let Ok(canon) = std::fs::canonicalize(path) {
return canon;
}
if hops > 0 {
if let Ok(meta) = std::fs::symlink_metadata(path) {
if meta.file_type().is_symlink() {
if let Ok(target) = std::fs::read_link(path) {
let resolved = if target.is_absolute() {
target
} else {
path.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.join(target)
};
return normalize_with_budget(&lexically_normalize(&resolved), hops - 1);
}
}
}
}
let Some(file_name) = path.file_name() else {
return path.to_path_buf();
};
let parent = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
match std::fs::canonicalize(parent) {
Ok(canon_parent) => canon_parent.join(file_name),
Err(_) => path.to_path_buf(),
}
}
fn same_file(a: &Path, b: &Path) -> bool {
if let Ok(same) = same_file::is_same_file(a, b) {
return same;
}
normalize(a) == normalize(b)
}
fn resolves_to_audit_file(path: &Path) -> bool {
audit_file_path().is_some_and(|audit| same_file(path, &audit))
}
fn resolves_to_audit_file_with(path: &Path, env: &impl EnvSource) -> bool {
audit_file_path_with(env).is_some_and(|audit| same_file(path, &audit))
}
#[cfg(not(test))]
fn test_audit_file_override() -> Option<PathBuf> {
None
}
#[cfg(test)]
fn test_audit_file_override() -> Option<PathBuf> {
TEST_AUDIT_ROUTE
.with(|slot| slot.borrow().clone())
.or_else(default_audit_file_path)
}
#[cfg(test)]
thread_local! {
pub(crate) static TEST_AUDIT_ROUTE: std::cell::RefCell<Option<PathBuf>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(not(test))]
fn default_audit_file_path() -> Option<PathBuf> {
omni_dev_state_subpath(AUDIT_FILE_NAME)
}
#[cfg(test)]
#[allow(clippy::expect_used)]
fn default_audit_file_path() -> Option<PathBuf> {
use std::sync::OnceLock;
static PATH: OnceLock<PathBuf> = OnceLock::new();
Some(
PATH.get_or_init(|| {
let dir =
tempfile::tempdir().expect("failed to create a scratch dir for test audit logs");
let path = dir.path().join(AUDIT_FILE_NAME);
std::mem::forget(dir);
path
})
.clone(),
)
}
pub fn record(entry: &LogRecord) {
if disabled() {
return;
}
if let Err(e) = try_record(entry) {
tracing::debug!("request_log: failed to append record: {e}");
}
}
fn try_record(entry: &LogRecord) -> anyhow::Result<()> {
use anyhow::{ensure, Context};
ensure!(
entry.kind != RecordKind::Audit,
"an audit-kind record must go through record_audit(), not record()"
);
let path = log_file_path().context("could not resolve the log file path")?;
append_record_to(&path, entry, append_line)
}
pub fn record_audit(entry: &LogRecord) -> anyhow::Result<()> {
use anyhow::Context;
let path = audit_file_path().context("could not resolve the audit log file path")?;
record_audit_to(path, entry)
}
#[cfg(test)]
fn record_audit_with(env: &impl EnvSource, entry: &LogRecord) -> anyhow::Result<()> {
use anyhow::Context;
let path = audit_file_path_with(env).context("could not resolve the audit log file path")?;
record_audit_to(path, entry)
}
fn record_audit_to(path: PathBuf, entry: &LogRecord) -> anyhow::Result<()> {
use anyhow::ensure;
ensure!(
entry.kind == RecordKind::Audit,
"record_audit() requires a RecordKind::Audit entry, got {:?}",
entry.kind
);
append_record_to(&path, entry, append_line_synced)
}
fn append_record_to(
path: &Path,
entry: &LogRecord,
append: fn(&Path, &str) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
use anyhow::Context;
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
crate::daemon::paths::ensure_dir_0700(parent)?;
}
}
let mut line = serde_json::to_string(entry).context("failed to serialize record")?;
line.push('\n');
append(path, &line)?;
Ok(())
}
#[cfg(unix)]
fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
if let Some(cfg) = rotation_config() {
return append_with_rotation(path, line, &cfg);
}
append_line_no_rotation(path, line)
}
#[cfg(unix)]
fn append_line_no_rotation(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
append_line_unrotated(path, line, false)
}
fn append_line_synced(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
append_line_unrotated(path, line, true)
}
#[cfg(unix)]
fn append_line_unrotated(path: &std::path::Path, line: &str, sync: bool) -> anyhow::Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.mode(0o600)
.open(path)?;
crate::daemon::paths::ensure_handle_0600(&file)?;
let sync_parent = sync && file.metadata()?.len() == 0;
if bodies_enabled() {
match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive) {
Ok(mut guard) => {
guard.write_all(line.as_bytes())?;
if sync {
guard.sync_data()?;
}
}
Err((mut file, _)) => {
file.write_all(line.as_bytes())?;
if sync {
file.sync_data()?;
}
}
}
} else {
let mut file = file;
file.write_all(line.as_bytes())?;
if sync {
file.sync_data()?;
}
}
if sync_parent {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::File::open(parent)?.sync_all()?;
}
}
Ok(())
}
#[cfg(not(unix))]
fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
append_line_no_rotation(path, line)
}
#[cfg(not(unix))]
fn append_line_no_rotation(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
append_line_unrotated(path, line, false)
}
#[cfg(not(unix))]
fn append_line_unrotated(path: &std::path::Path, line: &str, sync: bool) -> anyhow::Result<()> {
let mut file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(path)?;
file.write_all(line.as_bytes())?;
if sync {
file.sync_data()?;
}
Ok(())
}
fn sibling(path: &Path, suffix: &str) -> PathBuf {
let mut name = path.as_os_str().to_owned();
name.push(suffix);
PathBuf::from(name)
}
pub(crate) fn parse_size(s: &str) -> anyhow::Result<u64> {
use anyhow::Context as _;
let lower = s.trim().to_ascii_lowercase();
if lower.is_empty() {
anyhow::bail!("empty size (expected e.g. 10mb, 512kb, 1048576)");
}
let split = lower
.find(|c: char| !c.is_ascii_digit() && c != '.')
.unwrap_or(lower.len());
let (num, unit) = lower.split_at(split);
let value: f64 = num
.parse()
.with_context(|| format!("invalid size number: {s}"))?;
if !value.is_finite() || value < 0.0 {
anyhow::bail!("invalid size: {s}");
}
let mult: u64 = match unit.trim() {
"" | "b" => 1,
"k" | "kb" | "kib" => 1024,
"m" | "mb" | "mib" => 1024 * 1024,
"g" | "gb" | "gib" => 1024 * 1024 * 1024,
other => anyhow::bail!("invalid size unit: {other} (use b, kb, mb, or gb)"),
};
Ok((value * mult as f64) as u64)
}
#[cfg(unix)]
struct RotationConfig {
max_size: u64,
keep_files: u32,
}
#[cfg(unix)]
fn rotation_config() -> Option<RotationConfig> {
let raw = std::env::var("OMNI_DEV_LOG_MAX_SIZE").ok()?;
if raw.trim().is_empty() {
return None;
}
let max_size = match parse_size(&raw) {
Ok(0) => return None,
Ok(n) => n,
Err(e) => {
tracing::debug!("request_log: ignoring invalid OMNI_DEV_LOG_MAX_SIZE: {e}");
return None;
}
};
let keep_files = std::env::var("OMNI_DEV_LOG_KEEP_FILES")
.ok()
.and_then(|v| v.trim().parse::<u32>().ok())
.unwrap_or(DEFAULT_KEEP_FILES);
Some(RotationConfig {
max_size,
keep_files,
})
}
#[cfg(unix)]
fn rotate(path: &Path, keep_files: u32) -> anyhow::Result<()> {
if keep_files == 0 {
let _ = std::fs::remove_file(path);
return Ok(());
}
let _ = std::fs::remove_file(sibling(path, &format!(".{keep_files}")));
for i in (1..keep_files).rev() {
let from = sibling(path, &format!(".{i}"));
if from.exists() {
std::fs::rename(&from, sibling(path, &format!(".{}", i + 1)))?;
}
}
std::fs::rename(path, sibling(path, ".1"))?;
Ok(())
}
#[cfg(unix)]
fn append_with_rotation(path: &Path, line: &str, cfg: &RotationConfig) -> anyhow::Result<()> {
use std::os::unix::fs::OpenOptionsExt;
if resolves_to_audit_file(path) {
return append_line_no_rotation(path, line);
}
let lock_path = sibling(path, ".lock");
let lock_file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.mode(0o600)
.open(&lock_path)?;
crate::daemon::paths::ensure_handle_0600(&lock_file)?;
let _guard = nix::fcntl::Flock::lock(lock_file, nix::fcntl::FlockArg::LockExclusive).ok();
let current = std::fs::metadata(path).map_or(0, |m| m.len());
if current > 0 && current.saturating_add(line.len() as u64) > cfg.max_size {
if let Err(e) = rotate(path, cfg.keep_files) {
tracing::debug!("request_log: rotation failed, appending without rotating: {e}");
}
}
let mut file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.mode(0o600)
.open(path)?;
crate::daemon::paths::ensure_handle_0600(&file)?;
file.write_all(line.as_bytes())?;
Ok(())
}
pub struct PruneOptions {
pub older_than: Option<DateTime<Utc>>,
pub max_size: Option<u64>,
pub dry_run: bool,
}
pub struct PruneOutcome {
pub removed: usize,
pub kept: usize,
pub bytes_before: u64,
pub bytes_after: u64,
}
pub fn prune(path: &Path, opts: &PruneOptions) -> anyhow::Result<PruneOutcome> {
use anyhow::Context as _;
anyhow::ensure!(
!resolves_to_audit_file(path),
"refusing to prune {}: it resolves to the audit log, which is exempt from pruning by \
design (ADR-0080 §11); read it with `omni-dev log --audit` instead",
path.display()
);
let data = match std::fs::read(path) {
Ok(data) => data,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(PruneOutcome {
removed: 0,
kept: 0,
bytes_before: 0,
bytes_after: 0,
});
}
Err(e) => return Err(e).context("failed to read the log file"),
};
let bytes_before = data.len() as u64;
let text = String::from_utf8_lossy(&data);
let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
let aged: Vec<&str> = all
.iter()
.copied()
.filter(|line| keep_by_age(line, opts.older_than))
.collect();
let kept: &[&str] = match opts.max_size {
None => &aged,
Some(max) => keep_by_size(&aged, max),
};
let bytes_after: u64 = kept.iter().map(|l| l.len() as u64 + 1).sum();
let outcome = PruneOutcome {
removed: all.len() - kept.len(),
kept: kept.len(),
bytes_before,
bytes_after,
};
if !opts.dry_run && outcome.removed > 0 {
rewrite_atomically(path, kept)?;
}
Ok(outcome)
}
fn keep_by_age(line: &str, older_than: Option<DateTime<Utc>>) -> bool {
let Some(cutoff) = older_than else {
return true;
};
match serde_json::from_str::<LogRecord>(line) {
Ok(rec) => match DateTime::parse_from_rfc3339(&rec.timestamp) {
Ok(ts) => ts.with_timezone(&Utc) >= cutoff,
Err(_) => true,
},
Err(_) => true,
}
}
fn keep_by_size<'a>(lines: &'a [&'a str], max: u64) -> &'a [&'a str] {
let mut acc = 0u64;
let mut start = lines.len();
for (i, line) in lines.iter().enumerate().rev() {
acc += line.len() as u64 + 1;
if acc > max {
break;
}
start = i;
}
if start == lines.len() && !lines.is_empty() {
start = lines.len() - 1; }
&lines[start..]
}
fn rewrite_atomically(path: &Path, lines: &[&str]) -> anyhow::Result<()> {
let tmp = sibling(path, &format!(".prune.{}.tmp", std::process::id()));
let result = (|| -> anyhow::Result<()> {
let mut options = std::fs::OpenOptions::new();
options.create(true).write(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&tmp)?;
#[cfg(unix)]
crate::daemon::paths::ensure_handle_0600(&file)?;
for line in lines {
file.write_all(line.as_bytes())?;
file.write_all(b"\n")?;
}
file.flush()?;
std::fs::rename(&tmp, path)?;
Ok(())
})();
if result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
result
}
#[derive(Debug, Clone)]
pub struct InvocationOutcome {
pub command: Vec<String>,
pub command_line: Vec<String>,
pub exit_code: i32,
pub error: Option<String>,
pub duration: Duration,
}
pub fn record_invocation(outcome: InvocationOutcome) {
let ctx = current_context();
let mut rec = LogRecord::new(RecordKind::Invocation, ctx.invocation_id);
rec.source = Some(ctx.source);
rec.mcp_tool = ctx.mcp_tool;
rec.command = outcome.command;
rec.command_line = scrub_argv(&outcome.command_line);
rec.exit_code = Some(outcome.exit_code);
rec.error = outcome.error;
rec.duration_ms = Some(outcome.duration.as_millis() as u64);
rec.env = whitelisted_env();
record(&rec);
}
#[derive(Debug, Clone)]
pub struct GhOutcome {
pub label: String,
pub argv: Vec<String>,
pub exit_code: Option<i32>,
pub duration: Duration,
pub error: Option<String>,
}
pub fn record_gh(outcome: GhOutcome) {
record(&build_gh_record(outcome, current_context()));
}
fn build_gh_record(outcome: GhOutcome, ctx: RequestLogContext) -> LogRecord {
let mut rec = LogRecord::new(RecordKind::Gh, ctx.invocation_id);
rec.source = Some(ctx.source);
rec.mcp_tool = ctx.mcp_tool;
rec.command = outcome
.label
.split(' ')
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect();
rec.command_line = scrub_argv(&outcome.argv);
rec.exit_code = outcome.exit_code;
rec.error = outcome.error;
rec.duration_ms = Some(outcome.duration.as_millis() as u64);
rec
}
#[derive(Debug, Clone)]
pub struct WorktreeOutcome {
pub verb: String,
pub argv: Vec<String>,
pub exit_code: Option<i32>,
pub duration: Duration,
pub error: Option<String>,
pub context: BTreeMap<String, String>,
}
pub fn record_worktree(outcome: WorktreeOutcome) {
record(&build_worktree_record(outcome, current_context()));
}
fn build_worktree_record(outcome: WorktreeOutcome, ctx: RequestLogContext) -> LogRecord {
let mut rec = LogRecord::new(RecordKind::Worktree, ctx.invocation_id);
rec.source = Some(ctx.source);
rec.mcp_tool = ctx.mcp_tool;
rec.service = Some("worktree".to_string());
rec.command = vec!["git".to_string(), "worktree".to_string(), outcome.verb];
rec.command_line = scrub_argv(&outcome.argv);
rec.exit_code = outcome.exit_code;
rec.error = outcome.error;
rec.duration_ms = Some(outcome.duration.as_millis() as u64);
rec.context = outcome.context;
rec
}
#[derive(Debug, Clone, Default)]
pub struct DriveMutationOutcome {
pub operation: &'static str,
pub file_id: String,
pub file_name: String,
pub status: String,
pub added_principals: Vec<String>,
pub removed_principals: Vec<String>,
pub crosses_drive_boundary: bool,
pub resolved_folder_id: Option<String>,
pub decided_by_folder_id: Option<String>,
pub decided_by_depth: Option<usize>,
pub decided_by_file_id: Option<String>,
pub range: Option<String>,
pub updated_range: Option<String>,
pub updated_rows: Option<i64>,
pub updated_columns: Option<i64>,
pub updated_cells: Option<i64>,
pub sheet_id: Option<i64>,
pub sheet_title: Option<String>,
pub sheet_new_title: Option<String>,
pub dimension_range: Option<String>,
pub move_to: Option<i64>,
pub grid_range: Option<String>,
pub occurrences_changed: Option<i64>,
pub inserted_chars: Option<i64>,
pub required_revision_id: Option<String>,
pub fields_changed: Option<String>,
pub discarded_cells: Vec<String>,
pub overwritten_cells: Vec<String>,
pub overwritten_cells_upper_bound: bool,
pub validation_type: Option<String>,
pub protected_range_id: Option<i64>,
pub protection_editors_added: Vec<String>,
pub protection_editors_removed: Vec<String>,
pub filter_view_id: Option<i64>,
pub named_range_id: Option<String>,
pub referencing_formula_locations: Vec<String>,
pub embedded_object_id: Option<i64>,
pub banded_range_id: Option<i64>,
pub error: Option<String>,
pub duration: Duration,
}
pub fn record_drive_mutation(outcome: DriveMutationOutcome) {
record(&build_drive_mutation_record(outcome, current_context()));
}
fn build_drive_mutation_record(outcome: DriveMutationOutcome, ctx: RequestLogContext) -> LogRecord {
let mut rec = LogRecord::new(RecordKind::DriveMutation, ctx.invocation_id);
rec.source = Some(ctx.source);
rec.mcp_tool = ctx.mcp_tool;
rec.service = Some("drive".to_string());
rec.command = vec!["drive".to_string(), outcome.operation.to_string()];
rec.error = outcome.error;
rec.duration_ms = Some(outcome.duration.as_millis() as u64);
let mut context = BTreeMap::new();
context.insert("file_id".to_string(), outcome.file_id);
context.insert("file_name".to_string(), outcome.file_name);
context.insert("status".to_string(), outcome.status);
if !outcome.added_principals.is_empty() {
context.insert(
"added_principals".to_string(),
outcome.added_principals.join(","),
);
}
if !outcome.removed_principals.is_empty() {
context.insert(
"removed_principals".to_string(),
outcome.removed_principals.join(","),
);
}
if outcome.crosses_drive_boundary {
context.insert("crosses_drive_boundary".to_string(), "true".to_string());
}
if let Some(resolved_folder_id) = outcome.resolved_folder_id {
context.insert("resolved_folder_id".to_string(), resolved_folder_id);
}
if let Some(decided_by_folder_id) = outcome.decided_by_folder_id {
context.insert("decided_by_folder_id".to_string(), decided_by_folder_id);
}
if let Some(decided_by_depth) = outcome.decided_by_depth {
context.insert("decided_by_depth".to_string(), decided_by_depth.to_string());
}
if let Some(decided_by_file_id) = outcome.decided_by_file_id {
context.insert("decided_by_file_id".to_string(), decided_by_file_id);
}
if let Some(range) = outcome.range {
context.insert("range".to_string(), range);
}
if let Some(updated_range) = outcome.updated_range {
context.insert("updated_range".to_string(), updated_range);
}
if let Some(rows) = outcome.updated_rows {
context.insert("updated_rows".to_string(), rows.to_string());
}
if let Some(cols) = outcome.updated_columns {
context.insert("updated_columns".to_string(), cols.to_string());
}
if let Some(cells) = outcome.updated_cells {
context.insert("updated_cells".to_string(), cells.to_string());
}
if let Some(sheet_id) = outcome.sheet_id {
context.insert("sheet_id".to_string(), sheet_id.to_string());
}
if let Some(sheet_title) = outcome.sheet_title {
context.insert("sheet_title".to_string(), sheet_title);
}
if let Some(sheet_new_title) = outcome.sheet_new_title {
context.insert("sheet_new_title".to_string(), sheet_new_title);
}
if let Some(dimension_range) = outcome.dimension_range {
context.insert("dimension_range".to_string(), dimension_range);
}
if let Some(move_to) = outcome.move_to {
context.insert("move_to".to_string(), move_to.to_string());
}
if let Some(grid_range) = outcome.grid_range {
context.insert("grid_range".to_string(), grid_range);
}
if let Some(occurrences) = outcome.occurrences_changed {
context.insert("occurrences_changed".to_string(), occurrences.to_string());
}
if let Some(chars) = outcome.inserted_chars {
context.insert("inserted_chars".to_string(), chars.to_string());
}
if let Some(revision) = outcome.required_revision_id {
context.insert("required_revision_id".to_string(), revision);
}
if let Some(fields_changed) = outcome.fields_changed {
context.insert("fields_changed".to_string(), fields_changed);
}
if !outcome.discarded_cells.is_empty() {
context.insert(
"discarded_cells".to_string(),
outcome.discarded_cells.join("; "),
);
}
if !outcome.overwritten_cells.is_empty() {
context.insert(
"overwritten_cells".to_string(),
outcome.overwritten_cells.join(", "),
);
if outcome.overwritten_cells_upper_bound {
context.insert(
"overwritten_cells_are_upper_bound".to_string(),
"true".to_string(),
);
}
}
if let Some(validation_type) = outcome.validation_type {
context.insert("validation_type".to_string(), validation_type);
}
if let Some(protected_range_id) = outcome.protected_range_id {
context.insert(
"protected_range_id".to_string(),
protected_range_id.to_string(),
);
}
if !outcome.protection_editors_added.is_empty() {
context.insert(
"protection_editors_added".to_string(),
outcome.protection_editors_added.join(","),
);
}
if !outcome.protection_editors_removed.is_empty() {
context.insert(
"protection_editors_removed".to_string(),
outcome.protection_editors_removed.join(","),
);
}
if let Some(filter_view_id) = outcome.filter_view_id {
context.insert("filter_view_id".to_string(), filter_view_id.to_string());
}
if let Some(named_range_id) = outcome.named_range_id {
context.insert("named_range_id".to_string(), named_range_id);
}
if !outcome.referencing_formula_locations.is_empty() {
context.insert(
"referencing_formula_count".to_string(),
outcome.referencing_formula_locations.len().to_string(),
);
context.insert(
"referencing_formula_locations".to_string(),
outcome.referencing_formula_locations.join("; "),
);
}
if let Some(embedded_object_id) = outcome.embedded_object_id {
context.insert(
"embedded_object_id".to_string(),
embedded_object_id.to_string(),
);
}
if let Some(banded_range_id) = outcome.banded_range_id {
context.insert("banded_range_id".to_string(), banded_range_id.to_string());
}
rec.context = context;
rec
}
#[derive(Debug, Clone, Default)]
pub struct AuditOutcome {
pub command: Vec<String>,
pub integration: &'static str,
pub file_id: String,
pub lease_id: Option<String>,
pub verdict: String,
pub version_before: Option<String>,
pub version_after: Option<String>,
pub modified_time_before: Option<String>,
pub modified_time_after: Option<String>,
pub backup_location: Option<String>,
pub backup_sha256: Option<String>,
pub backup_size: Option<u64>,
pub auth_policy: Option<String>,
pub restored_from_lease_id: Option<String>,
pub superseded_lease_id: Option<String>,
pub error: Option<String>,
}
pub fn record_audit_event(outcome: AuditOutcome) -> anyhow::Result<()> {
record_audit(&build_audit_record(outcome, current_context()))
}
#[cfg(test)]
fn record_audit_event_with(env: &impl EnvSource, outcome: AuditOutcome) -> anyhow::Result<()> {
record_audit_with(env, &build_audit_record(outcome, current_context()))
}
fn build_audit_record(outcome: AuditOutcome, ctx: RequestLogContext) -> LogRecord {
let mut rec = LogRecord::new(RecordKind::Audit, ctx.invocation_id);
rec.source = Some(ctx.source);
rec.mcp_tool = ctx.mcp_tool;
rec.service = Some("drive".to_string());
rec.command = outcome.command;
rec.error = outcome.error;
let mut context = BTreeMap::new();
context.insert("integration".to_string(), outcome.integration.to_string());
context.insert("file_id".to_string(), outcome.file_id);
if let Some(lease_id) = outcome.lease_id {
context.insert("lease_id".to_string(), lease_id);
}
context.insert("verdict".to_string(), outcome.verdict);
if let Some(version) = outcome.version_before {
context.insert("version_before".to_string(), version);
}
if let Some(version) = outcome.version_after {
context.insert("version_after".to_string(), version);
}
if let Some(modified_time) = outcome.modified_time_before {
context.insert("modified_time_before".to_string(), modified_time);
}
if let Some(modified_time) = outcome.modified_time_after {
context.insert("modified_time_after".to_string(), modified_time);
}
if let Some(location) = outcome.backup_location {
context.insert("backup_location".to_string(), location);
}
if let Some(sha256) = outcome.backup_sha256 {
context.insert("backup_sha256".to_string(), sha256);
}
if let Some(size) = outcome.backup_size {
context.insert("backup_size".to_string(), size.to_string());
}
if let Some(auth_policy) = outcome.auth_policy {
context.insert("auth_policy".to_string(), auth_policy);
}
if let Some(restored_from) = outcome.restored_from_lease_id {
context.insert("restored_from_lease_id".to_string(), restored_from);
}
if let Some(superseded) = outcome.superseded_lease_id {
context.insert("superseded_lease_id".to_string(), superseded);
}
rec.context = context;
rec
}
#[derive(Debug, Clone, Default)]
pub struct HttpExtra {
pub via_daemon: bool,
pub daemon_session_id: Option<String>,
pub auth_principal: Option<String>,
pub request_headers: BTreeMap<String, String>,
pub response_headers: BTreeMap<String, String>,
pub request_body: Option<String>,
pub response_body: Option<String>,
pub context: BTreeMap<String, String>,
}
pub fn record_http(
service: &str,
method: &str,
url: &str,
started: Instant,
status: Option<u16>,
error: Option<&str>,
) {
record_http_with(
service,
method,
url,
started,
status,
error,
HttpExtra::default(),
);
}
pub fn record_http_result(
service: &str,
method: &str,
url: &str,
started: Instant,
result: &reqwest::Result<reqwest::Response>,
) {
match result {
Ok(response) => {
record_http(
service,
method,
url,
started,
Some(response.status().as_u16()),
None,
);
}
Err(error) => {
record_http(
service,
method,
url,
started,
None,
Some(&error.to_string()),
);
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn record_http_with(
service: &str,
method: &str,
url: &str,
started: Instant,
status: Option<u16>,
error: Option<&str>,
extra: HttpExtra,
) {
if disabled() {
return;
}
let ctx = current_context();
let mut rec = LogRecord::new(RecordKind::Http, ctx.invocation_id);
rec.source = Some(ctx.source);
rec.mcp_tool = ctx.mcp_tool;
rec.service = Some(service.to_string());
rec.method = Some(method.to_string());
rec.url = Some(redact_url(url));
rec.status_code = status;
rec.elapsed_ms = Some(started.elapsed().as_millis() as u64);
rec.error = error.map(str::to_string);
rec.via_daemon = extra.via_daemon;
rec.daemon_session_id = extra.daemon_session_id;
rec.auth_principal = extra.auth_principal;
rec.context = extra.context;
if headers_enabled() {
rec.request_headers = redact_headers(&extra.request_headers);
rec.response_headers = redact_headers(&extra.response_headers);
}
if bodies_enabled() {
rec.request_body = extra.request_body;
rec.response_body = extra.response_body;
}
record(&rec);
}
const SENSITIVE_HEADERS: &[&str] = &[
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"x-api-key",
"api-key",
"dd-api-key",
"dd-application-key",
"x-datadog-api-key",
"x-datadog-application-key",
"x-omni-bridge",
"x-omni-bridge-target",
];
const SENSITIVE_HEADER_MARKERS: &[&str] = &[
"auth",
"token",
"secret",
"key",
"cookie",
"password",
"session",
"signature",
"credential",
];
pub fn redact_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
headers
.iter()
.map(|(name, value)| {
let lower = name.to_ascii_lowercase();
let redacted = SENSITIVE_HEADERS.contains(&lower.as_str())
|| SENSITIVE_HEADER_MARKERS
.iter()
.any(|marker| lower.contains(marker));
(
name.clone(),
if redacted {
"REDACTED".to_string()
} else {
value.clone()
},
)
})
.collect()
}
const SECRETISH_FLAG_WORDS: &[&str] = &["token", "secret", "password", "passwd", "key"];
fn is_secretish_flag(name: &str) -> bool {
let segments: Vec<String> = name
.split(['-', '_'])
.map(str::to_ascii_lowercase)
.collect();
let takes_path = matches!(segments.last().map(String::as_str), Some("file" | "path"));
!takes_path
&& segments
.iter()
.any(|segment| SECRETISH_FLAG_WORDS.contains(&segment.as_str()))
}
fn scrub_header_arg(value: &str) -> Option<String> {
let Some((name, _)) = value.split_once(':') else {
return Some("REDACTED".to_string());
};
SENSITIVE_HEADERS
.contains(&name.trim().to_ascii_lowercase().as_str())
.then(|| format!("{}: REDACTED", name.trim()))
}
fn scrub_flag_value(name: &str, value: &str) -> Option<String> {
match name {
"header" => scrub_header_arg(value),
"body" => (!value.starts_with('@')).then(|| "REDACTED".to_string()),
"find" | "replacement" => Some("REDACTED".to_string()),
_ if is_secretish_flag(name) => Some("REDACTED".to_string()),
_ => None,
}
}
fn scrub_argv(argv: &[String]) -> Vec<String> {
scrub_flag_secrets(argv)
.iter()
.map(|arg| redact_url(arg))
.collect()
}
fn scrub_flag_secrets(argv: &[String]) -> Vec<String> {
let mut out = Vec::with_capacity(argv.len());
let mut i = 0;
while i < argv.len() {
let arg = &argv[i];
i += 1;
let Some(flag_body) = arg.strip_prefix("--") else {
out.push(arg.clone());
continue;
};
if let Some((name, value)) = flag_body.split_once('=') {
match scrub_flag_value(name, value) {
Some(scrubbed) => out.push(format!("--{name}={scrubbed}")),
None => out.push(arg.clone()),
}
} else {
out.push(arg.clone());
let takes_secret_value =
matches!(flag_body, "header" | "body" | "find" | "replacement")
|| is_secretish_flag(flag_body);
if takes_secret_value {
if let Some(value) = argv.get(i) {
i += 1;
out.push(scrub_flag_value(flag_body, value).unwrap_or_else(|| value.clone()));
}
}
}
}
out
}
const SENSITIVE_QUERY_KEYS: &[&str] = &["sig", "sas", "jwt", "auth"];
const SENSITIVE_QUERY_KEY_SUFFIXES: &[&str] = &[
"token",
"secret",
"password",
"passwd",
"signature",
"apikey",
"api_key",
"api-key",
];
const SENSITIVE_QUERY_KEY_PREFIXES: &[&str] = &["x-amz-", "x-goog-"];
fn sensitive_query_key(key: &str) -> bool {
let key = key.to_ascii_lowercase();
SENSITIVE_QUERY_KEYS.contains(&key.as_str())
|| SENSITIVE_QUERY_KEY_SUFFIXES
.iter()
.any(|suffix| key.ends_with(suffix))
|| SENSITIVE_QUERY_KEY_PREFIXES
.iter()
.any(|prefix| key.starts_with(prefix))
}
fn redact_pairs(pairs: &str) -> String {
pairs
.split('&')
.map(|segment| match segment.split_once('=') {
Some((raw_key, _)) => {
let sensitive = url::form_urlencoded::parse(raw_key.as_bytes())
.next()
.is_some_and(|(key, _)| sensitive_query_key(&key));
if sensitive {
format!("{raw_key}=REDACTED")
} else {
segment.to_string()
}
}
None => segment.to_string(),
})
.collect::<Vec<_>>()
.join("&")
}
fn redact_url(url: &str) -> String {
let (rest, fragment) = url
.split_once('#')
.map_or((url, None), |(rest, fragment)| (rest, Some(fragment)));
let (prefix, query) = rest
.split_once('?')
.map_or((rest, None), |(prefix, query)| (prefix, Some(query)));
let mut out = prefix.to_string();
if let Some(query) = query {
out.push('?');
out.push_str(&redact_pairs(query));
}
if let Some(fragment) = fragment {
out.push('#');
out.push_str(&redact_pairs(fragment));
}
out
}
pub fn new_id() -> String {
let millis = chrono::Utc::now().timestamp_millis().max(0);
let suffix = rand::random::<u64>();
format!("{millis:013}-{suffix:016x}")
}
fn now_rfc3339_millis() -> String {
chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
}
fn cwd() -> String {
std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_default()
}
fn system_user() -> String {
if let Ok(user) = std::env::var("USER") {
if !user.is_empty() {
return user;
}
}
#[cfg(unix)]
{
if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid()) {
return user.name;
}
}
String::new()
}
fn hostname() -> String {
#[cfg(unix)]
{
if let Ok(name) = nix::unistd::gethostname() {
if let Some(name) = name.to_str() {
if !name.is_empty() {
return name.to_string();
}
}
}
}
std::env::var("HOSTNAME").unwrap_or_default()
}
const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
fn whitelisted_env() -> BTreeMap<String, String> {
std::env::vars()
.filter(|(k, _)| k.starts_with("OMNI_DEV_"))
.map(|(k, v)| {
let secretish = SECRETISH.iter().any(|needle| k.contains(needle));
let value = if secretish { "REDACTED".to_string() } else { v };
(k, value)
})
.collect()
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::test_support::env::MapEnv;
#[test]
fn record_round_trips_through_json() {
let mut rec = LogRecord::new(RecordKind::Http, "inv-1".to_string());
rec.service = Some("jira".to_string());
rec.method = Some("GET".to_string());
rec.url = Some("https://example.atlassian.net/rest/api/3/issue/X-1".to_string());
rec.status_code = Some(200);
rec.elapsed_ms = Some(42);
let line = serde_json::to_string(&rec).unwrap();
let back: LogRecord = serde_json::from_str(&line).unwrap();
assert_eq!(back.invocation_id, "inv-1");
assert_eq!(back.kind, RecordKind::Http);
assert_eq!(back.service.as_deref(), Some("jira"));
assert_eq!(back.status_code, Some(200));
}
#[test]
fn reader_tolerates_unknown_fields() {
let line = r#"{"id":"x","invocation_id":"i","kind":"http","method":"GET",
"future_field":{"nested":true},"another":42}"#;
let rec: LogRecord = serde_json::from_str(line).unwrap();
assert_eq!(rec.kind, RecordKind::Http);
assert_eq!(rec.method.as_deref(), Some("GET"));
}
#[test]
fn reader_tolerates_missing_newer_fields() {
let line = r#"{"kind":"invocation","command":["git","view"]}"#;
let rec: LogRecord = serde_json::from_str(line).unwrap();
assert_eq!(rec.kind, RecordKind::Invocation);
assert_eq!(rec.command, vec!["git", "view"]);
assert!(rec.status_code.is_none());
assert!(rec.id.is_empty());
}
#[test]
fn unknown_kind_and_source_do_not_fail() {
let line = r#"{"kind":"telemetry","source":"webhook"}"#;
let rec: LogRecord = serde_json::from_str(line).unwrap();
assert_eq!(rec.kind, RecordKind::Unknown);
assert_eq!(rec.source, Some(Source::Unknown));
}
#[test]
fn optional_fields_are_skipped_when_empty() {
let rec = LogRecord::new(RecordKind::Invocation, "i".to_string());
let line = serde_json::to_string(&rec).unwrap();
assert!(!line.contains("status_code"));
assert!(!line.contains("request_headers"));
assert!(!line.contains("via_daemon"));
assert!(!line.contains("\"env\""));
}
#[test]
fn ids_are_time_sortable() {
let a = new_id();
std::thread::sleep(std::time::Duration::from_millis(2));
let b = new_id();
assert!(a < b, "{a} should sort before {b}");
}
#[test]
fn sensitive_headers_are_redacted() {
let mut headers = BTreeMap::new();
headers.insert("Authorization".to_string(), "Bearer secret".to_string());
headers.insert("X-Api-Key".to_string(), "abc123".to_string());
headers.insert("Content-Type".to_string(), "application/json".to_string());
let out = redact_headers(&headers);
assert_eq!(out["Authorization"], "REDACTED");
assert_eq!(out["X-Api-Key"], "REDACTED");
assert_eq!(out["Content-Type"], "application/json");
}
fn argv(args: &[&str]) -> Vec<String> {
args.iter().copied().map(String::from).collect()
}
#[test]
fn build_gh_record_stamps_kind_source_and_split_command() {
let ctx = RequestLogContext {
invocation_id: "inv-1".to_string(),
source: Source::Daemon,
mcp_tool: None,
};
let rec = build_gh_record(
GhOutcome {
label: "api graphql".to_string(),
argv: argv(&["api", "graphql", "-f", "query=xyz"]),
exit_code: Some(0),
duration: Duration::from_millis(120),
error: None,
},
ctx,
);
assert_eq!(rec.kind, RecordKind::Gh);
assert_eq!(rec.invocation_id, "inv-1");
assert_eq!(rec.source, Some(Source::Daemon));
assert_eq!(rec.command, argv(&["api", "graphql"]));
assert_eq!(
rec.command_line,
argv(&["api", "graphql", "-f", "query=xyz"])
);
assert_eq!(rec.exit_code, Some(0));
assert_eq!(rec.duration_ms, Some(120));
assert!(rec.error.is_none());
}
#[test]
fn build_gh_record_scrubs_secret_bearing_argv() {
let rec = build_gh_record(
GhOutcome {
label: "api graphql".to_string(),
argv: argv(&["api", "--header", "Authorization: Bearer sekret"]),
exit_code: Some(0),
duration: Duration::from_millis(5),
error: None,
},
RequestLogContext::default(),
);
assert_eq!(
rec.command_line,
argv(&["api", "--header", "Authorization: REDACTED"])
);
}
#[test]
fn build_worktree_record_stamps_kind_service_command_and_context() {
let ctx = RequestLogContext {
invocation_id: "inv-2".to_string(),
source: Source::Mcp,
mcp_tool: Some("some_tool".to_string()),
};
let mut context = BTreeMap::new();
context.insert("path".to_string(), "/tmp/wt".to_string());
context.insert("branch".to_string(), "demo-wt".to_string());
context.insert("had_uncommitted".to_string(), "true".to_string());
let rec = build_worktree_record(
WorktreeOutcome {
verb: "remove".to_string(),
argv: argv(&["worktree", "remove", "--force", "/tmp/wt"]),
exit_code: Some(0),
duration: Duration::from_millis(42),
error: None,
context,
},
ctx,
);
assert_eq!(rec.kind, RecordKind::Worktree);
assert_eq!(rec.invocation_id, "inv-2");
assert_eq!(rec.source, Some(Source::Mcp));
assert_eq!(rec.mcp_tool.as_deref(), Some("some_tool"));
assert_eq!(rec.service.as_deref(), Some("worktree"));
assert_eq!(rec.command, argv(&["git", "worktree", "remove"]));
assert_eq!(
rec.command_line,
argv(&["worktree", "remove", "--force", "/tmp/wt"])
);
assert_eq!(rec.exit_code, Some(0));
assert_eq!(rec.duration_ms, Some(42));
assert_eq!(
rec.context.get("branch").map(String::as_str),
Some("demo-wt")
);
assert_eq!(
rec.context.get("had_uncommitted").map(String::as_str),
Some("true")
);
}
#[test]
fn record_kind_worktree_serializes_as_worktree_and_round_trips() {
let rec = build_worktree_record(
WorktreeOutcome {
verb: "add".to_string(),
argv: argv(&["worktree", "add", "wt"]),
exit_code: Some(1),
duration: Duration::from_millis(1),
error: Some("boom".to_string()),
context: BTreeMap::new(),
},
RequestLogContext::default(),
);
let line = serde_json::to_string(&rec).unwrap();
assert!(line.contains("\"kind\":\"worktree\""), "line was: {line}");
assert!(
line.contains("\"service\":\"worktree\""),
"line was: {line}"
);
assert_eq!(RecordKind::Worktree.as_str(), "worktree");
let back: LogRecord = serde_json::from_str(&line).unwrap();
assert_eq!(back.kind, RecordKind::Worktree);
assert_eq!(back.command, argv(&["git", "worktree", "add"]));
assert_eq!(back.error.as_deref(), Some("boom"));
}
#[test]
fn build_drive_mutation_record_stamps_kind_service_command_and_context() {
let ctx = RequestLogContext {
invocation_id: "inv-3".to_string(),
source: Source::Mcp,
mcp_tool: Some("drive_file_move".to_string()),
};
let rec = build_drive_mutation_record(
DriveMutationOutcome {
operation: "move",
file_id: "f1".to_string(),
file_name: "report.pdf".to_string(),
status: "blocked".to_string(),
added_principals: vec!["alice@example.com".to_string()],
removed_principals: vec![],
crosses_drive_boundary: true,
resolved_folder_id: Some("dest1".to_string()),
decided_by_folder_id: Some("dest1".to_string()),
decided_by_depth: Some(0),
error: None,
duration: Duration::from_millis(17),
..Default::default()
},
ctx,
);
assert_eq!(rec.kind, RecordKind::DriveMutation);
assert_eq!(rec.invocation_id, "inv-3");
assert_eq!(rec.source, Some(Source::Mcp));
assert_eq!(rec.mcp_tool.as_deref(), Some("drive_file_move"));
assert_eq!(rec.service.as_deref(), Some("drive"));
assert_eq!(rec.command, vec!["drive".to_string(), "move".to_string()]);
assert_eq!(rec.duration_ms, Some(17));
assert_eq!(rec.context.get("file_id").map(String::as_str), Some("f1"));
assert_eq!(
rec.context.get("file_name").map(String::as_str),
Some("report.pdf")
);
assert_eq!(
rec.context.get("status").map(String::as_str),
Some("blocked")
);
assert_eq!(
rec.context.get("added_principals").map(String::as_str),
Some("alice@example.com")
);
assert_eq!(rec.context.get("removed_principals"), None);
assert_eq!(
rec.context
.get("crosses_drive_boundary")
.map(String::as_str),
Some("true")
);
assert_eq!(
rec.context.get("resolved_folder_id").map(String::as_str),
Some("dest1")
);
assert_eq!(
rec.context.get("decided_by_folder_id").map(String::as_str),
Some("dest1")
);
assert_eq!(
rec.context.get("decided_by_depth").map(String::as_str),
Some("0")
);
}
#[test]
fn build_drive_mutation_record_omits_empty_principal_lists_and_false_boundary() {
let rec = build_drive_mutation_record(
DriveMutationOutcome {
operation: "rename",
file_id: "f2".to_string(),
file_name: "old.txt".to_string(),
status: "moved".to_string(),
added_principals: vec![],
removed_principals: vec![],
crosses_drive_boundary: false,
resolved_folder_id: None,
decided_by_folder_id: None,
decided_by_depth: None,
error: None,
duration: Duration::from_millis(5),
..Default::default()
},
RequestLogContext::default(),
);
assert_eq!(rec.context.get("added_principals"), None);
assert_eq!(rec.context.get("removed_principals"), None);
assert_eq!(rec.context.get("crosses_drive_boundary"), None);
assert_eq!(rec.context.get("resolved_folder_id"), None);
assert_eq!(rec.context.get("decided_by_folder_id"), None);
assert_eq!(rec.context.get("decided_by_depth"), None);
}
#[test]
fn build_drive_mutation_record_includes_the_structural_context_keys() {
let rec = build_drive_mutation_record(
DriveMutationOutcome {
operation: "sheets-insert-rows",
file_id: "s1".to_string(),
file_name: "Budget".to_string(),
status: "changed".to_string(),
sheet_id: Some(118_293),
sheet_title: Some("Q2".to_string()),
dimension_range: Some("ROWS 5:7".to_string()),
duration: Duration::from_millis(1),
..Default::default()
},
RequestLogContext::default(),
);
assert_eq!(rec.command, vec!["drive", "sheets-insert-rows"]);
assert_eq!(
rec.context.get("sheet_id").map(String::as_str),
Some("118293")
);
assert_eq!(
rec.context.get("sheet_title").map(String::as_str),
Some("Q2")
);
assert_eq!(
rec.context.get("dimension_range").map(String::as_str),
Some("ROWS 5:7")
);
assert_eq!(rec.context.get("range"), None);
assert_eq!(rec.context.get("updated_cells"), None);
assert_eq!(rec.context.get("sheet_new_title"), None);
assert_eq!(rec.context.get("grid_range"), None);
assert_eq!(rec.context.get("move_to"), None);
}
#[test]
fn build_drive_mutation_record_includes_the_move_to_key_for_a_move() {
let rec = build_drive_mutation_record(
DriveMutationOutcome {
operation: "sheets-move-rows",
file_id: "s1".to_string(),
file_name: "Budget".to_string(),
status: "changed".to_string(),
sheet_id: Some(118_293),
sheet_title: Some("Q2".to_string()),
dimension_range: Some("ROWS 2:3".to_string()),
move_to: Some(6),
duration: Duration::from_millis(1),
..Default::default()
},
RequestLogContext::default(),
);
assert_eq!(rec.command, vec!["drive", "sheets-move-rows"]);
assert_eq!(
rec.context.get("dimension_range").map(String::as_str),
Some("ROWS 2:3")
);
assert_eq!(rec.context.get("move_to").map(String::as_str), Some("6"));
assert_eq!(rec.context.get("grid_range"), None);
}
#[test]
fn build_drive_mutation_record_includes_the_grid_range_key_for_a_delete_range() {
let rec = build_drive_mutation_record(
DriveMutationOutcome {
operation: "sheets-delete-range",
file_id: "s1".to_string(),
file_name: "Budget".to_string(),
status: "changed".to_string(),
sheet_id: Some(118_293),
sheet_title: Some("Q2".to_string()),
grid_range: Some("rows 2-4, columns 2-3".to_string()),
duration: Duration::from_millis(1),
..Default::default()
},
RequestLogContext::default(),
);
assert_eq!(
rec.context.get("grid_range").map(String::as_str),
Some("rows 2-4, columns 2-3")
);
assert_eq!(rec.context.get("dimension_range"), None);
}
#[test]
fn build_drive_mutation_record_includes_the_formatting_validation_and_protection_keys() {
let rec = build_drive_mutation_record(
DriveMutationOutcome {
operation: "sheets-merge-cells",
file_id: "s1".to_string(),
file_name: "Budget".to_string(),
status: "changed".to_string(),
fields_changed: Some("merged A1:B2".to_string()),
discarded_cells: vec!["B1: gone".to_string()],
validation_type: Some("ONE_OF_LIST".to_string()),
protected_range_id: Some(42),
protection_editors_added: vec!["alice@example.com".to_string()],
protection_editors_removed: vec!["bob@example.com".to_string()],
duration: Duration::from_millis(1),
..Default::default()
},
RequestLogContext::default(),
);
assert_eq!(
rec.context.get("fields_changed").map(String::as_str),
Some("merged A1:B2")
);
assert_eq!(
rec.context.get("discarded_cells").map(String::as_str),
Some("B1: gone")
);
assert_eq!(
rec.context.get("validation_type").map(String::as_str),
Some("ONE_OF_LIST")
);
assert_eq!(
rec.context.get("protected_range_id").map(String::as_str),
Some("42")
);
assert_eq!(
rec.context
.get("protection_editors_added")
.map(String::as_str),
Some("alice@example.com")
);
assert_eq!(
rec.context
.get("protection_editors_removed")
.map(String::as_str),
Some("bob@example.com")
);
}
#[test]
fn build_drive_mutation_record_omits_the_formatting_validation_and_protection_keys_when_unset()
{
let rec = build_drive_mutation_record(
DriveMutationOutcome {
operation: "sheets-write",
file_id: "s1".to_string(),
file_name: "Budget".to_string(),
status: "written".to_string(),
duration: Duration::from_millis(1),
..Default::default()
},
RequestLogContext::default(),
);
assert_eq!(rec.context.get("fields_changed"), None);
assert_eq!(rec.context.get("discarded_cells"), None);
assert_eq!(rec.context.get("validation_type"), None);
assert_eq!(rec.context.get("protected_range_id"), None);
assert_eq!(rec.context.get("protection_editors_added"), None);
assert_eq!(rec.context.get("protection_editors_removed"), None);
}
#[test]
fn build_drive_mutation_record_records_both_titles_for_a_rename() {
let rec = build_drive_mutation_record(
DriveMutationOutcome {
operation: "sheets-rename-sheet",
file_id: "s1".to_string(),
file_name: "Budget".to_string(),
status: "changed".to_string(),
sheet_id: Some(118_293),
sheet_title: Some("Q2".to_string()),
sheet_new_title: Some("Q2 (final)".to_string()),
duration: Duration::from_millis(1),
..Default::default()
},
RequestLogContext::default(),
);
assert_eq!(
rec.context.get("sheet_title").map(String::as_str),
Some("Q2")
);
assert_eq!(
rec.context.get("sheet_new_title").map(String::as_str),
Some("Q2 (final)")
);
assert_eq!(rec.context.get("dimension_range"), None);
}
#[test]
fn build_drive_mutation_record_omits_structural_keys_for_a_cell_write() {
let rec = build_drive_mutation_record(
DriveMutationOutcome {
operation: "sheets-write",
file_id: "s1".to_string(),
file_name: "Budget".to_string(),
status: "written".to_string(),
range: Some("A1:B2".to_string()),
duration: Duration::from_millis(1),
..Default::default()
},
RequestLogContext::default(),
);
assert_eq!(rec.context.get("sheet_id"), None);
assert_eq!(rec.context.get("sheet_title"), None);
assert_eq!(rec.context.get("sheet_new_title"), None);
assert_eq!(rec.context.get("dimension_range"), None);
assert_eq!(rec.context.get("grid_range"), None);
}
#[test]
fn build_drive_mutation_record_includes_range_and_every_sheets_count() {
let rec = build_drive_mutation_record(
DriveMutationOutcome {
operation: "sheets-write",
file_id: "s1".to_string(),
file_name: "Budget".to_string(),
status: "written".to_string(),
removed_principals: vec!["bob@example.com".to_string()],
range: Some("A1:B2".to_string()),
updated_range: Some("'Q1'!A1:B2".to_string()),
updated_rows: Some(3),
updated_columns: Some(2),
updated_cells: Some(6),
duration: Duration::from_millis(1),
..Default::default()
},
RequestLogContext::default(),
);
assert_eq!(
rec.context.get("removed_principals").map(String::as_str),
Some("bob@example.com")
);
assert_eq!(rec.context.get("range").map(String::as_str), Some("A1:B2"));
assert_eq!(
rec.context.get("updated_range").map(String::as_str),
Some("'Q1'!A1:B2")
);
assert_eq!(
rec.context.get("updated_rows").map(String::as_str),
Some("3")
);
assert_eq!(
rec.context.get("updated_columns").map(String::as_str),
Some("2")
);
assert_eq!(
rec.context.get("updated_cells").map(String::as_str),
Some("6")
);
}
#[test]
fn auto_fill_record_includes_only_overwritten_addresses() {
let rec = build_drive_mutation_record(
DriveMutationOutcome {
operation: "sheets-auto-fill",
file_id: "sheet-1".into(),
file_name: "Budget".into(),
status: "changed".into(),
range: Some("'Q1'!A4:A5".into()),
overwritten_cells: vec!["A4".into(), "A5".into()],
duration: Duration::from_millis(1),
..Default::default()
},
RequestLogContext::default(),
);
assert_eq!(
rec.context.get("overwritten_cells").map(String::as_str),
Some("A4, A5")
);
assert_eq!(
rec.context.get("range").map(String::as_str),
Some("'Q1'!A4:A5")
);
assert_eq!(rec.context.get("updated_cells"), None);
assert_eq!(rec.context.get("overwritten_cells_are_upper_bound"), None);
}
#[test]
fn auto_fill_range_form_marks_its_overwritten_cells_as_an_upper_bound() {
let rec = build_drive_mutation_record(
DriveMutationOutcome {
operation: "sheets-auto-fill",
file_id: "sheet-1".into(),
file_name: "Budget".into(),
status: "changed".into(),
range: Some("'Q1'!A1:A10".into()),
overwritten_cells: vec!["A1".into(), "A2".into()],
overwritten_cells_upper_bound: true,
duration: Duration::from_millis(1),
..Default::default()
},
RequestLogContext::default(),
);
assert_eq!(
rec.context
.get("overwritten_cells_are_upper_bound")
.map(String::as_str),
Some("true")
);
}
#[test]
fn text_to_columns_record_always_marks_its_overwritten_cells_as_an_upper_bound() {
let rec = build_drive_mutation_record(
DriveMutationOutcome {
operation: "sheets-text-to-columns",
file_id: "sheet-1".into(),
file_name: "Budget".into(),
status: "changed".into(),
range: Some("'Q1'!A2:A4".into()),
fields_changed: Some(
"split 'Q1'!A2:A4 on comma into up to 2 column(s), spill 'Q1'!B2:B4".into(),
),
overwritten_cells: vec!["B3".into()],
overwritten_cells_upper_bound: true,
duration: Duration::from_millis(1),
..Default::default()
},
RequestLogContext::default(),
);
assert_eq!(
rec.context.get("overwritten_cells").map(String::as_str),
Some("B3")
);
assert_eq!(
rec.context
.get("overwritten_cells_are_upper_bound")
.map(String::as_str),
Some("true")
);
assert!(!rec.context.get("fields_changed").unwrap().contains("a,b"));
}
#[test]
fn record_kind_drive_mutation_serializes_as_drivemutation_and_round_trips() {
let rec = build_drive_mutation_record(
DriveMutationOutcome {
operation: "rename",
file_id: "f1".to_string(),
file_name: "a.txt".to_string(),
status: "failed".to_string(),
added_principals: vec![],
removed_principals: vec![],
crosses_drive_boundary: false,
resolved_folder_id: None,
decided_by_folder_id: None,
decided_by_depth: None,
error: Some("boom".to_string()),
duration: Duration::from_millis(1),
..Default::default()
},
RequestLogContext::default(),
);
let line = serde_json::to_string(&rec).unwrap();
assert!(
line.contains("\"kind\":\"drivemutation\""),
"line was: {line}"
);
assert_eq!(RecordKind::DriveMutation.as_str(), "drivemutation");
let back: LogRecord = serde_json::from_str(&line).unwrap();
assert_eq!(back.kind, RecordKind::DriveMutation);
assert_eq!(
back.command,
vec!["drive".to_string(), "rename".to_string()]
);
assert_eq!(back.error.as_deref(), Some("boom"));
}
#[test]
fn build_drive_mutation_record_round_trips_for_create_operation() {
let rec = build_drive_mutation_record(
DriveMutationOutcome {
operation: "create",
file_id: "f1".to_string(),
file_name: "New File".to_string(),
status: "created".to_string(),
added_principals: vec![],
removed_principals: vec![],
crosses_drive_boundary: false,
resolved_folder_id: Some("parent1".to_string()),
decided_by_folder_id: None,
decided_by_depth: None,
error: None,
duration: Duration::from_millis(1),
..Default::default()
},
RequestLogContext::default(),
);
assert_eq!(rec.command, vec!["drive".to_string(), "create".to_string()]);
assert_eq!(
rec.context.get("resolved_folder_id").map(String::as_str),
Some("parent1")
);
assert_eq!(rec.context.get("decided_by_folder_id"), None);
let line = serde_json::to_string(&rec).unwrap();
let back: LogRecord = serde_json::from_str(&line).unwrap();
assert_eq!(back.kind, RecordKind::DriveMutation);
}
#[test]
fn record_kind_gh_serializes_as_gh_and_round_trips() {
let rec = build_gh_record(
GhOutcome {
label: "pr list".to_string(),
argv: argv(&["pr", "list"]),
exit_code: Some(1),
duration: Duration::from_millis(1),
error: Some("boom".to_string()),
},
RequestLogContext::default(),
);
let line = serde_json::to_string(&rec).unwrap();
assert!(line.contains("\"kind\":\"gh\""), "line was: {line}");
let back: LogRecord = serde_json::from_str(&line).unwrap();
assert_eq!(back.kind, RecordKind::Gh);
assert_eq!(back.command, argv(&["pr", "list"]));
assert_eq!(back.error.as_deref(), Some("boom"));
}
#[test]
fn build_audit_record_stamps_kind_service_command_and_context() {
let ctx = RequestLogContext {
invocation_id: "inv-4".to_string(),
source: Source::Cli,
mcp_tool: None,
};
let rec = build_audit_record(
AuditOutcome {
command: vec!["drive".to_string(), "lease-acquire".to_string()],
integration: "drive",
file_id: "f1".to_string(),
lease_id: Some("lease-1".to_string()),
verdict: "acquired".to_string(),
version_after: Some("42".to_string()),
modified_time_after: Some("2026-09-12T00:00:00Z".to_string()),
backup_location: Some("/tmp/backup".to_string()),
backup_sha256: Some("deadbeef".to_string()),
backup_size: Some(5),
auth_policy: Some("device-owner".to_string()),
..Default::default()
},
ctx,
);
assert_eq!(rec.kind, RecordKind::Audit);
assert_eq!(rec.invocation_id, "inv-4");
assert_eq!(rec.source, Some(Source::Cli));
assert_eq!(rec.service.as_deref(), Some("drive"));
assert_eq!(
rec.command,
vec!["drive".to_string(), "lease-acquire".to_string()]
);
assert_eq!(
rec.context.get("integration").map(String::as_str),
Some("drive")
);
assert_eq!(rec.context.get("file_id").map(String::as_str), Some("f1"));
assert_eq!(
rec.context.get("lease_id").map(String::as_str),
Some("lease-1")
);
assert_eq!(
rec.context.get("verdict").map(String::as_str),
Some("acquired")
);
assert_eq!(
rec.context.get("version_after").map(String::as_str),
Some("42")
);
assert_eq!(
rec.context.get("modified_time_after").map(String::as_str),
Some("2026-09-12T00:00:00Z")
);
assert_eq!(
rec.context.get("backup_location").map(String::as_str),
Some("/tmp/backup")
);
assert_eq!(
rec.context.get("backup_sha256").map(String::as_str),
Some("deadbeef")
);
assert_eq!(
rec.context.get("backup_size").map(String::as_str),
Some("5")
);
assert_eq!(
rec.context.get("auth_policy").map(String::as_str),
Some("device-owner")
);
}
#[test]
fn build_audit_record_omits_absent_optional_context_keys() {
let rec = build_audit_record(
AuditOutcome {
command: vec!["drive".to_string(), "lease-acquire".to_string()],
integration: "drive",
file_id: "f1".to_string(),
verdict: "denied".to_string(),
error: Some("no".to_string()),
..Default::default()
},
RequestLogContext::default(),
);
assert_eq!(rec.error.as_deref(), Some("no"));
assert_eq!(rec.context.get("lease_id"), None);
assert_eq!(rec.context.get("version_before"), None);
assert_eq!(rec.context.get("version_after"), None);
assert_eq!(rec.context.get("backup_location"), None);
assert_eq!(rec.context.get("backup_sha256"), None);
assert_eq!(rec.context.get("backup_size"), None);
assert_eq!(rec.context.get("auth_policy"), None);
}
#[test]
fn record_audit_event_writes_to_the_audit_sink() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let env = MapEnv::new().with("OMNI_DEV_AUDIT_LOG_FILE", path.to_str().unwrap());
record_audit_event_with(
&env,
AuditOutcome {
command: vec!["drive".to_string(), "lease-acquire".to_string()],
integration: "drive",
file_id: "f1".to_string(),
verdict: "acquired".to_string(),
..Default::default()
},
)
.unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
let back: LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
assert_eq!(back.kind, RecordKind::Audit);
assert_eq!(
back.context.get("verdict").map(String::as_str),
Some("acquired")
);
}
#[test]
fn record_kind_audit_serializes_as_audit_and_round_trips() {
let rec = LogRecord {
kind: RecordKind::Audit,
id: new_id(),
invocation_id: new_id(),
..LogRecord::default()
};
let line = serde_json::to_string(&rec).unwrap();
assert!(line.contains("\"kind\":\"audit\""), "line was: {line}");
assert_eq!(RecordKind::Audit.as_str(), "audit");
let back: LogRecord = serde_json::from_str(&line).unwrap();
assert_eq!(back.kind, RecordKind::Audit);
}
#[test]
fn audit_file_path_honors_env_override() {
let env = MapEnv::new().with("OMNI_DEV_AUDIT_LOG_FILE", "/tmp/omni-dev-test-audit.jsonl");
assert_eq!(
audit_file_path_with(&env),
Some(PathBuf::from("/tmp/omni-dev-test-audit.jsonl"))
);
}
#[test]
fn audit_file_path_never_resolves_to_the_real_machine_default_when_unset() {
let real_default = dirs::state_dir()
.or_else(dirs::data_dir)
.unwrap()
.join("omni-dev")
.join(AUDIT_FILE_NAME);
let unset = MapEnv::new();
let unrouted = audit_file_path_with(&unset).unwrap();
assert_eq!(
unrouted,
audit_file_path_with(&unset).unwrap(),
"the fallback path must be stable across calls"
);
assert_ne!(
unrouted, real_default,
"must never fall back to the real machine's default audit log path in a test build"
);
assert_eq!(
audit_file_path().unwrap(),
unrouted,
"the ambient entry point with no thread-local route lands on the same scratch file"
);
}
#[test]
fn record_audit_writes_a_line_that_round_trips() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let env = MapEnv::new().with("OMNI_DEV_AUDIT_LOG_FILE", path.to_str().unwrap());
let rec = LogRecord {
kind: RecordKind::Audit,
id: new_id(),
invocation_id: new_id(),
..LogRecord::default()
};
record_audit_with(&env, &rec).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
let back: LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
assert_eq!(back.kind, RecordKind::Audit);
assert_eq!(back.id, rec.id);
}
#[test]
fn record_audit_locks_and_syncs_the_handle_when_bodies_are_enabled() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let env = MapEnv::new()
.with("OMNI_DEV_LOG_BODIES", "1")
.with("OMNI_DEV_AUDIT_LOG_FILE", path.to_str().unwrap());
assert!(bodies_enabled_with(&env));
let rec = LogRecord {
kind: RecordKind::Audit,
id: new_id(),
invocation_id: new_id(),
..LogRecord::default()
};
record_audit_with(&env, &rec).unwrap();
let contents = std::fs::read_to_string(&path).unwrap();
let back: LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
assert_eq!(back.id, rec.id);
}
#[test]
fn record_audit_fails_closed_when_the_write_errors() {
let dir = tempfile::tempdir().unwrap();
let rec = LogRecord {
kind: RecordKind::Audit,
id: new_id(),
invocation_id: new_id(),
..LogRecord::default()
};
let err = append_record_to(dir.path(), &rec, append_line_no_rotation).unwrap_err();
assert!(!err.to_string().is_empty());
}
#[test]
fn record_audit_is_exempt_from_omni_dev_log_disable() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
let env = MapEnv::new()
.with("OMNI_DEV_LOG_DISABLE", "1")
.with("OMNI_DEV_AUDIT_LOG_FILE", path.to_str().unwrap());
assert!(disabled_with(&env));
let rec = LogRecord {
kind: RecordKind::Audit,
id: new_id(),
invocation_id: new_id(),
..LogRecord::default()
};
record_audit_with(&env, &rec).unwrap();
assert!(path.exists());
}
#[test]
fn the_audit_write_path_never_gates_on_log_disable() {
let source = include_str!("request_log.rs");
let start = source
.find("pub fn record_audit(entry: &LogRecord) -> anyhow::Result<()> {")
.expect("record_audit signature not found");
let end = source[start..]
.find("/// Serializes `entry` and appends it to `path` via `append`,")
.expect("append_record_to doc comment not found")
+ start;
let audit_write_path = &source[start..end];
assert!(
!audit_write_path.contains("disabled"),
"the fail-closed audit sink must never gate on OMNI_DEV_LOG_DISABLE:\n{audit_write_path}"
);
}
#[test]
fn try_record_refuses_an_audit_kind_entry() {
let rec = LogRecord {
kind: RecordKind::Audit,
id: new_id(),
invocation_id: new_id(),
..LogRecord::default()
};
let err = try_record(&rec).unwrap_err();
assert!(err.to_string().contains("record_audit()"), "{err}");
}
#[test]
fn record_audit_refuses_a_non_audit_kind_entry() {
let dir = tempfile::tempdir().unwrap();
let env = MapEnv::new().with(
"OMNI_DEV_AUDIT_LOG_FILE",
dir.path().join("audit.jsonl").to_str().unwrap(),
);
let rec = LogRecord {
kind: RecordKind::Invocation,
id: new_id(),
invocation_id: new_id(),
..LogRecord::default()
};
let err = record_audit_with(&env, &rec).unwrap_err();
assert!(err.to_string().contains("RecordKind::Audit"), "{err}");
}
#[test]
fn log_file_path_with_refuses_when_it_resolves_to_the_audit_file() {
let dir = tempfile::tempdir().unwrap();
let shared = dir.path().join("shared.jsonl");
let shared_str = shared.to_str().unwrap();
let env = MapEnv::new()
.with("OMNI_DEV_LOG_FILE", shared_str)
.with("OMNI_DEV_AUDIT_LOG_FILE", shared_str);
assert!(log_file_path_with(&env).is_none());
}
#[test]
fn log_file_path_with_refuses_a_dot_dot_spelling_of_the_audit_file() {
let dir = tempfile::tempdir().unwrap();
let audit = dir.path().join("audit.jsonl");
std::fs::write(&audit, "{}\n").unwrap();
let dotted = dir.path().join("sub/../audit.jsonl");
let env = MapEnv::new()
.with("OMNI_DEV_LOG_FILE", dotted.to_str().unwrap())
.with("OMNI_DEV_AUDIT_LOG_FILE", audit.to_str().unwrap());
assert!(log_file_path_with(&env).is_none());
}
#[test]
fn record_audit_with_succeeds_when_the_log_file_env_aliases_the_audit_path() {
let dir = tempfile::tempdir().unwrap();
let shared = dir.path().join("shared.jsonl");
let shared_str = shared.to_str().unwrap();
let env = MapEnv::new()
.with("OMNI_DEV_LOG_FILE", shared_str)
.with("OMNI_DEV_AUDIT_LOG_FILE", shared_str);
let rec = LogRecord {
kind: RecordKind::Audit,
id: new_id(),
invocation_id: new_id(),
..LogRecord::default()
};
record_audit_with(&env, &rec).unwrap();
assert!(shared.exists());
assert!(log_file_path_with(&env).is_none());
}
#[test]
fn scrub_argv_redacts_sensitive_header_in_both_forms() {
let out = scrub_argv(&argv(&[
"omni-dev",
"--header",
"Authorization: Bearer sekret",
"--header=Cookie: session=abc",
]));
assert_eq!(
out,
argv(&[
"omni-dev",
"--header",
"Authorization: REDACTED",
"--header=Cookie: REDACTED",
])
);
}
#[test]
fn scrub_argv_keeps_non_sensitive_headers() {
let input = argv(&["omni-dev", "--header", "Content-Type: application/json"]);
assert_eq!(scrub_argv(&input), input);
}
#[test]
fn scrub_argv_redacts_colonless_header_wholesale() {
let out = scrub_argv(&argv(&["omni-dev", "--header", "sekret"]));
assert_eq!(out, argv(&["omni-dev", "--header", "REDACTED"]));
}
#[test]
fn scrub_argv_redacts_inline_body_but_keeps_at_file() {
let out = scrub_argv(&argv(&["omni-dev", "--body", r#"{"secret":1}"#]));
assert_eq!(out, argv(&["omni-dev", "--body", "REDACTED"]));
let file_form = argv(&["omni-dev", "--body", "@payload.json"]);
assert_eq!(scrub_argv(&file_form), file_form);
let out = scrub_argv(&argv(&["omni-dev", "--body=sekret"]));
assert_eq!(out, argv(&["omni-dev", "--body=REDACTED"]));
}
#[test]
fn scrub_argv_redacts_find_replace_content_in_both_flag_forms() {
let out = scrub_argv(&argv(&[
"omni-dev",
"--find",
"customer@example.com",
"--replacement=redacted",
]));
assert_eq!(
out,
argv(&["omni-dev", "--find", "REDACTED", "--replacement=REDACTED",])
);
}
#[test]
fn scrub_argv_redacts_secretish_flag_values() {
let out = scrub_argv(&argv(&["omni-dev", "--api-key", "abc", "--auth-token=xyz"]));
assert_eq!(
out,
argv(&["omni-dev", "--api-key", "REDACTED", "--auth-token=REDACTED"])
);
}
#[test]
fn scrub_argv_exempts_path_flags_and_positionals() {
let input = argv(&["omni-dev", "--token-file", "/tmp/t", "PROJ-123"]);
assert_eq!(scrub_argv(&input), input);
}
#[test]
fn scrub_argv_redacts_secret_bearing_url_query_in_both_forms() {
let space = scrub_argv(&argv(&[
"omni-dev",
"browser",
"bridge",
"request",
"--url",
"/api/export?access_token=hunter2&sig=deadbeef&page=3",
]));
assert_eq!(
*space.last().unwrap(),
"/api/export?access_token=REDACTED&sig=REDACTED&page=3"
);
let eq_form = scrub_argv(&argv(&[
"omni-dev",
"--url=/api/export?access_token=hunter2&page=3",
]));
assert_eq!(
*eq_form.last().unwrap(),
"--url=/api/export?access_token=REDACTED&page=3"
);
let positional = scrub_argv(&argv(&["omni-dev", "https://h/cb#id_token=xyz"]));
assert_eq!(
*positional.last().unwrap(),
"https://h/cb#id_token=REDACTED"
);
}
#[test]
fn scrub_argv_leaves_benign_argv_byte_identical() {
let input = argv(&[
"omni-dev",
"browser",
"bridge",
"request",
"--control-port",
"19998",
"--url",
"/api/export?page=3&sort=asc",
]);
assert_eq!(scrub_argv(&input), input);
}
#[test]
fn scrub_argv_handles_trailing_flag_without_value() {
let input = argv(&["omni-dev", "--body"]);
assert_eq!(scrub_argv(&input), input);
}
#[cfg(unix)]
#[test]
fn append_line_creates_file_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("log.jsonl");
append_line(&path, "{\"kind\":\"http\"}\n").unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"{\"kind\":\"http\"}\n"
);
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[cfg(unix)]
#[test]
fn append_line_retightens_preexisting_loose_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("log.jsonl");
std::fs::write(&path, "old\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
append_line(&path, "new\n").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[cfg(unix)]
#[test]
fn append_line_synced_appends_in_order_across_the_creating_and_later_writes() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
append_line_synced(&path, "first\n").unwrap();
append_line_synced(&path, "second\n").unwrap();
append_line_synced(&path, "third\n").unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"first\nsecond\nthird\n"
);
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[cfg(unix)]
#[test]
fn append_line_synced_retightens_preexisting_loose_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
std::fs::write(&path, "old\n").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
append_line_synced(&path, "new\n").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn append_line_synced_errors_when_the_target_is_a_directory() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
std::fs::create_dir(&path).unwrap();
assert!(append_line_synced(&path, "line\n").is_err());
}
#[test]
fn append_line_synced_appends_to_an_existing_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
std::fs::write(&path, "").unwrap();
append_line_synced(&path, "line\n").unwrap();
assert_eq!(std::fs::read_to_string(&path).unwrap(), "line\n");
}
#[test]
fn off_list_secretish_headers_are_redacted() {
let mut headers = BTreeMap::new();
for name in [
"X-Auth-Token",
"x-amz-security-token",
"X-Goog-Api-Key",
"x-csrf-token",
"X-Vendor-Token",
"X-Omni-Bridge",
] {
headers.insert(name.to_string(), "secret-value".to_string());
}
for name in [
"Content-Type",
"Accept",
"User-Agent",
"x-request-id",
"traceparent",
] {
headers.insert(name.to_string(), "plain-value".to_string());
}
let out = redact_headers(&headers);
assert_eq!(out["X-Auth-Token"], "REDACTED");
assert_eq!(out["x-amz-security-token"], "REDACTED");
assert_eq!(out["X-Goog-Api-Key"], "REDACTED");
assert_eq!(out["x-csrf-token"], "REDACTED");
assert_eq!(out["X-Vendor-Token"], "REDACTED");
assert_eq!(out["X-Omni-Bridge"], "REDACTED");
assert_eq!(out["Content-Type"], "plain-value");
assert_eq!(out["Accept"], "plain-value");
assert_eq!(out["User-Agent"], "plain-value");
assert_eq!(out["x-request-id"], "plain-value");
assert_eq!(out["traceparent"], "plain-value");
}
#[test]
fn url_without_query_is_unchanged() {
assert_eq!(redact_url("https://h/p"), "https://h/p");
assert_eq!(redact_url("/relative/p"), "/relative/p");
}
#[test]
fn benign_query_is_byte_identical() {
let url = "https://h/p?q=a%20b&page=2&&x=y+z&keyword=k&sort_key=s&token_type=bearer";
assert_eq!(redact_url(url), url);
}
#[test]
fn sensitive_query_values_are_redacted() {
let url = "https://h/p?token=a&access_token=b&client_secret=c&api_key=d&x=1";
assert_eq!(
redact_url(url),
"https://h/p?token=REDACTED&access_token=REDACTED&client_secret=REDACTED\
&api_key=REDACTED&x=1"
);
}
#[test]
fn presigned_s3_query_is_redacted() {
let url = "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=AWS4-HMAC-SHA256\
&X-Amz-Credential=AKIA%2F20260703%2Fus-east-1%2Fs3%2Faws4_request\
&X-Amz-Date=20260703T000000Z&X-Amz-Expires=3600\
&X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef";
assert_eq!(
redact_url(url),
"https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=REDACTED\
&X-Amz-Credential=REDACTED&X-Amz-Date=REDACTED&X-Amz-Expires=REDACTED\
&X-Amz-SignedHeaders=REDACTED&X-Amz-Signature=REDACTED"
);
}
#[test]
fn key_matching_is_case_insensitive() {
assert_eq!(
redact_url("/p?TOKEN=x&Api_Key=y&X-Amz-Signature=z"),
"/p?TOKEN=REDACTED&Api_Key=REDACTED&X-Amz-Signature=REDACTED"
);
}
#[test]
fn repeated_sensitive_keys_are_each_redacted() {
assert_eq!(redact_url("/p?sig=a&sig=b"), "/p?sig=REDACTED&sig=REDACTED");
}
#[test]
fn valueless_key_is_left_alone() {
assert_eq!(redact_url("/p?token"), "/p?token");
assert_eq!(redact_url("/p?token="), "/p?token=REDACTED");
}
#[test]
fn relative_url_query_is_redacted() {
assert_eq!(
redact_url("/api/foo?sig=abc&x=y"),
"/api/foo?sig=REDACTED&x=y"
);
}
#[test]
fn fragment_credentials_are_redacted() {
assert_eq!(
redact_url("https://h/cb#access_token=xyz&token_type=bearer"),
"https://h/cb#access_token=REDACTED&token_type=bearer"
);
}
#[test]
fn query_and_fragment_are_scrubbed_independently() {
assert_eq!(
redact_url("/p?sig=a#id_token=b"),
"/p?sig=REDACTED#id_token=REDACTED"
);
}
#[test]
fn question_mark_in_fragment_is_not_parsed_as_query() {
assert_eq!(
redact_url("https://h/p#frag?token=x"),
"https://h/p#frag?token=REDACTED"
);
}
#[test]
fn encoded_sensitive_key_is_decoded_before_matching() {
assert_eq!(
redact_url("/p?access%5Ftoken=v"),
"/p?access%5Ftoken=REDACTED"
);
}
#[test]
fn empty_query_is_unchanged() {
assert_eq!(redact_url("https://h/p?"), "https://h/p?");
assert_eq!(redact_url("https://h/p?#f"), "https://h/p?#f");
}
#[test]
fn parse_size_handles_units_and_bare_bytes() {
assert_eq!(parse_size("1048576").unwrap(), 1024 * 1024);
assert_eq!(parse_size("512b").unwrap(), 512);
assert_eq!(parse_size("10kb").unwrap(), 10 * 1024);
assert_eq!(parse_size("2K").unwrap(), 2 * 1024);
assert_eq!(parse_size("3mb").unwrap(), 3 * 1024 * 1024);
assert_eq!(parse_size("1gb").unwrap(), 1024 * 1024 * 1024);
assert_eq!(parse_size("1.5mb").unwrap(), (1.5 * 1024.0 * 1024.0) as u64);
assert_eq!(parse_size(" 4mib ").unwrap(), 4 * 1024 * 1024);
}
#[test]
fn parse_size_rejects_garbage() {
assert!(parse_size("").is_err());
assert!(parse_size("mb").is_err());
assert!(parse_size("10tb").is_err());
assert!(parse_size("-5mb").is_err());
}
#[test]
fn sibling_appends_to_final_component() {
let base = Path::new("/tmp/omni/log.jsonl");
assert_eq!(sibling(base, ".1"), Path::new("/tmp/omni/log.jsonl.1"));
assert_eq!(
sibling(base, ".lock"),
Path::new("/tmp/omni/log.jsonl.lock")
);
}
#[test]
fn keep_by_size_keeps_most_recent_that_fit() {
let lines = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"];
let refs: Vec<&str> = lines.to_vec();
assert_eq!(keep_by_size(&refs, 22), &["cccccccccc", "dddddddddd"]);
assert_eq!(keep_by_size(&refs, 1), &["dddddddddd"]);
assert_eq!(keep_by_size(&refs, 10_000), &refs[..]);
assert!(keep_by_size(&[], 100).is_empty());
}
#[test]
fn keep_by_age_is_conservative_on_undateable_lines() {
let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
.unwrap()
.with_timezone(&Utc);
let old = r#"{"kind":"http","timestamp":"2026-01-01T00:00:00.000Z"}"#;
let new = r#"{"kind":"http","timestamp":"2026-12-01T00:00:00.000Z"}"#;
let undated = r#"{"kind":"http"}"#;
let malformed = "not json at all";
assert!(!keep_by_age(old, Some(cutoff)));
assert!(keep_by_age(new, Some(cutoff)));
assert!(keep_by_age(undated, Some(cutoff)), "undated is kept");
assert!(keep_by_age(malformed, Some(cutoff)), "malformed is kept");
assert!(keep_by_age(old, None), "no filter keeps everything");
}
fn http_line(id: &str, ts: &str) -> String {
format!(r#"{{"id":"{id}","kind":"http","timestamp":"{ts}"}}"#)
}
#[test]
fn prune_by_age_drops_old_records_and_rewrites_atomically() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("log.jsonl");
let body = format!(
"{}\n{}\n{}\n",
http_line("1", "2026-01-01T00:00:00.000Z"),
http_line("2", "2026-06-15T00:00:00.000Z"),
http_line("3", "2026-12-31T00:00:00.000Z"),
);
std::fs::write(&path, &body).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
.unwrap()
.with_timezone(&Utc);
let outcome = prune(
&path,
&PruneOptions {
older_than: Some(cutoff),
max_size: None,
dry_run: false,
},
)
.unwrap();
assert_eq!(outcome.removed, 1);
assert_eq!(outcome.kept, 2);
let contents = std::fs::read_to_string(&path).unwrap();
assert!(!contents.contains(r#""id":"1""#));
assert!(contents.contains(r#""id":"2""#));
assert!(contents.contains(r#""id":"3""#));
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn prune_dry_run_reports_without_modifying() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("log.jsonl");
let body = format!(
"{}\n{}\n",
http_line("1", "2026-01-01T00:00:00.000Z"),
http_line("2", "2026-12-31T00:00:00.000Z"),
);
std::fs::write(&path, &body).unwrap();
let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
.unwrap()
.with_timezone(&Utc);
let outcome = prune(
&path,
&PruneOptions {
older_than: Some(cutoff),
max_size: None,
dry_run: true,
},
)
.unwrap();
assert_eq!(outcome.removed, 1);
assert_eq!(std::fs::read_to_string(&path).unwrap(), body);
}
#[test]
fn prune_by_size_keeps_the_newest_that_fit() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("log.jsonl");
let l1 = http_line("1", "2026-01-01T00:00:00.000Z");
let l2 = http_line("2", "2026-06-15T00:00:00.000Z");
let l3 = http_line("3", "2026-12-31T00:00:00.000Z");
std::fs::write(&path, format!("{l1}\n{l2}\n{l3}\n")).unwrap();
let budget = (l2.len() + 1 + l3.len() + 1) as u64;
let outcome = prune(
&path,
&PruneOptions {
older_than: None,
max_size: Some(budget),
dry_run: false,
},
)
.unwrap();
assert_eq!(outcome.removed, 1);
assert_eq!(outcome.kept, 2);
let contents = std::fs::read_to_string(&path).unwrap();
assert!(!contents.contains(r#""id":"1""#));
assert!(contents.contains(r#""id":"3""#));
}
#[test]
fn prune_missing_file_is_a_noop() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("absent.jsonl");
let outcome = prune(
&path,
&PruneOptions {
older_than: None,
max_size: Some(1),
dry_run: false,
},
)
.unwrap();
assert_eq!(outcome.removed, 0);
assert_eq!(outcome.kept, 0);
assert!(!path.exists());
}
#[cfg(unix)]
#[test]
fn rotation_shifts_numbered_files_and_drops_the_oldest() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("log.jsonl");
let cfg = RotationConfig {
max_size: 20,
keep_files: 2,
};
let line = "0123456789012345\n"; for _ in 0..4 {
append_with_rotation(&path, line, &cfg).unwrap();
}
assert!(path.exists());
assert!(sibling(&path, ".1").exists());
assert!(sibling(&path, ".2").exists());
assert!(!sibling(&path, ".3").exists());
assert_eq!(
std::fs::metadata(sibling(&path, ".1"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
#[cfg(unix)]
#[test]
fn rotation_keep_zero_discards_on_overflow() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("log.jsonl");
let cfg = RotationConfig {
max_size: 20,
keep_files: 0,
};
let line = "0123456789012345\n"; append_with_rotation(&path, line, &cfg).unwrap();
append_with_rotation(&path, line, &cfg).unwrap();
assert!(!sibling(&path, ".1").exists());
assert_eq!(std::fs::read_to_string(&path).unwrap(), line);
}
#[test]
fn parse_size_rejects_overflow_to_infinity() {
assert!(parse_size(&"9".repeat(400)).is_err());
}
#[test]
fn same_file_matches_identical_paths() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("audit.jsonl");
assert!(same_file(&path, &path));
}
#[test]
fn same_file_matches_a_dot_dot_spelling_routed_through_a_nonexistent_directory() {
let dir = tempfile::tempdir().unwrap();
let audit = dir.path().join("audit.jsonl");
std::fs::write(&audit, "{}\n").unwrap();
let dotted = dir.path().join("sub/../audit.jsonl");
assert!(same_file(&audit, &dotted));
}
#[test]
fn same_file_matches_a_hard_link() {
let dir = tempfile::tempdir().unwrap();
let original = dir.path().join("audit.jsonl");
std::fs::write(&original, "{}\n").unwrap();
let linked = dir.path().join("linked.jsonl");
std::fs::hard_link(&original, &linked).unwrap();
assert!(same_file(&original, &linked));
}
#[cfg(unix)]
#[test]
fn same_file_matches_a_symlink_to_an_existing_file() {
let dir = tempfile::tempdir().unwrap();
let original = dir.path().join("audit.jsonl");
std::fs::write(&original, "{}\n").unwrap();
let link = dir.path().join("link.jsonl");
std::os::unix::fs::symlink(&original, &link).unwrap();
assert!(same_file(&original, &link));
}
#[cfg(unix)]
#[test]
fn same_file_matches_a_symlink_to_a_not_yet_existing_target() {
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("audit.jsonl"); let link = dir.path().join("link.jsonl");
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(same_file(&target, &link));
}
#[test]
fn same_file_rejects_two_distinct_existing_files() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.jsonl");
let b = dir.path().join("b.jsonl");
std::fs::write(&a, "a").unwrap();
std::fs::write(&b, "b").unwrap();
assert!(!same_file(&a, &b));
}
#[test]
fn same_file_rejects_two_distinct_paths_that_both_do_not_exist() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a.jsonl");
let b = dir.path().join("b.jsonl");
assert!(!same_file(&a, &b));
}
#[test]
fn lexically_normalize_collapses_dot_dot_without_touching_disk() {
assert_eq!(
lexically_normalize(Path::new("a/sub/../b")),
PathBuf::from("a/b")
);
assert_eq!(
lexically_normalize(Path::new("./a/./b")),
PathBuf::from("a/b")
);
assert_eq!(
lexically_normalize(Path::new("../a")),
PathBuf::from("../a")
);
}
#[test]
fn normalize_returns_the_lexical_form_unchanged_when_there_is_nothing_left_to_canonicalize() {
assert_eq!(normalize(Path::new("")), PathBuf::new());
}
#[test]
fn prune_surfaces_a_read_error() {
let dir = tempfile::tempdir().unwrap();
let result = prune(
dir.path(),
&PruneOptions {
older_than: None,
max_size: Some(1),
dry_run: false,
},
);
assert!(result.is_err());
}
#[test]
fn prune_refuses_the_audit_file_by_direct_path() {
let dir = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let audit_path = dir.path().join("audit.jsonl");
std::fs::write(&audit_path, "{}\n").unwrap();
let err = prune(
&audit_path,
&PruneOptions {
older_than: None,
max_size: Some(1),
dry_run: false,
},
)
.err()
.unwrap();
assert!(err.to_string().contains("audit log"), "{err}");
assert_eq!(std::fs::read_to_string(&audit_path).unwrap(), "{}\n");
}
#[test]
fn prune_refuses_a_dot_dot_spelling_of_the_audit_file() {
let dir = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let audit_path = dir.path().join("audit.jsonl");
std::fs::write(&audit_path, "{}\n").unwrap();
let dotted = dir.path().join("sub/../audit.jsonl");
let err = prune(
&dotted,
&PruneOptions {
older_than: None,
max_size: Some(1),
dry_run: false,
},
)
.err()
.unwrap();
assert!(err.to_string().contains("audit log"), "{err}");
assert_eq!(std::fs::read_to_string(&audit_path).unwrap(), "{}\n");
}
#[test]
fn prune_refuses_the_audit_file_even_under_dry_run() {
let dir = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let audit_path = dir.path().join("audit.jsonl");
std::fs::write(&audit_path, "{}\n").unwrap();
let err = prune(
&audit_path,
&PruneOptions {
older_than: None,
max_size: Some(1),
dry_run: true,
},
)
.err()
.unwrap();
assert!(err.to_string().contains("audit log"), "{err}");
}
#[cfg(unix)]
#[test]
fn append_with_rotation_refuses_to_rotate_the_audit_file() {
let dir = tempfile::tempdir().unwrap();
let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
let audit_path = dir.path().join("audit.jsonl");
std::fs::write(&audit_path, "0123456789012345\n").unwrap();
let cfg = RotationConfig {
max_size: 5,
keep_files: 1,
};
append_with_rotation(&audit_path, "new-line\n", &cfg).unwrap();
assert!(
!sibling(&audit_path, ".1").exists(),
"the audit file must never be rotated"
);
assert!(
!sibling(&audit_path, ".lock").exists(),
"a refused rotation must not create a lock file next to the audit file"
);
assert!(
std::fs::read_to_string(&audit_path)
.unwrap()
.contains("new-line"),
"the line is still appended (best effort)"
);
}
#[cfg(unix)]
#[test]
fn append_with_rotation_appends_even_when_rotate_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("log.jsonl");
std::fs::write(&path, "0123456789012345\n").unwrap();
std::fs::create_dir(sibling(&path, ".1")).unwrap();
let cfg = RotationConfig {
max_size: 5,
keep_files: 1,
};
append_with_rotation(&path, "new-line\n", &cfg).unwrap();
assert!(
std::fs::read_to_string(&path).unwrap().contains("new-line"),
"the record is appended despite the rotation failure"
);
}
#[test]
fn prune_cleans_up_temp_on_rewrite_failure() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("log.jsonl");
std::fs::write(
&path,
format!(
"{}\n{}\n",
http_line("a", "2999-01-01T00:00:00.000Z"),
http_line("b", "2999-01-01T00:00:00.000Z"),
),
)
.unwrap();
let tmp = sibling(&path, &format!(".prune.{}.tmp", std::process::id()));
std::fs::create_dir(&tmp).unwrap();
let result = prune(
&path,
&PruneOptions {
older_than: None,
max_size: Some(1),
dry_run: false,
},
);
assert!(result.is_err(), "a failing rewrite surfaces as an error");
let _ = std::fs::remove_dir(&tmp);
}
#[tokio::test]
async fn scope_origin_id_overwrites_id_but_preserves_source() {
let base = RequestLogContext {
invocation_id: "daemon-1".to_string(),
source: Source::Daemon,
mcp_tool: None,
};
CTX.scope(base, async {
scope_origin_id("cli-42".to_string(), async {
let ctx = current_context();
assert_eq!(ctx.invocation_id, "cli-42");
assert_eq!(ctx.source, Source::Daemon);
})
.await;
assert_eq!(current_context().invocation_id, "daemon-1");
})
.await;
}
}