Skip to main content

omni_dev/
request_log.rs

1//! Append-only, local invocation + HTTP request log (`log.jsonl`).
2//!
3//! Every `omni-dev` invocation appends one `kind: "invocation"` line; every
4//! outbound HTTP request made by one of the integration clients appends one
5//! `kind: "http"` line correlated to it by a shared `invocation_id`. The log
6//! is **local-machine state** written under the platform state/data directory
7//! (`0700` dir / `0600` file, the same posture as [`crate::daemon::paths`]).
8//!
9//! Design invariants:
10//!
11//! - **Best effort.** [`record`] swallows every error (logging only at
12//!   `tracing::debug`); a logging failure can never change the program's exit
13//!   code. Honors `OMNI_DEV_LOG_DISABLE=1` for an absolute opt-out of this
14//!   log only — [`record_audit`]'s fail-closed sink is deliberately exempt
15//!   (see its own doc comment).
16//! - **No secrets.** Auth headers/tokens are never written; only a non-secret
17//!   `auth_principal` identity is kept. Headers are redacted centrally
18//!   ([`redact_headers`]), secret-bearing URL query/fragment parameter values
19//!   are redacted (`redact_url`) before writing, and request/response bodies
20//!   are opt-in via `OMNI_DEV_LOG_BODIES=1`.
21//! - **Forward compatible.** A single [`LogRecord`] is used for both writing
22//!   and reading: every field is `#[serde(default)]`, and every optional field
23//!   is `skip_serializing_if`, so a newer reader never chokes on an older line
24//!   and an older reader never chokes on a newer one — the same forward-rolling
25//!   contract the daemon wire types use.
26
27use std::collections::BTreeMap;
28use std::io::Write;
29use std::path::{Path, PathBuf};
30use std::sync::OnceLock;
31use std::time::{Duration, Instant};
32
33use chrono::{DateTime, SecondsFormat, Utc};
34use serde::{Deserialize, Serialize};
35
36use crate::utils::env::{non_empty_var, truthy_var, EnvSource, SystemEnv};
37
38/// Default log file name under the runtime directory.
39const LOG_FILE_NAME: &str = "log.jsonl";
40
41/// Default audit log file name under the runtime directory — a sibling of
42/// [`LOG_FILE_NAME`] ([ADR-0080](../docs/adrs/adr-0080.md) §11). Distinctness
43/// from the default `log.jsonl` path is enforced at write time by
44/// [`record_audit`], not merely implied by having a different constant.
45const AUDIT_FILE_NAME: &str = "audit.jsonl";
46
47/// Number of rotated log files kept by default when
48/// [`OMNI_DEV_LOG_MAX_SIZE`](rotation_config) enables rotation but
49/// `OMNI_DEV_LOG_KEEP_FILES` is unset. (Rotation on write is unix-only.)
50#[cfg(unix)]
51const DEFAULT_KEEP_FILES: u32 = 3;
52
53/// Which kind of record a line holds. Unknown future kinds deserialize to
54/// [`RecordKind::Unknown`] rather than failing the read.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum RecordKind {
58    /// One per process invocation (or per MCP tool call).
59    #[default]
60    Invocation,
61    /// One per outbound HTTP request.
62    Http,
63    /// One per `gh` CLI subprocess invocation (the only path to the GitHub API;
64    /// the token never enters our process, so these are subprocess records, not
65    /// [`RecordKind::Http`]). See `crate::github_metrics`.
66    Gh,
67    /// One per wrapped `git worktree` subprocess invocation, carrying
68    /// recovery-relevant metadata (path/branch/commit) in `context`.
69    /// See `crate::cli::git` worktree subcommands.
70    Worktree,
71    /// One per Drive/Sheets/Docs content-mutating attempt — `create`,
72    /// `upload`, `edit`, `rename`, `move`, and every Sheets/Docs write,
73    /// structural, delete or protection verb — including a refused
74    /// `Blocked` attempt for any of them, where the refusal itself is the
75    /// security-relevant event. Covers every operation via `command`/
76    /// `context`, mirroring how [`RecordKind::Worktree`] covers multiple
77    /// verbs rather than one kind per verb. See `crate::drive`.
78    DriveMutation,
79    /// One per leased-write lifecycle event — lease acquire, a write under
80    /// a lease, expiry/release, restore, and every refusal along the way —
81    /// written to the separate, fail-closed `audit.jsonl` sink rather than
82    /// this log ([ADR-0080](../docs/adrs/adr-0080.md) §11). Distinct from
83    /// [`RecordKind::DriveMutation`], which stays the best-effort record
84    /// written here for the same underlying API call: a leased write
85    /// produces both, correlated by `invocation_id`. See
86    /// [`record_audit`]/[`audit_file_path`].
87    Audit,
88    /// A kind written by a newer version that this reader does not know.
89    #[serde(other)]
90    Unknown,
91}
92
93impl RecordKind {
94    /// Stable lowercase name, used for display and JSON map keys (matches the
95    /// `serde(rename_all = "lowercase")` wire form).
96    #[must_use]
97    pub fn as_str(self) -> &'static str {
98        match self {
99            Self::Invocation => "invocation",
100            Self::Http => "http",
101            Self::Gh => "gh",
102            Self::Worktree => "worktree",
103            Self::DriveMutation => "drivemutation",
104            Self::Audit => "audit",
105            Self::Unknown => "unknown",
106        }
107    }
108}
109
110/// What drove an invocation. Unknown future sources deserialize to
111/// [`Source::Unknown`].
112#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
113#[serde(rename_all = "lowercase")]
114pub enum Source {
115    /// A direct `omni-dev` CLI invocation.
116    #[default]
117    Cli,
118    /// An `omni-dev-mcp` tool call.
119    Mcp,
120    /// Work performed inside the long-lived daemon process.
121    Daemon,
122    /// A source written by a newer version that this reader does not know.
123    #[serde(other)]
124    Unknown,
125}
126
127/// One line of the log. Used for both writing and reading; every field is
128/// `#[serde(default)]` (tolerant reads) and every optional field is
129/// `skip_serializing_if` (compact, forward-compatible writes).
130#[derive(Debug, Clone, Default, Serialize, Deserialize)]
131pub struct LogRecord {
132    // --- Core fields (present on every record) ---
133    /// Per-record, time-sortable id (see [`new_id`]).
134    #[serde(default)]
135    pub id: String,
136    /// Shared by an invocation record and every HTTP record it spawned.
137    #[serde(default)]
138    pub invocation_id: String,
139    /// Discriminates the record type.
140    #[serde(default)]
141    pub kind: RecordKind,
142    /// RFC3339 timestamp with milliseconds.
143    #[serde(default)]
144    pub timestamp: String,
145    /// Host the record was written on.
146    #[serde(default)]
147    pub hostname: String,
148    /// Writing process id.
149    #[serde(default)]
150    pub pid: u32,
151    /// `omni-dev` version that wrote the record.
152    #[serde(default)]
153    pub omni_dev_version: String,
154    /// Working directory at write time.
155    #[serde(default)]
156    pub cwd: String,
157    /// OS user that owns the process.
158    #[serde(default)]
159    pub system_user: String,
160
161    // --- `kind: "invocation"` fields ---
162    /// Resolved clap subcommand path, e.g. `["jira","read"]`.
163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
164    pub command: Vec<String>,
165    /// Full argv.
166    #[serde(default, skip_serializing_if = "Vec::is_empty")]
167    pub command_line: Vec<String>,
168    /// Process exit code (0 success, 1 error — matches `die`).
169    #[serde(default, skip_serializing_if = "Option::is_none")]
170    pub exit_code: Option<i32>,
171    /// Wall time of the whole invocation.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub duration_ms: Option<u64>,
174    /// Whitelisted, non-secret `OMNI_DEV_*` env snapshot.
175    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
176    pub env: BTreeMap<String, String>,
177    /// What drove the run (`cli`/`mcp`/`daemon`).
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub source: Option<Source>,
180    /// When `source = mcp`, the tool name that drove the run.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub mcp_tool: Option<String>,
183
184    // --- `kind: "http"` fields ---
185    /// Coarse service tag (`jira`/`confluence`/`datadog`/…) for fast filtering.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub service: Option<String>,
188    /// HTTP method.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub method: Option<String>,
191    /// Request URL.
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub url: Option<String>,
194    /// Response status; absent on a network/transport error.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub status_code: Option<u16>,
197    /// Elapsed time of the request.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub elapsed_ms: Option<u64>,
200    /// True when the request ran inside the daemon (bridge/Snowflake pool).
201    #[serde(default, skip_serializing_if = "is_false")]
202    pub via_daemon: bool,
203    /// Which pooled daemon session served the request.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub daemon_session_id: Option<String>,
206    /// Non-secret identity actually used (token id / OAuth principal) — never
207    /// the secret itself.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub auth_principal: Option<String>,
210    /// Redacted request headers (only when `OMNI_DEV_LOG_HEADERS=1`).
211    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
212    pub request_headers: BTreeMap<String, String>,
213    /// Redacted response headers (only when `OMNI_DEV_LOG_HEADERS=1`).
214    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
215    pub response_headers: BTreeMap<String, String>,
216    /// Request body (only when `OMNI_DEV_LOG_BODIES=1`).
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub request_body: Option<String>,
219    /// Response body (only when `OMNI_DEV_LOG_BODIES=1`).
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub response_body: Option<String>,
222    /// Free-form correlation tags.
223    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
224    pub context: BTreeMap<String, String>,
225
226    // --- shared optional ---
227    /// Top-level error chain (invocation) or per-request error (http).
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub error: Option<String>,
230}
231
232/// `skip_serializing_if` predicate for `bool` fields that default to `false`.
233#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires `fn(&T) -> bool`
234fn is_false(b: &bool) -> bool {
235    !*b
236}
237
238impl LogRecord {
239    /// Builds a record carrying only the always-present core fields.
240    fn new(kind: RecordKind, invocation_id: String) -> Self {
241        Self {
242            id: new_id(),
243            invocation_id,
244            kind,
245            timestamp: now_rfc3339_millis(),
246            hostname: hostname(),
247            pid: std::process::id(),
248            omni_dev_version: crate::VERSION.to_string(),
249            cwd: cwd(),
250            system_user: system_user(),
251            ..Self::default()
252        }
253    }
254}
255
256/// The per-invocation context every record is stamped with.
257///
258/// Held once per process in [`GLOBAL`] (CLI/daemon) and overridden per task in
259/// [`CTX`] (the multiplexed MCP server), so HTTP records can find their parent
260/// invocation without threading state through every call site.
261#[derive(Debug, Clone)]
262pub struct RequestLogContext {
263    /// Shared id linking an invocation to the HTTP it spawned.
264    pub invocation_id: String,
265    /// What drove the run.
266    pub source: Source,
267    /// MCP tool name when `source = mcp`.
268    pub mcp_tool: Option<String>,
269}
270
271impl Default for RequestLogContext {
272    fn default() -> Self {
273        Self {
274            invocation_id: new_id(),
275            source: Source::Cli,
276            mcp_tool: None,
277        }
278    }
279}
280
281impl RequestLogContext {
282    /// A CLI context with a freshly minted invocation id.
283    pub fn cli() -> Self {
284        Self {
285            invocation_id: new_id(),
286            source: Source::Cli,
287            mcp_tool: None,
288        }
289    }
290
291    /// An MCP context for a single tool call.
292    pub fn mcp(tool: impl Into<String>) -> Self {
293        Self {
294            invocation_id: new_id(),
295            source: Source::Mcp,
296            mcp_tool: Some(tool.into()),
297        }
298    }
299}
300
301static GLOBAL: OnceLock<RequestLogContext> = OnceLock::new();
302
303tokio::task_local! {
304    /// Per-task context override, set around each MCP tool dispatch.
305    pub static CTX: RequestLogContext;
306}
307
308/// Installs the process-global context. The first call wins (the CLI/daemon
309/// shell sets it once, very early); later calls are ignored.
310pub fn set_global(ctx: RequestLogContext) {
311    let _ = GLOBAL.set(ctx);
312}
313
314/// Resolves the active context: task-local override first, then the
315/// process-global default, then a synthesized fallback.
316pub fn current_context() -> RequestLogContext {
317    if let Ok(ctx) = CTX.try_with(RequestLogContext::clone) {
318        return ctx;
319    }
320    if let Some(ctx) = GLOBAL.get() {
321        return ctx.clone();
322    }
323    RequestLogContext::default()
324}
325
326/// Runs `fut` with the active context's `invocation_id` replaced by
327/// `origin_id`, preserving `source` and `mcp_tool`.
328///
329/// The daemon and the browser bridge scope this around a request they serve on
330/// behalf of a CLI/MCP client, so the HTTP records that request spawns
331/// correlate to the *originating* invocation rather than the server's own
332/// (#1198). `source` is deliberately preserved: a request served inside the
333/// daemon keeps `source = Daemon`, so `via_daemon` detection is unaffected while
334/// `invocation_id` now points at the caller's invocation record.
335pub async fn scope_origin_id<F, T>(origin_id: String, fut: F) -> T
336where
337    F: std::future::Future<Output = T>,
338{
339    let mut ctx = current_context();
340    ctx.invocation_id = origin_id;
341    CTX.scope(ctx, fut).await
342}
343
344/// Whether logging is disabled entirely (`OMNI_DEV_LOG_DISABLE=1`).
345pub fn disabled() -> bool {
346    disabled_with(&SystemEnv)
347}
348
349/// [`disabled`], reading through an injected [`EnvSource`] (STYLE-0028).
350fn disabled_with(env: &impl EnvSource) -> bool {
351    truthy_var(env, "OMNI_DEV_LOG_DISABLE")
352}
353
354/// Whether request/response bodies may be recorded (`OMNI_DEV_LOG_BODIES=1`).
355pub fn bodies_enabled() -> bool {
356    bodies_enabled_with(&SystemEnv)
357}
358
359/// [`bodies_enabled`], reading through an injected [`EnvSource`] (STYLE-0028).
360fn bodies_enabled_with(env: &impl EnvSource) -> bool {
361    truthy_var(env, "OMNI_DEV_LOG_BODIES")
362}
363
364/// Whether (redacted) headers may be recorded (`OMNI_DEV_LOG_HEADERS=1`).
365pub fn headers_enabled() -> bool {
366    truthy_var(&SystemEnv, "OMNI_DEV_LOG_HEADERS")
367}
368
369/// The default location of an omni-dev runtime file: `state_dir` (falling
370/// back to `data_dir`) joined with `omni-dev/<component>`. `pub(crate)`
371/// rather than private: the Drive lease ledger and its default backup
372/// directory (`drive::lease::ledger::ledger_path`,
373/// `cli::drive::lease::default_backup_dir`) resolve their own location the
374/// same way and share this rather than re-deriving it.
375pub(crate) fn omni_dev_state_subpath(component: &str) -> Option<PathBuf> {
376    let base = dirs::state_dir().or_else(dirs::data_dir)?;
377    Some(base.join("omni-dev").join(component))
378}
379
380/// Resolves the log file path: `OMNI_DEV_LOG_FILE` override, else
381/// `state_dir` (falling back to `data_dir`) joined with `omni-dev/log.jsonl`.
382pub fn log_file_path() -> Option<PathBuf> {
383    log_file_path_with(&SystemEnv)
384}
385
386/// [`log_file_path`], reading through an injected [`EnvSource`]
387/// (STYLE-0028). `pub(crate)` rather than private: `cli::log`'s
388/// `LogCommand::resolve_path_with` needs it for its own env-injected test.
389///
390/// Refuses (returns `None`) when the resolved path names the same file as
391/// [`audit_file_path_with`] — an `OMNI_DEV_LOG_FILE` misconfiguration that
392/// would otherwise let ordinary best-effort records
393/// (`try_record`/`append_with_rotation`) and `prune` reach the fail-closed
394/// audit sink, since none of those consumers get a chance to guard against
395/// it themselves once a colliding path has already resolved
396/// ([#1747](https://github.com/rust-works/omni-dev/issues/1747), ADR-0080
397/// §11). Centralizing the refusal here — rather than relying on each
398/// consumer's own check, as `prune` and `append_with_rotation` still do for
399/// defense in depth against a caller that bypasses this resolver — means
400/// every future consumer of [`log_file_path`] is covered automatically.
401pub(crate) fn log_file_path_with(env: &impl EnvSource) -> Option<PathBuf> {
402    let path = non_empty_var(env, "OMNI_DEV_LOG_FILE")
403        .map(PathBuf::from)
404        .or_else(|| omni_dev_state_subpath(LOG_FILE_NAME))?;
405    if resolves_to_audit_file_with(&path, env) {
406        tracing::warn!(
407            "request_log: OMNI_DEV_LOG_FILE resolves to the audit log ({}); refusing to use it \
408             as the request log path — set OMNI_DEV_LOG_FILE and/or OMNI_DEV_AUDIT_LOG_FILE to \
409             distinct paths",
410            path.display()
411        );
412        return None;
413    }
414    Some(path)
415}
416
417/// Resolves the audit log file path.
418///
419/// `OMNI_DEV_AUDIT_LOG_FILE` override, else `state_dir` (falling back to
420/// `data_dir`) joined with `omni-dev/audit.jsonl` — a sibling of
421/// [`log_file_path`]'s default, resolved through the same two helpers so
422/// the two sinks' policy can't drift apart. Since either can be redirected
423/// independently via its own env override, nothing here *guarantees* the
424/// two stay distinct; [`record_audit`] checks that at write time instead
425/// of merely assuming it.
426///
427/// The body is the same in every build; only `test_audit_file_override`
428/// differs. In a release build it is always `None`, so the env override
429/// and the real default are all there is. In a test build it routes
430/// **per thread** (see that function): a thread that opted into nothing
431/// is pinned to a shared scratch file and never sees the env var or the
432/// real default, so no test can pollute another's file — or the machine's
433/// — by omission.
434pub fn audit_file_path() -> Option<PathBuf> {
435    test_audit_file_override().or_else(|| audit_file_path_with(&SystemEnv))
436}
437
438/// The pure override-or-default half of [`audit_file_path`]: honors
439/// `OMNI_DEV_AUDIT_LOG_FILE` through an injected [`EnvSource`] (STYLE-0028),
440/// deliberately **not** consulting `TEST_AUDIT_ROUTE` — that per-thread
441/// routing is a test-build-only safety net for the *ambient* entry point,
442/// orthogonal to this function's own override-or-default algorithm.
443/// `pub(crate)` rather than private: `cli::log`'s `LogCommand::resolve_path_with`
444/// needs it for its own env-injected test.
445pub(crate) fn audit_file_path_with(env: &impl EnvSource) -> Option<PathBuf> {
446    non_empty_var(env, "OMNI_DEV_AUDIT_LOG_FILE")
447        .map(PathBuf::from)
448        .or_else(default_audit_file_path)
449}
450
451/// Collapses `.`/`..` components **syntactically**, with no filesystem
452/// access — so `a/sub/../b` becomes `a/b` even when `sub` does not exist on
453/// disk. [`normalize`] needs this pass before it ever calls
454/// [`std::fs::canonicalize`]: canonicalizing a parent directory (its
455/// fallback for a path that doesn't exist yet) itself requires that parent
456/// to exist, and a `..` segment routed through a nonexistent intermediate
457/// directory would otherwise never resolve — exactly the `..`-spelling
458/// [`same_file()`] exists to catch. A leading `..` (or one immediately after
459/// a root/prefix) has nothing to pop, so it is kept as-is.
460fn lexically_normalize(path: &Path) -> PathBuf {
461    use std::path::Component;
462
463    let mut out = PathBuf::new();
464    for component in path.components() {
465        match component {
466            Component::CurDir => {}
467            Component::ParentDir => match out.components().next_back() {
468                Some(Component::Normal(_)) => {
469                    out.pop();
470                }
471                _ => out.push(".."),
472            },
473            other => out.push(other.as_os_str()),
474        }
475    }
476    out
477}
478
479/// Bounds how many symlinks [`normalize`] follows by hand, purely so a
480/// symlink cycle can't loop forever; a real cycle already fails
481/// `canonicalize` and is vanishingly unlikely to matter here, but the
482/// budget makes termination structural rather than assumed.
483const MAX_HAND_FOLLOWED_SYMLINK_HOPS: u32 = 8;
484
485/// Resolves `path` as close to a canonical form as possible without
486/// requiring it to exist. Lexically collapses `.`/`..` first
487/// ([`lexically_normalize`]), then tries [`std::fs::canonicalize`] (an
488/// existing file, with every symlink in the chain followed); a target that
489/// does not exist yet fails that outright, so a symlink is then followed by
490/// hand — load-bearing for a `OMNI_DEV_LOG_FILE` symlink whose target
491/// `audit.jsonl` has not been created yet, since a plain "canonicalize the
492/// parent" would otherwise compare the link's own name against the target's
493/// and answer "different" right up until the first write creates the target
494/// through the link. Falls back to canonicalizing the parent and rejoining
495/// the file name, and finally to the lexically-normalized path unchanged
496/// when even that has nothing to canonicalize (`file_name()` is `None` for
497/// `/`, `.`, a bare `..`, …).
498///
499/// This only exists for [`same_file()`]'s not-yet-existing-target fallback —
500/// when both paths already exist, `same_file::is_same_file` handles
501/// symlinks (and Windows file identity) itself and this is never reached.
502fn normalize(path: &Path) -> PathBuf {
503    normalize_with_budget(&lexically_normalize(path), MAX_HAND_FOLLOWED_SYMLINK_HOPS)
504}
505
506/// The recursive body of [`normalize`]. `path` is always already lexically
507/// normalized on entry (both the initial call and the recursive one below
508/// maintain that). Each `if let Ok(..)` below falls through to the next,
509/// weaker resolution strategy on any error — expected and unremarkable here
510/// (`NotFound` is the common case, since this only runs once
511/// `same_file::is_same_file` has already failed to resolve one side), so
512/// none of them are worth logging.
513fn normalize_with_budget(path: &Path, hops: u32) -> PathBuf {
514    if let Ok(canon) = std::fs::canonicalize(path) {
515        return canon;
516    }
517    if hops > 0 {
518        if let Ok(meta) = std::fs::symlink_metadata(path) {
519            if meta.file_type().is_symlink() {
520                if let Ok(target) = std::fs::read_link(path) {
521                    let resolved = if target.is_absolute() {
522                        target
523                    } else {
524                        path.parent()
525                            .filter(|p| !p.as_os_str().is_empty())
526                            .unwrap_or_else(|| Path::new("."))
527                            .join(target)
528                    };
529                    return normalize_with_budget(&lexically_normalize(&resolved), hops - 1);
530                }
531            }
532        }
533    }
534    let Some(file_name) = path.file_name() else {
535        return path.to_path_buf();
536    };
537    let parent = path
538        .parent()
539        .filter(|p| !p.as_os_str().is_empty())
540        .unwrap_or_else(|| Path::new("."));
541    match std::fs::canonicalize(parent) {
542        Ok(canon_parent) => canon_parent.join(file_name),
543        Err(_) => path.to_path_buf(),
544    }
545}
546
547/// Whether `a` and `b` name the same file, robust to `..` segments,
548/// relative-vs-absolute spellings, and symlinks — unlike a raw `PathBuf`
549/// compare, which [`record_audit`]'s collision guard used to rely on and
550/// which all three pass straight through.
551///
552/// Delegates to the [`same_file`](mod@same_file) crate's `is_same_file`
553/// first — real file-identity comparison (inode on unix, a file handle on
554/// Windows), rather than a hand-rolled `(dev, ino)` check that would have
555/// no non-unix equivalent. That call requires both paths to exist, though,
556/// so it fails for the case that actually motivated this function: a
557/// `OMNI_DEV_LOG_FILE` symlink whose target `audit.jsonl` has not been
558/// created yet. [`normalize`] is the fallback for exactly that gap.
559fn same_file(a: &Path, b: &Path) -> bool {
560    if let Ok(same) = same_file::is_same_file(a, b) {
561        return same;
562    }
563    normalize(a) == normalize(b)
564}
565
566/// Whether `path` resolves to the same file as [`audit_file_path`] — the
567/// check [`prune`] and [`append_with_rotation`] refuse on, so neither
568/// destructive operation can be pointed at the fail-closed audit sink by a
569/// `..`-spelled, relative, or symlinked `OMNI_DEV_LOG_FILE` (ADR-0080 §11).
570///
571/// Both callers check this once, before reading/rewriting `path`, not
572/// through a handle held across the whole operation — a symlink at `path`
573/// repointed to the audit file in the narrow window between this check and
574/// the subsequent read still slips through (an attacker able to time that
575/// swap already needs write access to the log directory, i.e. this same
576/// user's own account). This narrows that window, it does not close it —
577/// the same "narrows, does not close" shape as
578/// [`crate::daemon::paths::try_lock_file_exclusive`]'s own inode re-check.
579fn resolves_to_audit_file(path: &Path) -> bool {
580    audit_file_path().is_some_and(|audit| same_file(path, &audit))
581}
582
583/// [`resolves_to_audit_file`], reading the audit path through an injected
584/// [`EnvSource`] instead of the ambient [`audit_file_path`] — the
585/// [`log_file_path_with`] guard needs to compare against
586/// [`audit_file_path_with`] (same env, same test seam), not the ambient
587/// function's per-thread test override, which is orthogonal to this
588/// comparison. Deliberately does **not** short-circuit via `?` on a
589/// non-resolving audit path: an audit path that can't be resolved at all
590/// means there is nothing to collide with, not that the request-log path
591/// should be refused.
592fn resolves_to_audit_file_with(path: &Path, env: &impl EnvSource) -> bool {
593    audit_file_path_with(env).is_some_and(|audit| same_file(path, &audit))
594}
595
596/// Release builds have no per-thread override.
597#[cfg(not(test))]
598fn test_audit_file_override() -> Option<PathBuf> {
599    None
600}
601
602/// The per-thread routing a test build applies before falling back to the
603/// scratch default (`crate::test_support::AuditLogGuard` sets it).
604///
605/// Per thread rather than via an env var, and defaulting to the scratch
606/// file rather than to the real machine default, both on purpose. Dozens of
607/// engine tests exercise a leased write without caring about the audit
608/// record it writes; if those fell through to the real default, any of them
609/// would silently write into the machine's actual `audit.jsonl`. So an
610/// un-opted thread is pinned to the scratch file unconditionally, and only
611/// a thread holding an [`crate::test_support::AuditLogGuard`] sees its own
612/// isolated path. A test that wants to observe `OMNI_DEV_AUDIT_LOG_FILE`
613/// honoring an override calls [`audit_file_path_with`] directly with an
614/// injected [`EnvSource`] instead of going through this ambient routing at
615/// all (STYLE-0028).
616///
617/// Being per-thread does mean the writes a test wants to observe must
618/// happen on the test's own thread: `#[tokio::test]`'s current-thread
619/// runtime and even a `multi_thread` runtime's root future both run
620/// there, but a `spawn_blocking`/`tokio::spawn`ed write lands in the
621/// shared scratch file instead.
622#[cfg(test)]
623fn test_audit_file_override() -> Option<PathBuf> {
624    TEST_AUDIT_ROUTE
625        .with(|slot| slot.borrow().clone())
626        .or_else(default_audit_file_path)
627}
628
629#[cfg(test)]
630thread_local! {
631    pub(crate) static TEST_AUDIT_ROUTE: std::cell::RefCell<Option<PathBuf>> =
632        const { std::cell::RefCell::new(None) };
633}
634
635/// Where [`audit_file_path`] lands when `OMNI_DEV_AUDIT_LOG_FILE` is unset.
636#[cfg(not(test))]
637fn default_audit_file_path() -> Option<PathBuf> {
638    omni_dev_state_subpath(AUDIT_FILE_NAME)
639}
640
641/// Test-build variant of the function above: one shared scratch path for
642/// the whole test binary's process lifetime, never the real machine's
643/// default. Reached for any un-opted thread, via `test_audit_file_override`.
644///
645/// ADR-0080 §9/§11 made `check_and_lock_lease` (and `drive lease acquire`)
646/// write an audit record on essentially every lease-checking code path, so
647/// *any* test anywhere in the crate that exercises a leased write or an
648/// acquire without itself redirecting the audit log would otherwise
649/// silently write real-looking rows into a developer's or CI runner's
650/// actual `audit.jsonl` — which is exactly what happened before this
651/// fallback existed. A test that wants to inspect its *own* audit output
652/// redirects explicitly (`crate::test_support::AuditLogGuard`) rather than
653/// rely on this shared fallback, which many tests write into concurrently
654/// — and which is why a fail-closed intent write to it never fails
655/// (nothing ever makes it unwritable, unlike a test's own redirected
656/// path). The `tempdir` is deliberately never cleaned up (`mem::forget`)
657/// — a `cargo test` process is short-lived, and the alternative is the
658/// actual risk this function exists to close off.
659#[cfg(test)]
660#[allow(clippy::expect_used)]
661fn default_audit_file_path() -> Option<PathBuf> {
662    use std::sync::OnceLock;
663    static PATH: OnceLock<PathBuf> = OnceLock::new();
664    Some(
665        PATH.get_or_init(|| {
666            let dir =
667                tempfile::tempdir().expect("failed to create a scratch dir for test audit logs");
668            let path = dir.path().join(AUDIT_FILE_NAME);
669            std::mem::forget(dir);
670            path
671        })
672        .clone(),
673    )
674}
675
676/// Appends one record. Best effort: every error is swallowed (logged at
677/// `tracing::debug`) so logging can never affect the caller's exit code.
678pub fn record(entry: &LogRecord) {
679    if disabled() {
680        return;
681    }
682    if let Err(e) = try_record(entry) {
683        tracing::debug!("request_log: failed to append record: {e}");
684    }
685}
686
687/// The fallible append used by [`record`]; all errors flow back to be swallowed.
688///
689/// Refuses a [`RecordKind::Audit`] entry outright: that kind belongs to the
690/// fail-closed sink only ([`record_audit`]), and routing it through this
691/// best-effort path would silently subject a forensic record to
692/// `OMNI_DEV_LOG_DISABLE`, rotation and `prune`.
693fn try_record(entry: &LogRecord) -> anyhow::Result<()> {
694    use anyhow::{ensure, Context};
695
696    ensure!(
697        entry.kind != RecordKind::Audit,
698        "an audit-kind record must go through record_audit(), not record()"
699    );
700    let path = log_file_path().context("could not resolve the log file path")?;
701    append_record_to(&path, entry, append_line)
702}
703
704/// Appends one record to the fail-closed audit sink (`audit.jsonl`), for a
705/// leased-write lifecycle event or refusal
706/// ([ADR-0080](../docs/adrs/adr-0080.md) §11).
707///
708/// Unlike [`record`], this is **not** best-effort: it is not gated on
709/// [`disabled`] (`OMNI_DEV_LOG_DISABLE` has no effect here — exemption from
710/// that switch is the point of a forensic log), takes no part in rotation or
711/// [`prune`], and returns every error to the caller instead of swallowing
712/// it. A caller whose write-ahead intent record fails to land here must not
713/// perform the mutation it was about to audit — that is what "fail-closed"
714/// means for this sink; see `record`'s own doc comment for the contrasting,
715/// deliberately best-effort contract every other record in this module
716/// keeps.
717///
718/// Refuses outright if `entry.kind` isn't [`RecordKind::Audit`]. The reverse
719/// misconfiguration — `OMNI_DEV_LOG_FILE` naming the audit file, which would
720/// otherwise let best-effort records land in the fail-closed sink — no
721/// longer needs a check here: [`log_file_path`] itself now refuses to
722/// resolve a path that aliases [`audit_file_path`]
723/// ([#1747](https://github.com/rust-works/omni-dev/issues/1747), ADR-0080
724/// §11), so no request-log path this function could be handed ever collides
725/// with `path` in the first place.
726///
727/// The appended line is `fsync`ed (`sync_data`, then the parent directory
728/// on unix so a freshly created file's entry is durable too) before this
729/// returns — the one place in the module that pays for it. "Durably
730/// written before the mutating call" (ADR-0080 §11) has to survive more
731/// than the process dying: a plain `write` only reaches the page cache,
732/// which an OS crash or power loss between this return and the mutating
733/// call would discard, leaving a mutation with no intent record — the
734/// exact gap the sink exists to make impossible. Two syncs per leased
735/// write are cheap; the best-effort request log deliberately pays neither.
736pub fn record_audit(entry: &LogRecord) -> anyhow::Result<()> {
737    use anyhow::Context;
738
739    let path = audit_file_path().context("could not resolve the audit log file path")?;
740    record_audit_to(path, entry)
741}
742
743/// [`record_audit`], reading through an injected [`EnvSource`] (STYLE-0028)
744/// instead of the ambient, thread-local-routed [`audit_file_path`] /
745/// [`log_file_path`] — a `MapEnv`-driven seam for tests that want to assert
746/// on the write itself without process-global env mutation or the
747/// scratch-file safety net getting in the way. No production caller needs
748/// this: every ambient caller wants the scratch-pin safety net, so this
749/// seam only exists for direct unit testing.
750#[cfg(test)]
751fn record_audit_with(env: &impl EnvSource, entry: &LogRecord) -> anyhow::Result<()> {
752    use anyhow::Context;
753
754    let path = audit_file_path_with(env).context("could not resolve the audit log file path")?;
755    record_audit_to(path, entry)
756}
757
758/// Shared write path for [`record_audit`] and `record_audit_with`: `path` is
759/// already resolved, so this only validates and appends.
760fn record_audit_to(path: PathBuf, entry: &LogRecord) -> anyhow::Result<()> {
761    use anyhow::ensure;
762
763    ensure!(
764        entry.kind == RecordKind::Audit,
765        "record_audit() requires a RecordKind::Audit entry, got {:?}",
766        entry.kind
767    );
768    append_record_to(&path, entry, append_line_synced)
769}
770
771/// Serializes `entry` and appends it to `path` via `append`, creating a
772/// missing `0700` parent directory first. Shared by [`try_record`] (passing
773/// [`append_line`], which opts into rotation when configured) and
774/// [`record_audit`] (passing [`append_line_synced`], which never rotates
775/// regardless of `OMNI_DEV_LOG_MAX_SIZE` and syncs the line to disk) — the
776/// two differ only in path resolution and which appender they hand in, not
777/// in how a line actually gets written.
778fn append_record_to(
779    path: &Path,
780    entry: &LogRecord,
781    append: fn(&Path, &str) -> anyhow::Result<()>,
782) -> anyhow::Result<()> {
783    use anyhow::Context;
784
785    // Only create and tighten the parent when it's missing — re-`chmod`ing an
786    // existing dir (e.g. a user-chosen OMNI_DEV_LOG_FILE location, or a shared
787    // temp dir) is both wrong and may fail; the file itself is always 0600.
788    if let Some(parent) = path.parent() {
789        if !parent.as_os_str().is_empty() && !parent.exists() {
790            crate::daemon::paths::ensure_dir_0700(parent)?;
791        }
792    }
793    let mut line = serde_json::to_string(entry).context("failed to serialize record")?;
794    line.push('\n');
795    append(path, &line)?;
796    Ok(())
797}
798
799/// Appends a single line with `O_APPEND | O_CREATE`, creating the file `0600`.
800/// A pre-existing looser-perm file (an older version's, or a user-set
801/// `OMNI_DEV_LOG_FILE` target) is re-tightened to `0600` on every open, via
802/// the handle so there is no path race (#1139).
803/// When bodies are enabled (lines may exceed the atomic-write size) an advisory
804/// exclusive lock guards the write; the common no-body path relies on
805/// `O_APPEND` single-write atomicity and takes no lock.
806///
807/// `log.jsonl`'s own writer — opts into size-capped rotation
808/// ([`rotation_config`]) when configured. The audit sink deliberately does
809/// **not** call this; see [`append_line_no_rotation`].
810#[cfg(unix)]
811fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
812    // Opt-in size-capped rotation takes over the write: it must stat, maybe
813    // rotate, then open a fresh file, all under a stable-path lock (#1121).
814    if let Some(cfg) = rotation_config() {
815        return append_with_rotation(path, line, &cfg);
816    }
817    append_line_no_rotation(path, line)
818}
819
820/// The same append as [`append_line`], minus the `OMNI_DEV_LOG_MAX_SIZE`
821/// rotation check — used directly by the audit sink
822/// ([ADR-0080](../docs/adrs/adr-0080.md) §11: exempt from rotation by
823/// design, not merely by leaving `OMNI_DEV_LOG_MAX_SIZE` unset) and by
824/// [`append_line`] itself once rotation is confirmed inactive.
825#[cfg(unix)]
826fn append_line_no_rotation(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
827    append_line_unrotated(path, line, false)
828}
829
830/// [`append_line_no_rotation`] plus a `sync_data` on the handle before it
831/// closes, so the line is on disk (not merely in the page cache) when this
832/// returns, and — for the append that *created* the file — an `fsync` of
833/// the parent directory too: creating a file does not persist its new
834/// directory entry, so without that a crash right after the first leased
835/// write could lose the whole file, not just the line. The audit sink's
836/// appender — see [`record_audit`] for why it is the one writer that pays
837/// for either.
838///
839/// One function for both platforms, unlike its `append_line`/
840/// `append_line_no_rotation` neighbours: the platform difference (off unix
841/// there is no directory sync at all, since a directory cannot be opened as
842/// a `File` there) lives entirely in [`append_line_unrotated`], so a
843/// separate `#[cfg(not(unix))]` twin here would only be a second place to
844/// keep in step.
845fn append_line_synced(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
846    append_line_unrotated(path, line, true)
847}
848
849#[cfg(unix)]
850fn append_line_unrotated(path: &std::path::Path, line: &str, sync: bool) -> anyhow::Result<()> {
851    use std::os::unix::fs::OpenOptionsExt;
852
853    let file = std::fs::OpenOptions::new()
854        .append(true)
855        .create(true)
856        .mode(0o600)
857        .open(path)?;
858    crate::daemon::paths::ensure_handle_0600(&file)?;
859
860    // Whether this append needs the parent-directory `fsync` below, decided
861    // from the handle we already hold: an empty file is one this call may
862    // just have created, so it pays for the sync; a file with content was
863    // created by an earlier append, which synced the directory itself.
864    // An existing-but-empty file costs one spare `fsync`. The one case
865    // where this skips a sync the entry still needs is the concurrent
866    // window described below. Before issue #1697 every append paid it, so
867    // a 2,000-row `drive lease prune` cost 2,000 directory syncs to persist
868    // one directory entry.
869    //
870    // Deliberately *not* an `O_CREAT | O_EXCL` probe, which would report
871    // creation exactly rather than conservatively: `O_EXCL` refuses to
872    // follow a symlink, so an `OMNI_DEV_AUDIT_LOG_FILE` pointing at one
873    // would start failing `ENOENT` — and in a fail-closed sink a failed
874    // append refuses the write it was auditing. Both approaches leave the
875    // same residue anyway, so the exact answer buys nothing. That residue
876    // is the window between the creating append's data sync and its
877    // directory sync: a creator that dies inside it leaves a file no later
878    // append re-syncs, and a *concurrent* appender (nothing serializes the
879    // whole append across processes) that lands inside it sees a non-empty
880    // file and returns before the entry is durable. Every-append syncing
881    // closed the second case; it is accepted here as a narrow window —
882    // milliseconds, since it spans the creator's own `sync_data`, which is
883    // `F_FULLFSYNC` on macOS — that matters only if the machine crashes
884    // inside it, once per audit file.
885    let sync_parent = sync && file.metadata()?.len() == 0;
886
887    if bodies_enabled() {
888        match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive) {
889            Ok(mut guard) => {
890                guard.write_all(line.as_bytes())?;
891                if sync {
892                    guard.sync_data()?;
893                }
894            }
895            Err((mut file, _)) => {
896                file.write_all(line.as_bytes())?;
897                if sync {
898                    file.sync_data()?;
899                }
900            }
901        }
902    } else {
903        let mut file = file;
904        file.write_all(line.as_bytes())?;
905        if sync {
906            file.sync_data()?;
907        }
908    }
909
910    if sync_parent {
911        if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
912            std::fs::File::open(parent)?.sync_all()?;
913        }
914    }
915    Ok(())
916}
917
918/// Non-unix fallback: `O_APPEND | O_CREATE` single write, no advisory lock and
919/// no mode tightening (those are unix concepts). Size-capped rotation is a
920/// unix-only feature and is not applied here, so this already matches
921/// [`append_line_no_rotation`]'s contract exactly — there is nothing left to
922/// differ, so the audit sink reuses this same function on non-unix targets.
923#[cfg(not(unix))]
924fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
925    append_line_no_rotation(path, line)
926}
927
928/// See [`append_line`]'s doc comment on non-unix targets.
929#[cfg(not(unix))]
930fn append_line_no_rotation(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
931    append_line_unrotated(path, line, false)
932}
933
934/// See the unix variant. No directory sync here, whether or not this append
935/// created the file: a directory cannot be opened as a `File` off unix. The
936/// data sync is still applied.
937#[cfg(not(unix))]
938fn append_line_unrotated(path: &std::path::Path, line: &str, sync: bool) -> anyhow::Result<()> {
939    let mut file = std::fs::OpenOptions::new()
940        .append(true)
941        .create(true)
942        .open(path)?;
943    file.write_all(line.as_bytes())?;
944    if sync {
945        file.sync_data()?;
946    }
947    Ok(())
948}
949
950// --- Size management: rotation on write + `omni-dev log prune` ---
951//
952// The log is default-on for every invocation and every outbound request, so on
953// an active machine it would otherwise grow without bound (#1121). Two bounds
954// are offered, both opt-in:
955//
956//   * Automatic size-capped rotation on write, gated on `OMNI_DEV_LOG_MAX_SIZE`
957//     (+ `OMNI_DEV_LOG_KEEP_FILES`) — numbered `log.jsonl.1`, `.2`, … files.
958//   * The explicit `omni-dev log prune` command (age- and/or size-based), which
959//     rewrites the file in place via a same-dir temp file + atomic rename.
960
961/// Returns `path` with `suffix` appended to its final component (kept in the
962/// same directory), e.g. `…/log.jsonl` + `.1` → `…/log.jsonl.1`.
963fn sibling(path: &Path, suffix: &str) -> PathBuf {
964    let mut name = path.as_os_str().to_owned();
965    name.push(suffix);
966    PathBuf::from(name)
967}
968
969/// Parses a human byte size: a number (with optional decimal) and an optional
970/// unit suffix — `b` (bytes, the default), `k`/`kb`/`kib`, `m`/`mb`/`mib`,
971/// `g`/`gb`/`gib` (case-insensitive, all binary/1024-based).
972pub(crate) fn parse_size(s: &str) -> anyhow::Result<u64> {
973    use anyhow::Context as _;
974
975    let lower = s.trim().to_ascii_lowercase();
976    if lower.is_empty() {
977        anyhow::bail!("empty size (expected e.g. 10mb, 512kb, 1048576)");
978    }
979    let split = lower
980        .find(|c: char| !c.is_ascii_digit() && c != '.')
981        .unwrap_or(lower.len());
982    let (num, unit) = lower.split_at(split);
983    let value: f64 = num
984        .parse()
985        .with_context(|| format!("invalid size number: {s}"))?;
986    if !value.is_finite() || value < 0.0 {
987        anyhow::bail!("invalid size: {s}");
988    }
989    let mult: u64 = match unit.trim() {
990        "" | "b" => 1,
991        "k" | "kb" | "kib" => 1024,
992        "m" | "mb" | "mib" => 1024 * 1024,
993        "g" | "gb" | "gib" => 1024 * 1024 * 1024,
994        other => anyhow::bail!("invalid size unit: {other} (use b, kb, mb, or gb)"),
995    };
996    Ok((value * mult as f64) as u64)
997}
998
999/// Resolved rotation policy from the environment. `None` means rotation is off
1000/// (the default): `OMNI_DEV_LOG_MAX_SIZE` unset, empty, invalid, or `0`.
1001/// Rotation on write is a unix-only feature.
1002#[cfg(unix)]
1003struct RotationConfig {
1004    /// Rotate before an append that would push the file past this many bytes.
1005    max_size: u64,
1006    /// Number of rotated `log.jsonl.N` files to retain.
1007    keep_files: u32,
1008}
1009
1010/// Reads the rotation policy from `OMNI_DEV_LOG_MAX_SIZE` /
1011/// `OMNI_DEV_LOG_KEEP_FILES`. A set-but-invalid `OMNI_DEV_LOG_MAX_SIZE` logs at
1012/// debug and disables rotation rather than failing the write.
1013#[cfg(unix)]
1014fn rotation_config() -> Option<RotationConfig> {
1015    let raw = std::env::var("OMNI_DEV_LOG_MAX_SIZE").ok()?;
1016    if raw.trim().is_empty() {
1017        return None;
1018    }
1019    let max_size = match parse_size(&raw) {
1020        Ok(0) => return None,
1021        Ok(n) => n,
1022        Err(e) => {
1023            tracing::debug!("request_log: ignoring invalid OMNI_DEV_LOG_MAX_SIZE: {e}");
1024            return None;
1025        }
1026    };
1027    let keep_files = std::env::var("OMNI_DEV_LOG_KEEP_FILES")
1028        .ok()
1029        .and_then(|v| v.trim().parse::<u32>().ok())
1030        .unwrap_or(DEFAULT_KEEP_FILES);
1031    Some(RotationConfig {
1032        max_size,
1033        keep_files,
1034    })
1035}
1036
1037/// Rotates `log.jsonl` → `log.jsonl.1`, shifting existing numbered files up and
1038/// dropping any beyond `keep_files` (`keep_files == 0` simply discards the
1039/// current file). Rotated files inherit the `0600` mode of their source.
1040#[cfg(unix)]
1041fn rotate(path: &Path, keep_files: u32) -> anyhow::Result<()> {
1042    if keep_files == 0 {
1043        // Retain no history: dropping the current file lets the caller start a
1044        // fresh one on the following append.
1045        let _ = std::fs::remove_file(path);
1046        return Ok(());
1047    }
1048    // Drop the oldest retained file, then shift .(N-1) → .N … .1 → .2.
1049    let _ = std::fs::remove_file(sibling(path, &format!(".{keep_files}")));
1050    for i in (1..keep_files).rev() {
1051        let from = sibling(path, &format!(".{i}"));
1052        if from.exists() {
1053            std::fs::rename(&from, sibling(path, &format!(".{}", i + 1)))?;
1054        }
1055    }
1056    std::fs::rename(path, sibling(path, ".1"))?;
1057    Ok(())
1058}
1059
1060/// Size-capped append (unix): under an exclusive lock on a stable `<log>.lock`
1061/// file — so all rotation-aware writers serialize on an inode that is never
1062/// itself rotated — stat the log, rotate if this line would push a non-empty
1063/// file past the cap, then append to the (possibly fresh) file. A rotation
1064/// failure is logged at debug and the line is still appended (best effort).
1065///
1066/// Never rotates when `path` [resolves to the audit
1067/// file](resolves_to_audit_file) — checked first, before the `<path>.lock`
1068/// sibling below is created next to it — falling back to the plain
1069/// never-rotates append instead (ADR-0080 §11). The line is still appended:
1070/// this only withholds rotation, matching [`record`]'s best-effort, always
1071/// try to write it contract.
1072#[cfg(unix)]
1073fn append_with_rotation(path: &Path, line: &str, cfg: &RotationConfig) -> anyhow::Result<()> {
1074    use std::os::unix::fs::OpenOptionsExt;
1075
1076    if resolves_to_audit_file(path) {
1077        return append_line_no_rotation(path, line);
1078    }
1079
1080    let lock_path = sibling(path, ".lock");
1081    let lock_file = std::fs::OpenOptions::new()
1082        .create(true)
1083        .write(true)
1084        .truncate(false)
1085        .mode(0o600)
1086        .open(&lock_path)?;
1087    crate::daemon::paths::ensure_handle_0600(&lock_file)?;
1088    // Hold the lock for the whole check-rotate-append. If the lock cannot be
1089    // taken, fall through unlocked rather than dropping the record.
1090    let _guard = nix::fcntl::Flock::lock(lock_file, nix::fcntl::FlockArg::LockExclusive).ok();
1091
1092    let current = std::fs::metadata(path).map_or(0, |m| m.len());
1093    if current > 0 && current.saturating_add(line.len() as u64) > cfg.max_size {
1094        if let Err(e) = rotate(path, cfg.keep_files) {
1095            tracing::debug!("request_log: rotation failed, appending without rotating: {e}");
1096        }
1097    }
1098
1099    let mut file = std::fs::OpenOptions::new()
1100        .append(true)
1101        .create(true)
1102        .mode(0o600)
1103        .open(path)?;
1104    crate::daemon::paths::ensure_handle_0600(&file)?;
1105    file.write_all(line.as_bytes())?;
1106    Ok(())
1107}
1108
1109/// Options controlling [`prune`].
1110pub struct PruneOptions {
1111    /// Drop records whose timestamp is strictly older than this cutoff. A
1112    /// record with a missing/unparseable timestamp (or a malformed line) is
1113    /// conservatively kept.
1114    pub older_than: Option<DateTime<Utc>>,
1115    /// After age pruning, drop the oldest records until the file is at most
1116    /// this many bytes. At least the single most recent record is always kept.
1117    pub max_size: Option<u64>,
1118    /// Compute and report the outcome without modifying the file.
1119    pub dry_run: bool,
1120}
1121
1122/// What a [`prune`] run did (or, when `dry_run`, would do).
1123pub struct PruneOutcome {
1124    /// Records removed.
1125    pub removed: usize,
1126    /// Records retained.
1127    pub kept: usize,
1128    /// File size before.
1129    pub bytes_before: u64,
1130    /// File size after (the size the retained records occupy).
1131    pub bytes_after: u64,
1132}
1133
1134/// Prunes the log at `path` by age and/or size, rewriting it in place.
1135///
1136/// Non-empty lines are retained by two successive filters: age (`older_than`)
1137/// then size (`max_size`, keeping the most recent records that fit). The kept
1138/// lines are written to a same-directory temp file (`0600` on unix) and
1139/// atomically renamed over the original, so a reader never sees a half-written
1140/// file. A missing log is a no-op; a no-change prune skips the rewrite (leaving
1141/// the file's inode — and any concurrent appends — untouched).
1142///
1143/// Refuses outright when `path` [resolves to the audit file](resolves_to_audit_file)
1144/// — regardless of who computed `path`, since `--audit`'s own refusal
1145/// (`src/cli/log/prune.rs`) only catches that one flag, not a
1146/// `OMNI_DEV_LOG_FILE` override spelled to name `audit.jsonl` directly, via
1147/// `..`, or via a symlink. Exemption from pruning is the point (ADR-0080
1148/// §11); this applies even under `dry_run`, since `omni-dev log --audit` is
1149/// the supported way to read the file.
1150pub fn prune(path: &Path, opts: &PruneOptions) -> anyhow::Result<PruneOutcome> {
1151    use anyhow::Context as _;
1152
1153    anyhow::ensure!(
1154        !resolves_to_audit_file(path),
1155        "refusing to prune {}: it resolves to the audit log, which is exempt from pruning by \
1156         design (ADR-0080 §11); read it with `omni-dev log --audit` instead",
1157        path.display()
1158    );
1159
1160    let data = match std::fs::read(path) {
1161        Ok(data) => data,
1162        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1163            return Ok(PruneOutcome {
1164                removed: 0,
1165                kept: 0,
1166                bytes_before: 0,
1167                bytes_after: 0,
1168            });
1169        }
1170        Err(e) => return Err(e).context("failed to read the log file"),
1171    };
1172    let bytes_before = data.len() as u64;
1173    let text = String::from_utf8_lossy(&data);
1174
1175    // Every non-empty line, then the subset passing the age filter (both in
1176    // original — chronological — order).
1177    let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
1178    let aged: Vec<&str> = all
1179        .iter()
1180        .copied()
1181        .filter(|line| keep_by_age(line, opts.older_than))
1182        .collect();
1183
1184    let kept: &[&str] = match opts.max_size {
1185        None => &aged,
1186        Some(max) => keep_by_size(&aged, max),
1187    };
1188
1189    let bytes_after: u64 = kept.iter().map(|l| l.len() as u64 + 1).sum();
1190    let outcome = PruneOutcome {
1191        removed: all.len() - kept.len(),
1192        kept: kept.len(),
1193        bytes_before,
1194        bytes_after,
1195    };
1196
1197    if !opts.dry_run && outcome.removed > 0 {
1198        rewrite_atomically(path, kept)?;
1199    }
1200    Ok(outcome)
1201}
1202
1203/// Whether a raw line survives the age filter. Absent filter keeps everything;
1204/// an undateable or malformed line is conservatively kept.
1205fn keep_by_age(line: &str, older_than: Option<DateTime<Utc>>) -> bool {
1206    let Some(cutoff) = older_than else {
1207        return true;
1208    };
1209    match serde_json::from_str::<LogRecord>(line) {
1210        Ok(rec) => match DateTime::parse_from_rfc3339(&rec.timestamp) {
1211            Ok(ts) => ts.with_timezone(&Utc) >= cutoff,
1212            Err(_) => true,
1213        },
1214        Err(_) => true,
1215    }
1216}
1217
1218/// Longest suffix of `lines` whose bytes (each line + its newline) fit in `max`,
1219/// but never fewer than the single most recent line.
1220fn keep_by_size<'a>(lines: &'a [&'a str], max: u64) -> &'a [&'a str] {
1221    let mut acc = 0u64;
1222    let mut start = lines.len();
1223    for (i, line) in lines.iter().enumerate().rev() {
1224        acc += line.len() as u64 + 1;
1225        if acc > max {
1226            break;
1227        }
1228        start = i;
1229    }
1230    if start == lines.len() && !lines.is_empty() {
1231        start = lines.len() - 1; // keep at least the most recent record
1232    }
1233    &lines[start..]
1234}
1235
1236/// Writes `lines` (each newline-terminated) to a same-directory temp file and
1237/// atomically renames it over `path`, preserving the `0600` posture on unix.
1238fn rewrite_atomically(path: &Path, lines: &[&str]) -> anyhow::Result<()> {
1239    let tmp = sibling(path, &format!(".prune.{}.tmp", std::process::id()));
1240    let result = (|| -> anyhow::Result<()> {
1241        let mut options = std::fs::OpenOptions::new();
1242        options.create(true).write(true).truncate(true);
1243        #[cfg(unix)]
1244        {
1245            use std::os::unix::fs::OpenOptionsExt;
1246            options.mode(0o600);
1247        }
1248        let mut file = options.open(&tmp)?;
1249        #[cfg(unix)]
1250        crate::daemon::paths::ensure_handle_0600(&file)?;
1251        for line in lines {
1252            file.write_all(line.as_bytes())?;
1253            file.write_all(b"\n")?;
1254        }
1255        file.flush()?;
1256        std::fs::rename(&tmp, path)?;
1257        Ok(())
1258    })();
1259    if result.is_err() {
1260        let _ = std::fs::remove_file(&tmp);
1261    }
1262    result
1263}
1264
1265/// The outcome of an invocation, recorded once after `cli.execute()` returns.
1266#[derive(Debug, Clone)]
1267pub struct InvocationOutcome {
1268    /// Resolved clap subcommand path.
1269    pub command: Vec<String>,
1270    /// Full argv.
1271    pub command_line: Vec<String>,
1272    /// Process exit code.
1273    pub exit_code: i32,
1274    /// Rendered error chain, when the command failed.
1275    pub error: Option<String>,
1276    /// Wall time of the whole invocation.
1277    pub duration: Duration,
1278}
1279
1280/// Appends one `kind: "invocation"` record from the active context.
1281pub fn record_invocation(outcome: InvocationOutcome) {
1282    let ctx = current_context();
1283    let mut rec = LogRecord::new(RecordKind::Invocation, ctx.invocation_id);
1284    rec.source = Some(ctx.source);
1285    rec.mcp_tool = ctx.mcp_tool;
1286    rec.command = outcome.command;
1287    rec.command_line = scrub_argv(&outcome.command_line);
1288    rec.exit_code = Some(outcome.exit_code);
1289    rec.error = outcome.error;
1290    rec.duration_ms = Some(outcome.duration.as_millis() as u64);
1291    rec.env = whitelisted_env();
1292    record(&rec);
1293}
1294
1295/// The outcome of one `gh` subprocess invocation, recorded by
1296/// `crate::github_metrics::run_gh` after the process exits.
1297#[derive(Debug, Clone)]
1298pub struct GhOutcome {
1299    /// The semantic subcommand label, e.g. `"api graphql"`, `"pr list"`. Split on
1300    /// spaces into the record's `command` so `--command`/`command:` queries work.
1301    pub label: String,
1302    /// Full argv passed to `gh` (without the binary path). Scrubbed before write.
1303    pub argv: Vec<String>,
1304    /// Process exit code; `None` when the process could not be spawned.
1305    pub exit_code: Option<i32>,
1306    /// Wall time of the subprocess.
1307    pub duration: Duration,
1308    /// Spawn/collection error, when the invocation did not complete.
1309    pub error: Option<String>,
1310}
1311
1312/// Appends one `kind: "gh"` record from the active context.
1313///
1314/// Best effort and exit-code-safe: it goes through [`record`], which swallows
1315/// every error, so a logging failure can never change a `gh` caller's result.
1316pub fn record_gh(outcome: GhOutcome) {
1317    record(&build_gh_record(outcome, current_context()));
1318}
1319
1320/// Builds the `kind: "gh"` record for `outcome` under `ctx`. Split out from
1321/// [`record_gh`] so the record shape (source stamping, subcommand split, argv
1322/// scrubbing) is unit-testable without touching the filesystem or environment.
1323fn build_gh_record(outcome: GhOutcome, ctx: RequestLogContext) -> LogRecord {
1324    let mut rec = LogRecord::new(RecordKind::Gh, ctx.invocation_id);
1325    rec.source = Some(ctx.source);
1326    rec.mcp_tool = ctx.mcp_tool;
1327    rec.command = outcome
1328        .label
1329        .split(' ')
1330        .filter(|s| !s.is_empty())
1331        .map(str::to_string)
1332        .collect();
1333    // Same scrubbing as invocation records — defense in depth. `gh` manages its
1334    // own auth (the token never enters our argv), but any secret-bearing `--flag`
1335    // or URL-query value is redacted regardless.
1336    rec.command_line = scrub_argv(&outcome.argv);
1337    rec.exit_code = outcome.exit_code;
1338    rec.error = outcome.error;
1339    rec.duration_ms = Some(outcome.duration.as_millis() as u64);
1340    rec
1341}
1342
1343/// The outcome of one wrapped `git worktree` subprocess, recorded by the
1344/// `crate::cli::git` worktree subcommands after the process exits.
1345#[derive(Debug, Clone)]
1346pub struct WorktreeOutcome {
1347    /// The verb (`add`/`remove`/`list`/`move`/`prune`/`repair`); the record's
1348    /// `command` becomes `["git", "worktree", verb]` so `--command`/`command:`
1349    /// queries work.
1350    pub verb: String,
1351    /// Full argv passed to `git` (without the binary path). Scrubbed before
1352    /// write.
1353    pub argv: Vec<String>,
1354    /// Process exit code; `None` when the process could not be spawned.
1355    pub exit_code: Option<i32>,
1356    /// Wall time of the subprocess (metadata enrichment excluded).
1357    pub duration: Duration,
1358    /// Spawn/collection error, when the invocation did not complete.
1359    pub error: Option<String>,
1360    /// Recovery-relevant per-verb fields (`path`/`branch`/`commit`/
1361    /// `had_uncommitted`/`used_force`/…), written to the record's `context`.
1362    pub context: BTreeMap<String, String>,
1363}
1364
1365/// Appends one `kind: "worktree"` record from the active context.
1366///
1367/// Best effort and exit-code-safe: it goes through [`record`], which swallows
1368/// every error, so a logging failure can never change the wrapped git
1369/// operation's result.
1370pub fn record_worktree(outcome: WorktreeOutcome) {
1371    record(&build_worktree_record(outcome, current_context()));
1372}
1373
1374/// Builds the `kind: "worktree"` record for `outcome` under `ctx`. Split out
1375/// from [`record_worktree`] so the record shape (source stamping, service tag,
1376/// context passthrough, argv scrubbing) is unit-testable without touching the
1377/// filesystem or environment.
1378fn build_worktree_record(outcome: WorktreeOutcome, ctx: RequestLogContext) -> LogRecord {
1379    let mut rec = LogRecord::new(RecordKind::Worktree, ctx.invocation_id);
1380    rec.source = Some(ctx.source);
1381    rec.mcp_tool = ctx.mcp_tool;
1382    // The service tag makes `--service worktree` the canonical recovery query.
1383    rec.service = Some("worktree".to_string());
1384    rec.command = vec!["git".to_string(), "worktree".to_string(), outcome.verb];
1385    rec.command_line = scrub_argv(&outcome.argv);
1386    rec.exit_code = outcome.exit_code;
1387    rec.error = outcome.error;
1388    rec.duration_ms = Some(outcome.duration.as_millis() as u64);
1389    rec.context = outcome.context;
1390    rec
1391}
1392
1393/// The outcome of one Drive mutation attempt.
1394///
1395/// `Default` so a caller names only the fields its verb actually has: this
1396/// struct is shared by every mutating verb and most leave most of it empty.
1397/// Note `duration` defaults to zero, which is never the right value — every
1398/// construction site sets it explicitly.
1399#[derive(Debug, Clone, Default)]
1400pub struct DriveMutationOutcome {
1401    /// The verb — `"rename"`, `"move"`, `"sheets-write"`, … ; becomes the
1402    /// record's `command` as `["drive", <operation>]`.
1403    pub operation: &'static str,
1404    /// The Drive file id acted on.
1405    pub file_id: String,
1406    /// The file's name at the time of the attempt.
1407    pub file_name: String,
1408    /// The domain outcome (e.g. `"moved"`, `"blocked"`, `"already-in-folder"`,
1409    /// `"failed"` — kebab-case, matching `MoveResult`'s
1410    /// `#[serde(tag = "status", rename_all = "kebab-case")]`).
1411    pub status: String,
1412    /// Principals gaining access, when the visibility diff detected an
1413    /// increase. Empty for `rename` (which never changes visibility) and
1414    /// for a `move` with no visibility change.
1415    pub added_principals: Vec<String>,
1416    /// Principals losing access — the decrease-side counterpart of
1417    /// `added_principals`.
1418    pub removed_principals: Vec<String>,
1419    /// Whether the file moved across a My Drive / Shared Drive boundary.
1420    pub crosses_drive_boundary: bool,
1421    /// The folder the write-permission gate evaluated against (issue
1422    /// #1574) — the `--parent` for `create`/`upload`, the target's current
1423    /// parent for `edit`. `None` for `rename`/`move` (never gated), for a
1424    /// `content_edit` outcome that short-circuited before the gate (a
1425    /// Google-native-document refusal), and for any verdict a `file_id`
1426    /// rule decided (issue #1612), which is settled at depth −1 without
1427    /// evaluating a folder at all. In that last case `decided_by_file_id`
1428    /// carries the id instead.
1429    pub resolved_folder_id: Option<String>,
1430    /// The folder id of the configured rule that decided the write-gate
1431    /// verdict, when one did (as opposed to the bare default policy).
1432    /// Paired with `decided_by_depth`.
1433    pub decided_by_folder_id: Option<String>,
1434    /// How many levels above `resolved_folder_id` that rule's folder sits.
1435    pub decided_by_depth: Option<usize>,
1436    /// The file id of the configured rule that decided the write-gate
1437    /// verdict, when a **file** rule did (issue #1612).
1438    ///
1439    /// Mutually exclusive with `decided_by_folder_id`, deliberately: a file
1440    /// id must never appear in the folder field, or an existing
1441    /// `omni-dev log --query decided_by_folder_id:X` would silently start
1442    /// matching a different kind of id. A file rule matches the target
1443    /// itself, so it has no depth and walks no chain — `decided_by_depth`
1444    /// and `resolved_folder_id` are both `None` alongside it.
1445    pub decided_by_file_id: Option<String>,
1446    /// The A1 range a Sheets write targeted (issue #1589). `None` for every
1447    /// non-Sheets verb.
1448    pub range: Option<String>,
1449    /// The server-normalised range the Sheets API reported writing or
1450    /// clearing. Can differ from [`Self::range`]: the server resolves an
1451    /// open-ended range against the sheet's actual extent.
1452    pub updated_range: Option<String>,
1453    /// Rows the Sheets API reported writing.
1454    pub updated_rows: Option<i64>,
1455    /// Columns the Sheets API reported writing.
1456    pub updated_columns: Option<i64>,
1457    /// Cells the Sheets API reported writing — the number that answers "how
1458    /// much did that write actually touch".
1459    pub updated_cells: Option<i64>,
1460    /// The stable numeric id of the sheet a structural verb acted on
1461    /// (issue #1613). Unlike a title it survives a later rename, so it is
1462    /// the only durable answer to "which tab was this". `None` for every
1463    /// non-structural verb.
1464    pub sheet_id: Option<i64>,
1465    /// The sheet's title at the time of the attempt — for `add-sheet`, the
1466    /// title being created.
1467    pub sheet_title: Option<String>,
1468    /// The title a `rename-sheet` moved the sheet *to*. Set by that verb
1469    /// alone: it is the only structural verb whose effect a record could not
1470    /// otherwise reconstruct, since [`Self::sheet_title`] necessarily holds
1471    /// the title the sheet had before. `None` everywhere else.
1472    pub sheet_new_title: Option<String>,
1473    /// The rows or columns a structural verb spanned, e.g. `"ROWS 5:7"`,
1474    /// 1-based and inclusive like the CLI's `--at`. The structural analogue
1475    /// of [`Self::range`], for effects A1 notation cannot express; absent
1476    /// for verbs that span no dimension.
1477    pub dimension_range: Option<String>,
1478    /// The 1-based `--before` value of a `move-rows`/`move-columns` verb
1479    /// (issue #1834), numbered as the sheet stood *before* the move —
1480    /// matching the CLI flag and [`Self::dimension_range`]'s own numbering.
1481    /// `None` for every other verb.
1482    pub move_to: Option<i64>,
1483    /// The rectangular cell range a `delete-range` verb spanned (issue
1484    /// #1623), e.g. `"rows 2-10, columns 2-4"`, 1-based inclusive like the
1485    /// CLI's `--start-row`/`--end-row`/`--start-column`/`--end-column`. The
1486    /// `deleteRange` analogue of [`Self::dimension_range`], which only ever
1487    /// spans one axis. `None` for every other verb.
1488    pub grid_range: Option<String>,
1489    /// Occurrences the Docs API reported changing (issue #1615). `None` for
1490    /// every verb but `docs replace`.
1491    ///
1492    /// The *server's* count, not the client-side estimate `--dry-run`
1493    /// previews — the two can differ, and only this one says what actually
1494    /// happened.
1495    pub occurrences_changed: Option<i64>,
1496    /// Characters inserted by a `docs append` or a `docs create --text`.
1497    pub inserted_chars: Option<i64>,
1498    /// The revision lease presented on a `documents.batchUpdate` (issue
1499    /// #1615).
1500    ///
1501    /// Recorded so a `stale-revision` refusal is as auditable as a success.
1502    /// An opaque, per-user, short-lived id — not a secret, and not user
1503    /// content. The searched, replacement and inserted **text** are
1504    /// deliberately never recorded; `docs/log.md` documents what is.
1505    pub required_revision_id: Option<String>,
1506    /// A human-readable summary of what a formatting/dimension-property
1507    /// verb changed (issue #1643) — `format-cells`' populated `CellFormat`
1508    /// fields, `update-borders`' sides and style, `merge-cells`'/
1509    /// `unmerge-cells`' effect, or the dimension property set. The
1510    /// formatting analogue of [`Self::dimension_range`]: these verbs can
1511    /// send more than one field in a single request, so a single `range`-
1512    /// or `dimension_range`-shaped key can't name the effect on its own.
1513    pub fields_changed: Option<String>,
1514    /// `merge-cells` only (issue #1643): the non-top-left, non-blank cells
1515    /// a merge discarded, as `"A1: value"` strings — the one formatting
1516    /// request that destroys data, so its record says exactly what was
1517    /// lost rather than only that a merge happened.
1518    pub discarded_cells: Vec<String>,
1519    /// `auto-fill` only (issue #1840, [ADR-0083](../docs/adrs/adr-0083.md)
1520    /// §6): the non-blank cells in the destination range that were (or,
1521    /// under `--dry-run`, would be) overwritten, as **bare A1 addresses**
1522    /// — deliberately not `"A1: value"` like [`Self::discarded_cells`],
1523    /// since a fill's values are Sheets' own series detection and this
1524    /// crate never sees them, before or after the request. Empty when the
1525    /// destination has no non-blank cells, and for every other verb.
1526    pub overwritten_cells: Vec<String>,
1527    /// `auto-fill --range` and every `text-to-columns` run:
1528    /// [`Self::overwritten_cells`] is an **upper bound**, not a list of
1529    /// cells that were certainly overwritten. `auto-fill --range` lets
1530    /// Sheets decide for itself which cells in the named range are the
1531    /// source and which are filled, so some of the listed cells are the
1532    /// source and were never touched; `text-to-columns` reports the span a
1533    /// locally computed, non-quote-aware split *might* need, which the API
1534    /// decides for itself and may need fewer columns of — and under
1535    /// `--delimiter auto`, where Sheets picks the separator as well, it
1536    /// is an estimate rather than a bound in either direction (this key
1537    /// has no third state; that caveat is carried in the rendered
1538    /// output). `false` for `auto-fill --source`, where the destination
1539    /// is computed client-side and the list is exact, and for every
1540    /// other verb.
1541    pub overwritten_cells_upper_bound: bool,
1542    /// The data validation condition type a `set-data-validation` applied
1543    /// (issue #1643) — `"ONE_OF_LIST"`, `"NUMBER_BETWEEN"`, `"BOOLEAN"`,
1544    /// `"CUSTOM_FORMULA"` — or `"cleared"` for `clear-data-validation`.
1545    /// `None` for every non-validation verb.
1546    pub validation_type: Option<String>,
1547    /// The stable numeric id of a protected range a protection verb acted
1548    /// on (issue #1643, [ADR-0077](../docs/adrs/adr-0077.md)). Set by
1549    /// `protect-range` from the `addProtectedRange` reply (the id is
1550    /// server-assigned, like [`Self::sheet_id`] for `add-sheet`) and by
1551    /// `update-protection`/`unprotect-range` from the range they resolved
1552    /// against.
1553    pub protected_range_id: Option<i64>,
1554    /// Editors a `protect-range`/`update-protection` granted an exemption
1555    /// from the protection. Empty unless the verb actually changed the
1556    /// editor list.
1557    pub protection_editors_added: Vec<String>,
1558    /// Editors an `update-protection` removed the exemption from — the
1559    /// decrease-side counterpart of [`Self::protection_editors_added`].
1560    pub protection_editors_removed: Vec<String>,
1561    /// The stable numeric id of a filter view an `add-filter-view`/
1562    /// `update-filter-view`/`delete-filter-view` acted on (issue #1794).
1563    /// Set from the `addFilterView` reply for `add-filter-view` (the id is
1564    /// server-assigned, like [`Self::protected_range_id`] for
1565    /// `protect-range`) and from the id resolved against for
1566    /// `update-filter-view`/`delete-filter-view`. `None` for
1567    /// `set-basic-filter`/`clear-basic-filter`, which are scoped to a sheet
1568    /// rather than a filter view — see [`Self::sheet_id`].
1569    pub filter_view_id: Option<i64>,
1570    /// The stable id of a named range a named-range verb acted on (issue
1571    /// #1796, [ADR-0081](../docs/adrs/adr-0081.md) §2). Set by
1572    /// `add-named-range` from the `addNamedRange` reply (server-assigned,
1573    /// like [`Self::protected_range_id`]) and by `update-named-range`/
1574    /// `delete-named-range` from the named range they resolved against.
1575    pub named_range_id: Option<String>,
1576    /// `delete-named-range` only (ADR-0081 §2): the A1 locations of every
1577    /// formula referencing the name being removed, as `"Sheet!A1"` strings —
1578    /// **never** the formula text or a cell's value, matching
1579    /// [`Self::discarded_cells`]'s no-content-exposure line. Empty when
1580    /// nothing references the name, and for every other named-range verb.
1581    pub referencing_formula_locations: Vec<String>,
1582    /// The stable numeric id of a chart or slicer an `add-chart`/
1583    /// `update-chart`/`delete-chart`/`add-slicer`/`update-slicer`/
1584    /// `delete-slicer` acted on (issue #1797). Set from the `addChart`/
1585    /// `addSlicer` reply for the two adds (the id is server-assigned, like
1586    /// [`Self::filter_view_id`] for `add-filter-view`) and from the id
1587    /// resolved against otherwise. One key for both kinds — Sheets'
1588    /// `deleteEmbeddedObject` itself addresses a chart or a slicer by the
1589    /// same `objectId` with no discriminator naming which.
1590    pub embedded_object_id: Option<i64>,
1591    /// The stable numeric id of a banded range an `add-banding`/
1592    /// `update-banding`/`delete-banding` acted on (issue #1832). Set from
1593    /// the `addBanding` reply for `add-banding` (server-assigned, like
1594    /// [`Self::filter_view_id`] for `add-filter-view`) and from the id
1595    /// resolved against for `update-banding`/`delete-banding`.
1596    pub banded_range_id: Option<i64>,
1597    /// The API/validation error, when the attempt failed.
1598    pub error: Option<String>,
1599    /// Wall time of the attempt.
1600    pub duration: Duration,
1601}
1602
1603/// Appends one `kind: "drivemutation"` record from the active context.
1604///
1605/// Best effort and exit-code-safe: it goes through [`record`], which
1606/// swallows every error, so a logging failure can never change the
1607/// underlying rename/move result.
1608///
1609/// Deliberately called from *inside* `src/drive/rename.rs`/
1610/// `src/drive/file_move.rs` themselves rather than the CLI layer — unlike
1611/// [`record_worktree`], which is safe to call from `src/cli/git/worktree.rs`
1612/// only because `git worktree` has no MCP surface at all. Drive move/rename
1613/// may grow an MCP caller later, and "every move/rename must be logged" is a
1614/// hard invariant that needs to hold for every current and future caller.
1615/// This is also additive to (not redundant with) the automatic per-request
1616/// `kind: "http"` records `crate::drive::client::DriveClient` already writes
1617/// for every call it makes: a `Blocked` outcome makes *no* `files.update`
1618/// call at all, so without this record the single most security-relevant
1619/// event — "we refused this because visibility would change, here's exactly
1620/// why" — would never appear in the log.
1621pub fn record_drive_mutation(outcome: DriveMutationOutcome) {
1622    record(&build_drive_mutation_record(outcome, current_context()));
1623}
1624
1625/// Builds the `kind: "drivemutation"` record for `outcome` under `ctx`. Split
1626/// out from [`record_drive_mutation`] so the record shape is unit-testable
1627/// without touching the filesystem or environment.
1628fn build_drive_mutation_record(outcome: DriveMutationOutcome, ctx: RequestLogContext) -> LogRecord {
1629    let mut rec = LogRecord::new(RecordKind::DriveMutation, ctx.invocation_id);
1630    rec.source = Some(ctx.source);
1631    rec.mcp_tool = ctx.mcp_tool;
1632    rec.service = Some("drive".to_string());
1633    rec.command = vec!["drive".to_string(), outcome.operation.to_string()];
1634    rec.error = outcome.error;
1635    rec.duration_ms = Some(outcome.duration.as_millis() as u64);
1636
1637    let mut context = BTreeMap::new();
1638    context.insert("file_id".to_string(), outcome.file_id);
1639    context.insert("file_name".to_string(), outcome.file_name);
1640    context.insert("status".to_string(), outcome.status);
1641    if !outcome.added_principals.is_empty() {
1642        context.insert(
1643            "added_principals".to_string(),
1644            outcome.added_principals.join(","),
1645        );
1646    }
1647    if !outcome.removed_principals.is_empty() {
1648        context.insert(
1649            "removed_principals".to_string(),
1650            outcome.removed_principals.join(","),
1651        );
1652    }
1653    if outcome.crosses_drive_boundary {
1654        context.insert("crosses_drive_boundary".to_string(), "true".to_string());
1655    }
1656    if let Some(resolved_folder_id) = outcome.resolved_folder_id {
1657        context.insert("resolved_folder_id".to_string(), resolved_folder_id);
1658    }
1659    if let Some(decided_by_folder_id) = outcome.decided_by_folder_id {
1660        context.insert("decided_by_folder_id".to_string(), decided_by_folder_id);
1661    }
1662    if let Some(decided_by_depth) = outcome.decided_by_depth {
1663        context.insert("decided_by_depth".to_string(), decided_by_depth.to_string());
1664    }
1665    if let Some(decided_by_file_id) = outcome.decided_by_file_id {
1666        context.insert("decided_by_file_id".to_string(), decided_by_file_id);
1667    }
1668    if let Some(range) = outcome.range {
1669        context.insert("range".to_string(), range);
1670    }
1671    if let Some(updated_range) = outcome.updated_range {
1672        context.insert("updated_range".to_string(), updated_range);
1673    }
1674    if let Some(rows) = outcome.updated_rows {
1675        context.insert("updated_rows".to_string(), rows.to_string());
1676    }
1677    if let Some(cols) = outcome.updated_columns {
1678        context.insert("updated_columns".to_string(), cols.to_string());
1679    }
1680    if let Some(cells) = outcome.updated_cells {
1681        context.insert("updated_cells".to_string(), cells.to_string());
1682    }
1683    if let Some(sheet_id) = outcome.sheet_id {
1684        context.insert("sheet_id".to_string(), sheet_id.to_string());
1685    }
1686    if let Some(sheet_title) = outcome.sheet_title {
1687        context.insert("sheet_title".to_string(), sheet_title);
1688    }
1689    if let Some(sheet_new_title) = outcome.sheet_new_title {
1690        context.insert("sheet_new_title".to_string(), sheet_new_title);
1691    }
1692    if let Some(dimension_range) = outcome.dimension_range {
1693        context.insert("dimension_range".to_string(), dimension_range);
1694    }
1695    if let Some(move_to) = outcome.move_to {
1696        context.insert("move_to".to_string(), move_to.to_string());
1697    }
1698    if let Some(grid_range) = outcome.grid_range {
1699        context.insert("grid_range".to_string(), grid_range);
1700    }
1701    if let Some(occurrences) = outcome.occurrences_changed {
1702        context.insert("occurrences_changed".to_string(), occurrences.to_string());
1703    }
1704    if let Some(chars) = outcome.inserted_chars {
1705        context.insert("inserted_chars".to_string(), chars.to_string());
1706    }
1707    if let Some(revision) = outcome.required_revision_id {
1708        context.insert("required_revision_id".to_string(), revision);
1709    }
1710    if let Some(fields_changed) = outcome.fields_changed {
1711        context.insert("fields_changed".to_string(), fields_changed);
1712    }
1713    if !outcome.discarded_cells.is_empty() {
1714        context.insert(
1715            "discarded_cells".to_string(),
1716            outcome.discarded_cells.join("; "),
1717        );
1718    }
1719    if !outcome.overwritten_cells.is_empty() {
1720        context.insert(
1721            "overwritten_cells".to_string(),
1722            outcome.overwritten_cells.join(", "),
1723        );
1724        // Emitted only when true: absent means exact, which is every verb
1725        // but `auto-fill --range` and the overwhelmingly common case.
1726        if outcome.overwritten_cells_upper_bound {
1727            context.insert(
1728                "overwritten_cells_are_upper_bound".to_string(),
1729                "true".to_string(),
1730            );
1731        }
1732    }
1733    if let Some(validation_type) = outcome.validation_type {
1734        context.insert("validation_type".to_string(), validation_type);
1735    }
1736    if let Some(protected_range_id) = outcome.protected_range_id {
1737        context.insert(
1738            "protected_range_id".to_string(),
1739            protected_range_id.to_string(),
1740        );
1741    }
1742    if !outcome.protection_editors_added.is_empty() {
1743        context.insert(
1744            "protection_editors_added".to_string(),
1745            outcome.protection_editors_added.join(","),
1746        );
1747    }
1748    if !outcome.protection_editors_removed.is_empty() {
1749        context.insert(
1750            "protection_editors_removed".to_string(),
1751            outcome.protection_editors_removed.join(","),
1752        );
1753    }
1754    if let Some(filter_view_id) = outcome.filter_view_id {
1755        context.insert("filter_view_id".to_string(), filter_view_id.to_string());
1756    }
1757    if let Some(named_range_id) = outcome.named_range_id {
1758        context.insert("named_range_id".to_string(), named_range_id);
1759    }
1760    if !outcome.referencing_formula_locations.is_empty() {
1761        context.insert(
1762            "referencing_formula_count".to_string(),
1763            outcome.referencing_formula_locations.len().to_string(),
1764        );
1765        context.insert(
1766            "referencing_formula_locations".to_string(),
1767            outcome.referencing_formula_locations.join("; "),
1768        );
1769    }
1770    if let Some(embedded_object_id) = outcome.embedded_object_id {
1771        context.insert(
1772            "embedded_object_id".to_string(),
1773            embedded_object_id.to_string(),
1774        );
1775    }
1776    if let Some(banded_range_id) = outcome.banded_range_id {
1777        context.insert("banded_range_id".to_string(), banded_range_id.to_string());
1778    }
1779    rec.context = context;
1780    rec
1781}
1782
1783/// One event for the Drive write-lease audit trail (ADR-0080 §11).
1784///
1785/// Covers `drive lease acquire` succeeding or refusing, or a leased write's
1786/// own intent/outcome pair — a single shape for all of them, the same way
1787/// [`DriveMutationOutcome`] is one shape for every mutating verb. The
1788/// `verdict` field (kebab-case, mirroring every other `*Result::log_status`
1789/// in this codebase) is what distinguishes them, not a separate Rust type
1790/// per event kind.
1791#[derive(Debug, Clone, Default)]
1792pub struct AuditOutcome {
1793    /// The audited command, e.g. `["drive", "lease-acquire"]` or `["drive",
1794    /// "edit"]` — becomes the record's `command`, mirroring
1795    /// [`DriveMutationOutcome::operation`].
1796    pub command: Vec<String>,
1797    /// Which integration this event belongs to (`"drive"` today; ADR-0080
1798    /// §11 names Gmail/Atlassian as later, additive extensions).
1799    pub integration: &'static str,
1800    /// The Drive file id the lease/write concerns.
1801    pub file_id: String,
1802    /// The lease token, once one exists. Absent for an acquire attempt that
1803    /// never reached minting one (denied/unavailable/refused before a
1804    /// token was created).
1805    pub lease_id: Option<String>,
1806    /// What happened, kebab-case: `"acquired"`, `"refused-native-document"`,
1807    /// `"denied"`, `"unavailable"`, `"failed"` for an acquire attempt;
1808    /// `"pending"` (the write-ahead intent), then `"allowed"` or `"failed"`
1809    /// (the outcome), or a single `"refused-no-lease"`,
1810    /// `"refused-lease-expired"`, `"refused-lease-wrong-file"`,
1811    /// `"refused-lease-stale"` / `"failed"` for a leased write that never
1812    /// reached its mutating call (`crate::drive::lease::check::verdict`).
1813    /// Free-form rather than a closed enum, like every other
1814    /// `*Result::log_status` in this codebase — a later integration's
1815    /// verdict vocabulary doesn't need a schema change here.
1816    pub verdict: String,
1817    /// The file's Drive `version` before the event, when relevant.
1818    pub version_before: Option<String>,
1819    /// The file's Drive `version` after, when relevant (e.g. the version an
1820    /// acquire recorded into the ledger, or a leased write's post-write
1821    /// version).
1822    pub version_after: Option<String>,
1823    /// `modifiedTime` paired with [`Self::version_before`].
1824    pub modified_time_before: Option<String>,
1825    /// `modifiedTime` paired with [`Self::version_after`].
1826    pub modified_time_after: Option<String>,
1827    /// Where an acquire's backup landed: a local path for a byte backup, or
1828    /// the backup copy's own file id for a native-document backup.
1829    pub backup_location: Option<String>,
1830    /// SHA-256 of a byte backup, for later integrity verification. `None`
1831    /// for a native-document backup (verified by Drive's own copy
1832    /// semantics instead) and for anything that isn't an acquire.
1833    pub backup_sha256: Option<String>,
1834    /// Size in bytes of a byte backup.
1835    pub backup_size: Option<u64>,
1836    /// Which authentication policy (ADR-0080 §7) an acquire satisfied —
1837    /// `"device-owner"` or `"biometrics-only"`.
1838    pub auth_policy: Option<String>,
1839    /// `drive lease restore` only: the *backup* lease's token the restore
1840    /// read from — distinct from [`Self::lease_id`], the *fresh* token the
1841    /// restore itself minted before writing (ADR-0080 §10/§11: "the restore
1842    /// is audit-logged carrying both tokens"). `None` for every other
1843    /// event.
1844    pub restored_from_lease_id: Option<String>,
1845    /// The lease this event ended early, when it ended one as a side effect
1846    /// of doing something else: `drive lease acquire`'s supersede, set only
1847    /// by `drive lease restore` naming the backup token it restores from
1848    /// (issue #1685). Distinct from [`Self::lease_id`], the token the
1849    /// acquire itself minted, and from a `drive lease release` record, whose
1850    /// released token *is* its `lease_id`. `None` for every other event.
1851    ///
1852    /// A field rather than a verdict — unlike the headless waiver, which is
1853    /// a flag and so became `acquired-headless-waiver` — because this
1854    /// carries an identifier, exactly as [`Self::restored_from_lease_id`]
1855    /// does, and a third verdict axis would multiply combinatorially with
1856    /// the existing `-headless-waiver`/`-backup-orphaned` suffixes.
1857    pub superseded_lease_id: Option<String>,
1858    /// The error, when the event itself failed (an API/filesystem/ledger
1859    /// error — distinct from an ordinary refusal, which is a `verdict`, not
1860    /// an `error`).
1861    pub error: Option<String>,
1862}
1863
1864/// Appends one `kind: "audit"` record for `outcome`, fail-closed.
1865///
1866/// Propagates [`record_audit`]'s `Result` rather than swallowing it, since
1867/// this is the sink whose whole purpose is to make a mutation with no
1868/// accompanying record impossible (ADR-0080 §11). Callers on the write path
1869/// (as opposed to `drive lease acquire`, which mutates no Drive content)
1870/// must refuse the write itself when this returns `Err` — see
1871/// `content_edit.rs`'s eventual write-ahead call site for the pattern.
1872pub fn record_audit_event(outcome: AuditOutcome) -> anyhow::Result<()> {
1873    record_audit(&build_audit_record(outcome, current_context()))
1874}
1875
1876/// [`record_audit_event`], reading through an injected [`EnvSource`]
1877/// (STYLE-0028) — see [`record_audit_with`].
1878#[cfg(test)]
1879fn record_audit_event_with(env: &impl EnvSource, outcome: AuditOutcome) -> anyhow::Result<()> {
1880    record_audit_with(env, &build_audit_record(outcome, current_context()))
1881}
1882
1883/// Builds the `kind: "audit"` record for `outcome` under `ctx`. Split out
1884/// from [`record_audit_event`] so the record shape is unit-testable without
1885/// touching the filesystem, mirroring [`build_drive_mutation_record`].
1886fn build_audit_record(outcome: AuditOutcome, ctx: RequestLogContext) -> LogRecord {
1887    let mut rec = LogRecord::new(RecordKind::Audit, ctx.invocation_id);
1888    rec.source = Some(ctx.source);
1889    rec.mcp_tool = ctx.mcp_tool;
1890    rec.service = Some("drive".to_string());
1891    rec.command = outcome.command;
1892    rec.error = outcome.error;
1893
1894    let mut context = BTreeMap::new();
1895    context.insert("integration".to_string(), outcome.integration.to_string());
1896    context.insert("file_id".to_string(), outcome.file_id);
1897    if let Some(lease_id) = outcome.lease_id {
1898        context.insert("lease_id".to_string(), lease_id);
1899    }
1900    context.insert("verdict".to_string(), outcome.verdict);
1901    if let Some(version) = outcome.version_before {
1902        context.insert("version_before".to_string(), version);
1903    }
1904    if let Some(version) = outcome.version_after {
1905        context.insert("version_after".to_string(), version);
1906    }
1907    if let Some(modified_time) = outcome.modified_time_before {
1908        context.insert("modified_time_before".to_string(), modified_time);
1909    }
1910    if let Some(modified_time) = outcome.modified_time_after {
1911        context.insert("modified_time_after".to_string(), modified_time);
1912    }
1913    if let Some(location) = outcome.backup_location {
1914        context.insert("backup_location".to_string(), location);
1915    }
1916    if let Some(sha256) = outcome.backup_sha256 {
1917        context.insert("backup_sha256".to_string(), sha256);
1918    }
1919    if let Some(size) = outcome.backup_size {
1920        context.insert("backup_size".to_string(), size.to_string());
1921    }
1922    if let Some(auth_policy) = outcome.auth_policy {
1923        context.insert("auth_policy".to_string(), auth_policy);
1924    }
1925    if let Some(restored_from) = outcome.restored_from_lease_id {
1926        context.insert("restored_from_lease_id".to_string(), restored_from);
1927    }
1928    if let Some(superseded) = outcome.superseded_lease_id {
1929        context.insert("superseded_lease_id".to_string(), superseded);
1930    }
1931    rec.context = context;
1932    rec
1933}
1934
1935/// Optional, non-secret extras for an HTTP record. Bodies/headers are gated and
1936/// redacted centrally in [`record_http_with`], so callers may pass them freely.
1937#[derive(Debug, Clone, Default)]
1938pub struct HttpExtra {
1939    /// True when served inside the daemon.
1940    pub via_daemon: bool,
1941    /// Pooled daemon session id that served the request.
1942    pub daemon_session_id: Option<String>,
1943    /// Non-secret identity used (never the secret).
1944    pub auth_principal: Option<String>,
1945    /// Raw request headers (redacted + gated before writing).
1946    pub request_headers: BTreeMap<String, String>,
1947    /// Raw response headers (redacted + gated before writing).
1948    pub response_headers: BTreeMap<String, String>,
1949    /// Request body (gated before writing).
1950    pub request_body: Option<String>,
1951    /// Response body (gated before writing).
1952    pub response_body: Option<String>,
1953    /// Free-form correlation tags.
1954    pub context: BTreeMap<String, String>,
1955}
1956
1957/// Appends one `kind: "http"` record with method/url/status/elapsed/error.
1958pub fn record_http(
1959    service: &str,
1960    method: &str,
1961    url: &str,
1962    started: Instant,
1963    status: Option<u16>,
1964    error: Option<&str>,
1965) {
1966    record_http_with(
1967        service,
1968        method,
1969        url,
1970        started,
1971        status,
1972        error,
1973        HttpExtra::default(),
1974    );
1975}
1976
1977/// Appends one `kind: "http"` record from a `reqwest` send result, mapping
1978/// `Ok` → status code and `Err` → error message.
1979///
1980/// Collapses the `match result { Ok → status, Err → error }` shape the REST
1981/// clients previously each open-coded around [`record_http`] (#1152).
1982pub fn record_http_result(
1983    service: &str,
1984    method: &str,
1985    url: &str,
1986    started: Instant,
1987    result: &reqwest::Result<reqwest::Response>,
1988) {
1989    match result {
1990        Ok(response) => {
1991            record_http(
1992                service,
1993                method,
1994                url,
1995                started,
1996                Some(response.status().as_u16()),
1997                None,
1998            );
1999        }
2000        Err(error) => {
2001            record_http(
2002                service,
2003                method,
2004                url,
2005                started,
2006                None,
2007                Some(&error.to_string()),
2008            );
2009        }
2010    }
2011}
2012
2013/// Appends one `kind: "http"` record with extra, non-secret fields.
2014///
2015/// Headers and bodies are dropped unless their opt-in env var is set, headers
2016/// are always redacted, and URL query/fragment values under secret-looking
2017/// keys are replaced with `REDACTED` (`redact_url`) — so no secret can be
2018/// written here under any caller.
2019#[allow(clippy::too_many_arguments)]
2020pub fn record_http_with(
2021    service: &str,
2022    method: &str,
2023    url: &str,
2024    started: Instant,
2025    status: Option<u16>,
2026    error: Option<&str>,
2027    extra: HttpExtra,
2028) {
2029    if disabled() {
2030        return;
2031    }
2032    let ctx = current_context();
2033    let mut rec = LogRecord::new(RecordKind::Http, ctx.invocation_id);
2034    rec.source = Some(ctx.source);
2035    rec.mcp_tool = ctx.mcp_tool;
2036    rec.service = Some(service.to_string());
2037    rec.method = Some(method.to_string());
2038    rec.url = Some(redact_url(url));
2039    rec.status_code = status;
2040    rec.elapsed_ms = Some(started.elapsed().as_millis() as u64);
2041    rec.error = error.map(str::to_string);
2042    rec.via_daemon = extra.via_daemon;
2043    rec.daemon_session_id = extra.daemon_session_id;
2044    rec.auth_principal = extra.auth_principal;
2045    rec.context = extra.context;
2046    if headers_enabled() {
2047        rec.request_headers = redact_headers(&extra.request_headers);
2048        rec.response_headers = redact_headers(&extra.response_headers);
2049    }
2050    if bodies_enabled() {
2051        rec.request_body = extra.request_body;
2052        rec.response_body = extra.response_body;
2053    }
2054    record(&rec);
2055}
2056
2057/// Header names whose values must never be written (compared lowercased).
2058const SENSITIVE_HEADERS: &[&str] = &[
2059    "authorization",
2060    "proxy-authorization",
2061    "cookie",
2062    "set-cookie",
2063    "x-api-key",
2064    "api-key",
2065    "dd-api-key",
2066    "dd-application-key",
2067    "x-datadog-api-key",
2068    "x-datadog-application-key",
2069    "x-omni-bridge",
2070    "x-omni-bridge-target",
2071];
2072
2073/// Substrings that mark a header name as secret-bearing (compared lowercased),
2074/// guarding against off-list auth headers (e.g. `x-auth-token`,
2075/// `x-goog-api-key`). False positives redact harmlessly.
2076const SENSITIVE_HEADER_MARKERS: &[&str] = &[
2077    "auth",
2078    "token",
2079    "secret",
2080    "key",
2081    "cookie",
2082    "password",
2083    "session",
2084    "signature",
2085    "credential",
2086];
2087
2088/// Replaces sensitive header values with `REDACTED`, passing others through.
2089///
2090/// A header is sensitive when its lowercased name is in [`SENSITIVE_HEADERS`]
2091/// or contains any [`SENSITIVE_HEADER_MARKERS`] substring.
2092pub fn redact_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
2093    headers
2094        .iter()
2095        .map(|(name, value)| {
2096            let lower = name.to_ascii_lowercase();
2097            let redacted = SENSITIVE_HEADERS.contains(&lower.as_str())
2098                || SENSITIVE_HEADER_MARKERS
2099                    .iter()
2100                    .any(|marker| lower.contains(marker));
2101            (
2102                name.clone(),
2103                if redacted {
2104                    "REDACTED".to_string()
2105                } else {
2106                    value.clone()
2107                },
2108            )
2109        })
2110        .collect()
2111}
2112
2113/// Flag-name segments marking a long flag's value as secret-bearing — the argv
2114/// counterpart of [`SECRETISH`]. Matched per `-`/`_`-separated segment of the
2115/// flag name so `--api-key` is caught but a name like `--keyword` is not.
2116const SECRETISH_FLAG_WORDS: &[&str] = &["token", "secret", "password", "passwd", "key"];
2117
2118/// True when the long flag `--<name>` takes a secret-bearing value. Flags whose
2119/// last segment is `file` or `path` carry paths, not secrets, and are exempt
2120/// (e.g. `--token-file`).
2121fn is_secretish_flag(name: &str) -> bool {
2122    let segments: Vec<String> = name
2123        .split(['-', '_'])
2124        .map(str::to_ascii_lowercase)
2125        .collect();
2126    let takes_path = matches!(segments.last().map(String::as_str), Some("file" | "path"));
2127    !takes_path
2128        && segments
2129            .iter()
2130            .any(|segment| SECRETISH_FLAG_WORDS.contains(&segment.as_str()))
2131}
2132
2133/// Scrubs one `--header` value (`Name: Value`): values of [`SENSITIVE_HEADERS`]
2134/// are redacted keeping the name, other headers pass through (`None`), and a
2135/// value with no colon is redacted wholesale.
2136fn scrub_header_arg(value: &str) -> Option<String> {
2137    let Some((name, _)) = value.split_once(':') else {
2138        return Some("REDACTED".to_string());
2139    };
2140    SENSITIVE_HEADERS
2141        .contains(&name.trim().to_ascii_lowercase().as_str())
2142        .then(|| format!("{}: REDACTED", name.trim()))
2143}
2144
2145/// Returns the scrubbed replacement for the value of flag `--<name>`, or
2146/// `None` when the value is safe to log verbatim. `--body` keeps `@file`
2147/// references (a path, not a secret).
2148fn scrub_flag_value(name: &str, value: &str) -> Option<String> {
2149    match name {
2150        "header" => scrub_header_arg(value),
2151        "body" => (!value.starts_with('@')).then(|| "REDACTED".to_string()),
2152        "find" | "replacement" => Some("REDACTED".to_string()),
2153        _ if is_secretish_flag(name) => Some("REDACTED".to_string()),
2154        _ => None,
2155    }
2156}
2157
2158/// Scrubs secret-bearing values out of a raw argv before it is logged. Two
2159/// write-side layers, so the on-disk line is clean and every reader/format is
2160/// covered with no reader changes:
2161///
2162/// 1. [`scrub_flag_secrets`] — flag-aware whole-value redaction (`--header`/
2163///    `--body` plus any [`is_secretish_flag`] name, in both `--flag value` and
2164///    `--flag=value` forms).
2165/// 2. [`redact_url`] over every resulting element — a secret-bearing query or
2166///    fragment parameter on a URL argument (most naturally
2167///    `--url /path?access_token=…`, which no flag-name rule catches) has its
2168///    value redacted, while benign argv passes through byte-identical (#1162).
2169fn scrub_argv(argv: &[String]) -> Vec<String> {
2170    scrub_flag_secrets(argv)
2171        .iter()
2172        .map(|arg| redact_url(arg))
2173        .collect()
2174}
2175
2176/// Flag-aware first layer of [`scrub_argv`]: redacts secret-bearing flag values
2177/// (`--header`/`--body` plus any [`is_secretish_flag`] name, in both
2178/// `--flag value` and `--flag=value` forms). Everything else passes through to
2179/// the URL layer.
2180fn scrub_flag_secrets(argv: &[String]) -> Vec<String> {
2181    let mut out = Vec::with_capacity(argv.len());
2182    let mut i = 0;
2183    while i < argv.len() {
2184        let arg = &argv[i];
2185        i += 1;
2186        let Some(flag_body) = arg.strip_prefix("--") else {
2187            out.push(arg.clone());
2188            continue;
2189        };
2190        if let Some((name, value)) = flag_body.split_once('=') {
2191            match scrub_flag_value(name, value) {
2192                Some(scrubbed) => out.push(format!("--{name}={scrubbed}")),
2193                None => out.push(arg.clone()),
2194            }
2195        } else {
2196            out.push(arg.clone());
2197            let takes_secret_value =
2198                matches!(flag_body, "header" | "body" | "find" | "replacement")
2199                    || is_secretish_flag(flag_body);
2200            if takes_secret_value {
2201                if let Some(value) = argv.get(i) {
2202                    i += 1;
2203                    out.push(scrub_flag_value(flag_body, value).unwrap_or_else(|| value.clone()));
2204                }
2205            }
2206        }
2207    }
2208    out
2209}
2210
2211/// Query/fragment keys that are secrets outright (compared decoded + lowercased).
2212const SENSITIVE_QUERY_KEYS: &[&str] = &["sig", "sas", "jwt", "auth"];
2213
2214/// Key suffixes marking the open-ended secret families (`access_token`,
2215/// `client_secret`, `api_key`, …).
2216const SENSITIVE_QUERY_KEY_SUFFIXES: &[&str] = &[
2217    "token",
2218    "secret",
2219    "password",
2220    "passwd",
2221    "signature",
2222    "apikey",
2223    "api_key",
2224    "api-key",
2225];
2226
2227/// Key prefixes for cloud-storage signed-URL parameter families.
2228const SENSITIVE_QUERY_KEY_PREFIXES: &[&str] = &["x-amz-", "x-goog-"];
2229
2230/// Returns whether a decoded query/fragment key looks secret-bearing.
2231fn sensitive_query_key(key: &str) -> bool {
2232    let key = key.to_ascii_lowercase();
2233    SENSITIVE_QUERY_KEYS.contains(&key.as_str())
2234        || SENSITIVE_QUERY_KEY_SUFFIXES
2235            .iter()
2236            .any(|suffix| key.ends_with(suffix))
2237        || SENSITIVE_QUERY_KEY_PREFIXES
2238            .iter()
2239            .any(|prefix| key.starts_with(prefix))
2240}
2241
2242/// Rewrites one `&`-separated pair list, replacing the values of
2243/// secret-bearing keys with `REDACTED` and passing every other segment
2244/// through byte-verbatim.
2245fn redact_pairs(pairs: &str) -> String {
2246    pairs
2247        .split('&')
2248        .map(|segment| match segment.split_once('=') {
2249            Some((raw_key, _)) => {
2250                // Decode only the key (handles `access%5Ftoken` and `+`); the
2251                // raw key text is preserved in the output.
2252                let sensitive = url::form_urlencoded::parse(raw_key.as_bytes())
2253                    .next()
2254                    .is_some_and(|(key, _)| sensitive_query_key(&key));
2255                if sensitive {
2256                    format!("{raw_key}=REDACTED")
2257                } else {
2258                    segment.to_string()
2259                }
2260            }
2261            // A bare key (no `=`) carries no value to leak.
2262            None => segment.to_string(),
2263        })
2264        .collect::<Vec<_>>()
2265        .join("&")
2266}
2267
2268/// Redacts secret-bearing query and fragment parameter values in a URL,
2269/// preserving scheme, host, path, and all parameter keys so `--url` substring
2270/// filtering stays useful. Handles relative URLs (the browser bridge logs
2271/// page-origin targets like `/api/foo?sig=…`), so this never requires the
2272/// input to parse as an absolute [`url::Url`].
2273fn redact_url(url: &str) -> String {
2274    let (rest, fragment) = url
2275        .split_once('#')
2276        .map_or((url, None), |(rest, fragment)| (rest, Some(fragment)));
2277    let (prefix, query) = rest
2278        .split_once('?')
2279        .map_or((rest, None), |(prefix, query)| (prefix, Some(query)));
2280    let mut out = prefix.to_string();
2281    if let Some(query) = query {
2282        out.push('?');
2283        out.push_str(&redact_pairs(query));
2284    }
2285    if let Some(fragment) = fragment {
2286        out.push('#');
2287        out.push_str(&redact_pairs(fragment));
2288    }
2289    out
2290}
2291
2292/// A time-sortable id: 13-digit zero-padded epoch-millis, a dash, then 16 hex.
2293///
2294/// Lexical order ≈ chronological order, which is all the reader needs. Mirrors
2295/// the uuid-shaped minting in [`crate::snowflake::client`] without adding a
2296/// crate.
2297pub fn new_id() -> String {
2298    let millis = chrono::Utc::now().timestamp_millis().max(0);
2299    let suffix = rand::random::<u64>();
2300    format!("{millis:013}-{suffix:016x}")
2301}
2302
2303/// Current time as RFC3339 with millisecond precision, in UTC.
2304fn now_rfc3339_millis() -> String {
2305    chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
2306}
2307
2308/// Best-effort current working directory.
2309fn cwd() -> String {
2310    std::env::current_dir()
2311        .map(|p| p.display().to_string())
2312        .unwrap_or_default()
2313}
2314
2315/// Best-effort OS username (`$USER`, then the passwd entry for the euid).
2316fn system_user() -> String {
2317    if let Ok(user) = std::env::var("USER") {
2318        if !user.is_empty() {
2319            return user;
2320        }
2321    }
2322    #[cfg(unix)]
2323    {
2324        if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid()) {
2325            return user.name;
2326        }
2327    }
2328    String::new()
2329}
2330
2331/// Best-effort hostname (`gethostname`, then `$HOSTNAME`, then empty).
2332fn hostname() -> String {
2333    #[cfg(unix)]
2334    {
2335        if let Ok(name) = nix::unistd::gethostname() {
2336            if let Some(name) = name.to_str() {
2337                if !name.is_empty() {
2338                    return name.to_string();
2339                }
2340            }
2341        }
2342    }
2343    std::env::var("HOSTNAME").unwrap_or_default()
2344}
2345
2346/// Names matching these substrings have their env values redacted, guarding
2347/// against any future secret-bearing `OMNI_DEV_*` var.
2348const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
2349
2350/// Snapshot of `OMNI_DEV_*` env vars, with secret-looking values redacted.
2351fn whitelisted_env() -> BTreeMap<String, String> {
2352    std::env::vars()
2353        .filter(|(k, _)| k.starts_with("OMNI_DEV_"))
2354        .map(|(k, v)| {
2355            let secretish = SECRETISH.iter().any(|needle| k.contains(needle));
2356            let value = if secretish { "REDACTED".to_string() } else { v };
2357            (k, value)
2358        })
2359        .collect()
2360}
2361
2362#[cfg(test)]
2363#[allow(clippy::unwrap_used, clippy::expect_used)]
2364mod tests {
2365    use super::*;
2366    use crate::test_support::env::MapEnv;
2367
2368    #[test]
2369    fn record_round_trips_through_json() {
2370        let mut rec = LogRecord::new(RecordKind::Http, "inv-1".to_string());
2371        rec.service = Some("jira".to_string());
2372        rec.method = Some("GET".to_string());
2373        rec.url = Some("https://example.atlassian.net/rest/api/3/issue/X-1".to_string());
2374        rec.status_code = Some(200);
2375        rec.elapsed_ms = Some(42);
2376
2377        let line = serde_json::to_string(&rec).unwrap();
2378        let back: LogRecord = serde_json::from_str(&line).unwrap();
2379        assert_eq!(back.invocation_id, "inv-1");
2380        assert_eq!(back.kind, RecordKind::Http);
2381        assert_eq!(back.service.as_deref(), Some("jira"));
2382        assert_eq!(back.status_code, Some(200));
2383    }
2384
2385    #[test]
2386    fn reader_tolerates_unknown_fields() {
2387        let line = r#"{"id":"x","invocation_id":"i","kind":"http","method":"GET",
2388            "future_field":{"nested":true},"another":42}"#;
2389        let rec: LogRecord = serde_json::from_str(line).unwrap();
2390        assert_eq!(rec.kind, RecordKind::Http);
2391        assert_eq!(rec.method.as_deref(), Some("GET"));
2392    }
2393
2394    #[test]
2395    fn reader_tolerates_missing_newer_fields() {
2396        // An "old" line with only a couple of fields present.
2397        let line = r#"{"kind":"invocation","command":["git","view"]}"#;
2398        let rec: LogRecord = serde_json::from_str(line).unwrap();
2399        assert_eq!(rec.kind, RecordKind::Invocation);
2400        assert_eq!(rec.command, vec!["git", "view"]);
2401        assert!(rec.status_code.is_none());
2402        assert!(rec.id.is_empty());
2403    }
2404
2405    #[test]
2406    fn unknown_kind_and_source_do_not_fail() {
2407        let line = r#"{"kind":"telemetry","source":"webhook"}"#;
2408        let rec: LogRecord = serde_json::from_str(line).unwrap();
2409        assert_eq!(rec.kind, RecordKind::Unknown);
2410        assert_eq!(rec.source, Some(Source::Unknown));
2411    }
2412
2413    #[test]
2414    fn optional_fields_are_skipped_when_empty() {
2415        let rec = LogRecord::new(RecordKind::Invocation, "i".to_string());
2416        let line = serde_json::to_string(&rec).unwrap();
2417        // Empty collections / None options must not appear on the wire.
2418        assert!(!line.contains("status_code"));
2419        assert!(!line.contains("request_headers"));
2420        assert!(!line.contains("via_daemon"));
2421        assert!(!line.contains("\"env\""));
2422    }
2423
2424    #[test]
2425    fn ids_are_time_sortable() {
2426        let a = new_id();
2427        std::thread::sleep(std::time::Duration::from_millis(2));
2428        let b = new_id();
2429        assert!(a < b, "{a} should sort before {b}");
2430    }
2431
2432    #[test]
2433    fn sensitive_headers_are_redacted() {
2434        let mut headers = BTreeMap::new();
2435        headers.insert("Authorization".to_string(), "Bearer secret".to_string());
2436        headers.insert("X-Api-Key".to_string(), "abc123".to_string());
2437        headers.insert("Content-Type".to_string(), "application/json".to_string());
2438        let out = redact_headers(&headers);
2439        assert_eq!(out["Authorization"], "REDACTED");
2440        assert_eq!(out["X-Api-Key"], "REDACTED");
2441        assert_eq!(out["Content-Type"], "application/json");
2442    }
2443
2444    fn argv(args: &[&str]) -> Vec<String> {
2445        args.iter().copied().map(String::from).collect()
2446    }
2447
2448    #[test]
2449    fn build_gh_record_stamps_kind_source_and_split_command() {
2450        let ctx = RequestLogContext {
2451            invocation_id: "inv-1".to_string(),
2452            source: Source::Daemon,
2453            mcp_tool: None,
2454        };
2455        let rec = build_gh_record(
2456            GhOutcome {
2457                label: "api graphql".to_string(),
2458                argv: argv(&["api", "graphql", "-f", "query=xyz"]),
2459                exit_code: Some(0),
2460                duration: Duration::from_millis(120),
2461                error: None,
2462            },
2463            ctx,
2464        );
2465        assert_eq!(rec.kind, RecordKind::Gh);
2466        assert_eq!(rec.invocation_id, "inv-1");
2467        assert_eq!(rec.source, Some(Source::Daemon));
2468        // The label is split into `command` for per-subcommand aggregation.
2469        assert_eq!(rec.command, argv(&["api", "graphql"]));
2470        assert_eq!(
2471            rec.command_line,
2472            argv(&["api", "graphql", "-f", "query=xyz"])
2473        );
2474        assert_eq!(rec.exit_code, Some(0));
2475        assert_eq!(rec.duration_ms, Some(120));
2476        assert!(rec.error.is_none());
2477    }
2478
2479    #[test]
2480    fn build_gh_record_scrubs_secret_bearing_argv() {
2481        // Defense in depth: even though `gh` never receives our auth, a
2482        // secret-bearing flag value in the argv is redacted before write.
2483        let rec = build_gh_record(
2484            GhOutcome {
2485                label: "api graphql".to_string(),
2486                argv: argv(&["api", "--header", "Authorization: Bearer sekret"]),
2487                exit_code: Some(0),
2488                duration: Duration::from_millis(5),
2489                error: None,
2490            },
2491            RequestLogContext::default(),
2492        );
2493        assert_eq!(
2494            rec.command_line,
2495            argv(&["api", "--header", "Authorization: REDACTED"])
2496        );
2497    }
2498
2499    #[test]
2500    fn build_worktree_record_stamps_kind_service_command_and_context() {
2501        let ctx = RequestLogContext {
2502            invocation_id: "inv-2".to_string(),
2503            source: Source::Mcp,
2504            mcp_tool: Some("some_tool".to_string()),
2505        };
2506        let mut context = BTreeMap::new();
2507        context.insert("path".to_string(), "/tmp/wt".to_string());
2508        context.insert("branch".to_string(), "demo-wt".to_string());
2509        context.insert("had_uncommitted".to_string(), "true".to_string());
2510        let rec = build_worktree_record(
2511            WorktreeOutcome {
2512                verb: "remove".to_string(),
2513                argv: argv(&["worktree", "remove", "--force", "/tmp/wt"]),
2514                exit_code: Some(0),
2515                duration: Duration::from_millis(42),
2516                error: None,
2517                context,
2518            },
2519            ctx,
2520        );
2521        assert_eq!(rec.kind, RecordKind::Worktree);
2522        assert_eq!(rec.invocation_id, "inv-2");
2523        assert_eq!(rec.source, Some(Source::Mcp));
2524        assert_eq!(rec.mcp_tool.as_deref(), Some("some_tool"));
2525        assert_eq!(rec.service.as_deref(), Some("worktree"));
2526        assert_eq!(rec.command, argv(&["git", "worktree", "remove"]));
2527        assert_eq!(
2528            rec.command_line,
2529            argv(&["worktree", "remove", "--force", "/tmp/wt"])
2530        );
2531        assert_eq!(rec.exit_code, Some(0));
2532        assert_eq!(rec.duration_ms, Some(42));
2533        assert_eq!(
2534            rec.context.get("branch").map(String::as_str),
2535            Some("demo-wt")
2536        );
2537        assert_eq!(
2538            rec.context.get("had_uncommitted").map(String::as_str),
2539            Some("true")
2540        );
2541    }
2542
2543    #[test]
2544    fn record_kind_worktree_serializes_as_worktree_and_round_trips() {
2545        let rec = build_worktree_record(
2546            WorktreeOutcome {
2547                verb: "add".to_string(),
2548                argv: argv(&["worktree", "add", "wt"]),
2549                exit_code: Some(1),
2550                duration: Duration::from_millis(1),
2551                error: Some("boom".to_string()),
2552                context: BTreeMap::new(),
2553            },
2554            RequestLogContext::default(),
2555        );
2556        let line = serde_json::to_string(&rec).unwrap();
2557        assert!(line.contains("\"kind\":\"worktree\""), "line was: {line}");
2558        assert!(
2559            line.contains("\"service\":\"worktree\""),
2560            "line was: {line}"
2561        );
2562        // The display name matches the wire form.
2563        assert_eq!(RecordKind::Worktree.as_str(), "worktree");
2564        let back: LogRecord = serde_json::from_str(&line).unwrap();
2565        assert_eq!(back.kind, RecordKind::Worktree);
2566        assert_eq!(back.command, argv(&["git", "worktree", "add"]));
2567        assert_eq!(back.error.as_deref(), Some("boom"));
2568    }
2569
2570    #[test]
2571    fn build_drive_mutation_record_stamps_kind_service_command_and_context() {
2572        let ctx = RequestLogContext {
2573            invocation_id: "inv-3".to_string(),
2574            source: Source::Mcp,
2575            mcp_tool: Some("drive_file_move".to_string()),
2576        };
2577        let rec = build_drive_mutation_record(
2578            DriveMutationOutcome {
2579                operation: "move",
2580                file_id: "f1".to_string(),
2581                file_name: "report.pdf".to_string(),
2582                status: "blocked".to_string(),
2583                added_principals: vec!["alice@example.com".to_string()],
2584                removed_principals: vec![],
2585                crosses_drive_boundary: true,
2586                resolved_folder_id: Some("dest1".to_string()),
2587                decided_by_folder_id: Some("dest1".to_string()),
2588                decided_by_depth: Some(0),
2589                error: None,
2590                duration: Duration::from_millis(17),
2591                ..Default::default()
2592            },
2593            ctx,
2594        );
2595        assert_eq!(rec.kind, RecordKind::DriveMutation);
2596        assert_eq!(rec.invocation_id, "inv-3");
2597        assert_eq!(rec.source, Some(Source::Mcp));
2598        assert_eq!(rec.mcp_tool.as_deref(), Some("drive_file_move"));
2599        assert_eq!(rec.service.as_deref(), Some("drive"));
2600        assert_eq!(rec.command, vec!["drive".to_string(), "move".to_string()]);
2601        assert_eq!(rec.duration_ms, Some(17));
2602        assert_eq!(rec.context.get("file_id").map(String::as_str), Some("f1"));
2603        assert_eq!(
2604            rec.context.get("file_name").map(String::as_str),
2605            Some("report.pdf")
2606        );
2607        assert_eq!(
2608            rec.context.get("status").map(String::as_str),
2609            Some("blocked")
2610        );
2611        assert_eq!(
2612            rec.context.get("added_principals").map(String::as_str),
2613            Some("alice@example.com")
2614        );
2615        assert_eq!(rec.context.get("removed_principals"), None);
2616        assert_eq!(
2617            rec.context
2618                .get("crosses_drive_boundary")
2619                .map(String::as_str),
2620            Some("true")
2621        );
2622        assert_eq!(
2623            rec.context.get("resolved_folder_id").map(String::as_str),
2624            Some("dest1")
2625        );
2626        assert_eq!(
2627            rec.context.get("decided_by_folder_id").map(String::as_str),
2628            Some("dest1")
2629        );
2630        assert_eq!(
2631            rec.context.get("decided_by_depth").map(String::as_str),
2632            Some("0")
2633        );
2634    }
2635
2636    #[test]
2637    fn build_drive_mutation_record_omits_empty_principal_lists_and_false_boundary() {
2638        let rec = build_drive_mutation_record(
2639            DriveMutationOutcome {
2640                operation: "rename",
2641                file_id: "f2".to_string(),
2642                file_name: "old.txt".to_string(),
2643                status: "moved".to_string(),
2644                added_principals: vec![],
2645                removed_principals: vec![],
2646                crosses_drive_boundary: false,
2647                resolved_folder_id: None,
2648                decided_by_folder_id: None,
2649                decided_by_depth: None,
2650                error: None,
2651                duration: Duration::from_millis(5),
2652                ..Default::default()
2653            },
2654            RequestLogContext::default(),
2655        );
2656        assert_eq!(rec.context.get("added_principals"), None);
2657        assert_eq!(rec.context.get("removed_principals"), None);
2658        assert_eq!(rec.context.get("crosses_drive_boundary"), None);
2659        assert_eq!(rec.context.get("resolved_folder_id"), None);
2660        assert_eq!(rec.context.get("decided_by_folder_id"), None);
2661        assert_eq!(rec.context.get("decided_by_depth"), None);
2662    }
2663
2664    #[test]
2665    fn build_drive_mutation_record_includes_the_structural_context_keys() {
2666        let rec = build_drive_mutation_record(
2667            DriveMutationOutcome {
2668                operation: "sheets-insert-rows",
2669                file_id: "s1".to_string(),
2670                file_name: "Budget".to_string(),
2671                status: "changed".to_string(),
2672                sheet_id: Some(118_293),
2673                sheet_title: Some("Q2".to_string()),
2674                dimension_range: Some("ROWS 5:7".to_string()),
2675                duration: Duration::from_millis(1),
2676                ..Default::default()
2677            },
2678            RequestLogContext::default(),
2679        );
2680        assert_eq!(rec.command, vec!["drive", "sheets-insert-rows"]);
2681        assert_eq!(
2682            rec.context.get("sheet_id").map(String::as_str),
2683            Some("118293")
2684        );
2685        assert_eq!(
2686            rec.context.get("sheet_title").map(String::as_str),
2687            Some("Q2")
2688        );
2689        assert_eq!(
2690            rec.context.get("dimension_range").map(String::as_str),
2691            Some("ROWS 5:7")
2692        );
2693        // A structural verb has no A1 range and reports no cell counts, so
2694        // those keys stay absent rather than being written as empty.
2695        assert_eq!(rec.context.get("range"), None);
2696        assert_eq!(rec.context.get("updated_cells"), None);
2697        // An insert renames nothing.
2698        assert_eq!(rec.context.get("sheet_new_title"), None);
2699        // An insert spans one dimension, not a rectangle.
2700        assert_eq!(rec.context.get("grid_range"), None);
2701        // Only a move has a destination.
2702        assert_eq!(rec.context.get("move_to"), None);
2703    }
2704
2705    #[test]
2706    fn build_drive_mutation_record_includes_the_move_to_key_for_a_move() {
2707        // Issue #1834: `dimension_range` names the span that moved, which
2708        // is only half the effect — without `move_to` the record could not
2709        // say where it went. Both are 1-based and numbered as the sheet
2710        // stood before the move, matching `--at`/`--count`/`--before`.
2711        let rec = build_drive_mutation_record(
2712            DriveMutationOutcome {
2713                operation: "sheets-move-rows",
2714                file_id: "s1".to_string(),
2715                file_name: "Budget".to_string(),
2716                status: "changed".to_string(),
2717                sheet_id: Some(118_293),
2718                sheet_title: Some("Q2".to_string()),
2719                dimension_range: Some("ROWS 2:3".to_string()),
2720                move_to: Some(6),
2721                duration: Duration::from_millis(1),
2722                ..Default::default()
2723            },
2724            RequestLogContext::default(),
2725        );
2726        assert_eq!(rec.command, vec!["drive", "sheets-move-rows"]);
2727        assert_eq!(
2728            rec.context.get("dimension_range").map(String::as_str),
2729            Some("ROWS 2:3")
2730        );
2731        assert_eq!(rec.context.get("move_to").map(String::as_str), Some("6"));
2732        assert_eq!(rec.context.get("grid_range"), None);
2733    }
2734
2735    #[test]
2736    fn build_drive_mutation_record_includes_the_grid_range_key_for_a_delete_range() {
2737        // The `deleteRange` analogue of the dimension_range test above (issue
2738        // #1623): a rectangle spans both axes at once, so it gets its own
2739        // key rather than overloading `dimension_range`, which
2740        // `StructureVerb::dimension` reports `None` for on this verb.
2741        let rec = build_drive_mutation_record(
2742            DriveMutationOutcome {
2743                operation: "sheets-delete-range",
2744                file_id: "s1".to_string(),
2745                file_name: "Budget".to_string(),
2746                status: "changed".to_string(),
2747                sheet_id: Some(118_293),
2748                sheet_title: Some("Q2".to_string()),
2749                grid_range: Some("rows 2-4, columns 2-3".to_string()),
2750                duration: Duration::from_millis(1),
2751                ..Default::default()
2752            },
2753            RequestLogContext::default(),
2754        );
2755        assert_eq!(
2756            rec.context.get("grid_range").map(String::as_str),
2757            Some("rows 2-4, columns 2-3")
2758        );
2759        assert_eq!(rec.context.get("dimension_range"), None);
2760    }
2761
2762    #[test]
2763    fn build_drive_mutation_record_includes_the_formatting_validation_and_protection_keys() {
2764        // issue #1643: the formatting/validation/protection verbs' new
2765        // context keys, including the three collection fields that only
2766        // write a key when non-empty (`discarded_cells`,
2767        // `protection_editors_added`, `protection_editors_removed`).
2768        let rec = build_drive_mutation_record(
2769            DriveMutationOutcome {
2770                operation: "sheets-merge-cells",
2771                file_id: "s1".to_string(),
2772                file_name: "Budget".to_string(),
2773                status: "changed".to_string(),
2774                fields_changed: Some("merged A1:B2".to_string()),
2775                discarded_cells: vec!["B1: gone".to_string()],
2776                validation_type: Some("ONE_OF_LIST".to_string()),
2777                protected_range_id: Some(42),
2778                protection_editors_added: vec!["alice@example.com".to_string()],
2779                protection_editors_removed: vec!["bob@example.com".to_string()],
2780                duration: Duration::from_millis(1),
2781                ..Default::default()
2782            },
2783            RequestLogContext::default(),
2784        );
2785        assert_eq!(
2786            rec.context.get("fields_changed").map(String::as_str),
2787            Some("merged A1:B2")
2788        );
2789        assert_eq!(
2790            rec.context.get("discarded_cells").map(String::as_str),
2791            Some("B1: gone")
2792        );
2793        assert_eq!(
2794            rec.context.get("validation_type").map(String::as_str),
2795            Some("ONE_OF_LIST")
2796        );
2797        assert_eq!(
2798            rec.context.get("protected_range_id").map(String::as_str),
2799            Some("42")
2800        );
2801        assert_eq!(
2802            rec.context
2803                .get("protection_editors_added")
2804                .map(String::as_str),
2805            Some("alice@example.com")
2806        );
2807        assert_eq!(
2808            rec.context
2809                .get("protection_editors_removed")
2810                .map(String::as_str),
2811            Some("bob@example.com")
2812        );
2813    }
2814
2815    #[test]
2816    fn build_drive_mutation_record_omits_the_formatting_validation_and_protection_keys_when_unset()
2817    {
2818        let rec = build_drive_mutation_record(
2819            DriveMutationOutcome {
2820                operation: "sheets-write",
2821                file_id: "s1".to_string(),
2822                file_name: "Budget".to_string(),
2823                status: "written".to_string(),
2824                duration: Duration::from_millis(1),
2825                ..Default::default()
2826            },
2827            RequestLogContext::default(),
2828        );
2829        assert_eq!(rec.context.get("fields_changed"), None);
2830        assert_eq!(rec.context.get("discarded_cells"), None);
2831        assert_eq!(rec.context.get("validation_type"), None);
2832        assert_eq!(rec.context.get("protected_range_id"), None);
2833        assert_eq!(rec.context.get("protection_editors_added"), None);
2834        assert_eq!(rec.context.get("protection_editors_removed"), None);
2835    }
2836
2837    #[test]
2838    fn build_drive_mutation_record_records_both_titles_for_a_rename() {
2839        // The one structural verb whose effect is otherwise unrecoverable
2840        // from its record: `sheet_title` necessarily holds the *old* title.
2841        let rec = build_drive_mutation_record(
2842            DriveMutationOutcome {
2843                operation: "sheets-rename-sheet",
2844                file_id: "s1".to_string(),
2845                file_name: "Budget".to_string(),
2846                status: "changed".to_string(),
2847                sheet_id: Some(118_293),
2848                sheet_title: Some("Q2".to_string()),
2849                sheet_new_title: Some("Q2 (final)".to_string()),
2850                duration: Duration::from_millis(1),
2851                ..Default::default()
2852            },
2853            RequestLogContext::default(),
2854        );
2855        assert_eq!(
2856            rec.context.get("sheet_title").map(String::as_str),
2857            Some("Q2")
2858        );
2859        assert_eq!(
2860            rec.context.get("sheet_new_title").map(String::as_str),
2861            Some("Q2 (final)")
2862        );
2863        // A rename spans no dimension.
2864        assert_eq!(rec.context.get("dimension_range"), None);
2865    }
2866
2867    #[test]
2868    fn build_drive_mutation_record_omits_structural_keys_for_a_cell_write() {
2869        let rec = build_drive_mutation_record(
2870            DriveMutationOutcome {
2871                operation: "sheets-write",
2872                file_id: "s1".to_string(),
2873                file_name: "Budget".to_string(),
2874                status: "written".to_string(),
2875                range: Some("A1:B2".to_string()),
2876                duration: Duration::from_millis(1),
2877                ..Default::default()
2878            },
2879            RequestLogContext::default(),
2880        );
2881        assert_eq!(rec.context.get("sheet_id"), None);
2882        assert_eq!(rec.context.get("sheet_title"), None);
2883        assert_eq!(rec.context.get("sheet_new_title"), None);
2884        assert_eq!(rec.context.get("dimension_range"), None);
2885        assert_eq!(rec.context.get("grid_range"), None);
2886    }
2887
2888    #[test]
2889    fn build_drive_mutation_record_includes_range_and_every_sheets_count() {
2890        let rec = build_drive_mutation_record(
2891            DriveMutationOutcome {
2892                operation: "sheets-write",
2893                file_id: "s1".to_string(),
2894                file_name: "Budget".to_string(),
2895                status: "written".to_string(),
2896                removed_principals: vec!["bob@example.com".to_string()],
2897                range: Some("A1:B2".to_string()),
2898                updated_range: Some("'Q1'!A1:B2".to_string()),
2899                updated_rows: Some(3),
2900                updated_columns: Some(2),
2901                updated_cells: Some(6),
2902                duration: Duration::from_millis(1),
2903                ..Default::default()
2904            },
2905            RequestLogContext::default(),
2906        );
2907        assert_eq!(
2908            rec.context.get("removed_principals").map(String::as_str),
2909            Some("bob@example.com")
2910        );
2911        assert_eq!(rec.context.get("range").map(String::as_str), Some("A1:B2"));
2912        assert_eq!(
2913            rec.context.get("updated_range").map(String::as_str),
2914            Some("'Q1'!A1:B2")
2915        );
2916        assert_eq!(
2917            rec.context.get("updated_rows").map(String::as_str),
2918            Some("3")
2919        );
2920        assert_eq!(
2921            rec.context.get("updated_columns").map(String::as_str),
2922            Some("2")
2923        );
2924        assert_eq!(
2925            rec.context.get("updated_cells").map(String::as_str),
2926            Some("6")
2927        );
2928    }
2929
2930    #[test]
2931    fn auto_fill_record_includes_only_overwritten_addresses() {
2932        let rec = build_drive_mutation_record(
2933            DriveMutationOutcome {
2934                operation: "sheets-auto-fill",
2935                file_id: "sheet-1".into(),
2936                file_name: "Budget".into(),
2937                status: "changed".into(),
2938                range: Some("'Q1'!A4:A5".into()),
2939                overwritten_cells: vec!["A4".into(), "A5".into()],
2940                duration: Duration::from_millis(1),
2941                ..Default::default()
2942            },
2943            RequestLogContext::default(),
2944        );
2945        assert_eq!(
2946            rec.context.get("overwritten_cells").map(String::as_str),
2947            Some("A4, A5")
2948        );
2949        assert_eq!(
2950            rec.context.get("range").map(String::as_str),
2951            Some("'Q1'!A4:A5")
2952        );
2953        assert_eq!(rec.context.get("updated_cells"), None);
2954        // `--source` computes the destination client-side, so the list is
2955        // exact and the upper-bound marker stays absent.
2956        assert_eq!(rec.context.get("overwritten_cells_are_upper_bound"), None);
2957    }
2958
2959    /// `auto-fill --range` lets Sheets pick the source/destination split
2960    /// itself, so the same list is an upper bound — some of those cells
2961    /// are the source and were never touched. The record has to say so,
2962    /// or a reader takes them for cells that certainly changed.
2963    #[test]
2964    fn auto_fill_range_form_marks_its_overwritten_cells_as_an_upper_bound() {
2965        let rec = build_drive_mutation_record(
2966            DriveMutationOutcome {
2967                operation: "sheets-auto-fill",
2968                file_id: "sheet-1".into(),
2969                file_name: "Budget".into(),
2970                status: "changed".into(),
2971                range: Some("'Q1'!A1:A10".into()),
2972                overwritten_cells: vec!["A1".into(), "A2".into()],
2973                overwritten_cells_upper_bound: true,
2974                duration: Duration::from_millis(1),
2975                ..Default::default()
2976            },
2977            RequestLogContext::default(),
2978        );
2979        assert_eq!(
2980            rec.context
2981                .get("overwritten_cells_are_upper_bound")
2982                .map(String::as_str),
2983            Some("true")
2984        );
2985    }
2986
2987    /// `text-to-columns` never knows exactly how many columns the API's
2988    /// own split will need, so every run's `overwritten_cells` is an
2989    /// upper bound — unlike `auto-fill`, this is unconditional rather
2990    /// than form-dependent.
2991    #[test]
2992    fn text_to_columns_record_always_marks_its_overwritten_cells_as_an_upper_bound() {
2993        let rec = build_drive_mutation_record(
2994            DriveMutationOutcome {
2995                operation: "sheets-text-to-columns",
2996                file_id: "sheet-1".into(),
2997                file_name: "Budget".into(),
2998                status: "changed".into(),
2999                range: Some("'Q1'!A2:A4".into()),
3000                fields_changed: Some(
3001                    "split 'Q1'!A2:A4 on comma into up to 2 column(s), spill 'Q1'!B2:B4".into(),
3002                ),
3003                overwritten_cells: vec!["B3".into()],
3004                overwritten_cells_upper_bound: true,
3005                duration: Duration::from_millis(1),
3006                ..Default::default()
3007            },
3008            RequestLogContext::default(),
3009        );
3010        assert_eq!(
3011            rec.context.get("overwritten_cells").map(String::as_str),
3012            Some("B3")
3013        );
3014        assert_eq!(
3015            rec.context
3016                .get("overwritten_cells_are_upper_bound")
3017                .map(String::as_str),
3018            Some("true")
3019        );
3020        // Never the split pieces or the source's contents — only the A1
3021        // address and the prose summary (ADR-0083 §6).
3022        assert!(!rec.context.get("fields_changed").unwrap().contains("a,b"));
3023    }
3024
3025    #[test]
3026    fn record_kind_drive_mutation_serializes_as_drivemutation_and_round_trips() {
3027        let rec = build_drive_mutation_record(
3028            DriveMutationOutcome {
3029                operation: "rename",
3030                file_id: "f1".to_string(),
3031                file_name: "a.txt".to_string(),
3032                status: "failed".to_string(),
3033                added_principals: vec![],
3034                removed_principals: vec![],
3035                crosses_drive_boundary: false,
3036                resolved_folder_id: None,
3037                decided_by_folder_id: None,
3038                decided_by_depth: None,
3039                error: Some("boom".to_string()),
3040                duration: Duration::from_millis(1),
3041                ..Default::default()
3042            },
3043            RequestLogContext::default(),
3044        );
3045        let line = serde_json::to_string(&rec).unwrap();
3046        assert!(
3047            line.contains("\"kind\":\"drivemutation\""),
3048            "line was: {line}"
3049        );
3050        assert_eq!(RecordKind::DriveMutation.as_str(), "drivemutation");
3051        let back: LogRecord = serde_json::from_str(&line).unwrap();
3052        assert_eq!(back.kind, RecordKind::DriveMutation);
3053        assert_eq!(
3054            back.command,
3055            vec!["drive".to_string(), "rename".to_string()]
3056        );
3057        assert_eq!(back.error.as_deref(), Some("boom"));
3058    }
3059
3060    /// Confirms the existing `kind: "drivemutation"` record — reused, not a
3061    /// new `RecordKind` — round-trips identically for the new `create`
3062    /// operation issue #1574 adds (ADR-0071 §8).
3063    #[test]
3064    fn build_drive_mutation_record_round_trips_for_create_operation() {
3065        let rec = build_drive_mutation_record(
3066            DriveMutationOutcome {
3067                operation: "create",
3068                file_id: "f1".to_string(),
3069                file_name: "New File".to_string(),
3070                status: "created".to_string(),
3071                added_principals: vec![],
3072                removed_principals: vec![],
3073                crosses_drive_boundary: false,
3074                resolved_folder_id: Some("parent1".to_string()),
3075                decided_by_folder_id: None,
3076                decided_by_depth: None,
3077                error: None,
3078                duration: Duration::from_millis(1),
3079                ..Default::default()
3080            },
3081            RequestLogContext::default(),
3082        );
3083        assert_eq!(rec.command, vec!["drive".to_string(), "create".to_string()]);
3084        assert_eq!(
3085            rec.context.get("resolved_folder_id").map(String::as_str),
3086            Some("parent1")
3087        );
3088        assert_eq!(rec.context.get("decided_by_folder_id"), None);
3089        let line = serde_json::to_string(&rec).unwrap();
3090        let back: LogRecord = serde_json::from_str(&line).unwrap();
3091        assert_eq!(back.kind, RecordKind::DriveMutation);
3092    }
3093
3094    #[test]
3095    fn record_kind_gh_serializes_as_gh_and_round_trips() {
3096        let rec = build_gh_record(
3097            GhOutcome {
3098                label: "pr list".to_string(),
3099                argv: argv(&["pr", "list"]),
3100                exit_code: Some(1),
3101                duration: Duration::from_millis(1),
3102                error: Some("boom".to_string()),
3103            },
3104            RequestLogContext::default(),
3105        );
3106        let line = serde_json::to_string(&rec).unwrap();
3107        assert!(line.contains("\"kind\":\"gh\""), "line was: {line}");
3108        let back: LogRecord = serde_json::from_str(&line).unwrap();
3109        assert_eq!(back.kind, RecordKind::Gh);
3110        assert_eq!(back.command, argv(&["pr", "list"]));
3111        assert_eq!(back.error.as_deref(), Some("boom"));
3112    }
3113
3114    #[test]
3115    fn build_audit_record_stamps_kind_service_command_and_context() {
3116        let ctx = RequestLogContext {
3117            invocation_id: "inv-4".to_string(),
3118            source: Source::Cli,
3119            mcp_tool: None,
3120        };
3121        let rec = build_audit_record(
3122            AuditOutcome {
3123                command: vec!["drive".to_string(), "lease-acquire".to_string()],
3124                integration: "drive",
3125                file_id: "f1".to_string(),
3126                lease_id: Some("lease-1".to_string()),
3127                verdict: "acquired".to_string(),
3128                version_after: Some("42".to_string()),
3129                modified_time_after: Some("2026-09-12T00:00:00Z".to_string()),
3130                backup_location: Some("/tmp/backup".to_string()),
3131                backup_sha256: Some("deadbeef".to_string()),
3132                backup_size: Some(5),
3133                auth_policy: Some("device-owner".to_string()),
3134                ..Default::default()
3135            },
3136            ctx,
3137        );
3138        assert_eq!(rec.kind, RecordKind::Audit);
3139        assert_eq!(rec.invocation_id, "inv-4");
3140        assert_eq!(rec.source, Some(Source::Cli));
3141        assert_eq!(rec.service.as_deref(), Some("drive"));
3142        assert_eq!(
3143            rec.command,
3144            vec!["drive".to_string(), "lease-acquire".to_string()]
3145        );
3146        assert_eq!(
3147            rec.context.get("integration").map(String::as_str),
3148            Some("drive")
3149        );
3150        assert_eq!(rec.context.get("file_id").map(String::as_str), Some("f1"));
3151        assert_eq!(
3152            rec.context.get("lease_id").map(String::as_str),
3153            Some("lease-1")
3154        );
3155        assert_eq!(
3156            rec.context.get("verdict").map(String::as_str),
3157            Some("acquired")
3158        );
3159        assert_eq!(
3160            rec.context.get("version_after").map(String::as_str),
3161            Some("42")
3162        );
3163        assert_eq!(
3164            rec.context.get("modified_time_after").map(String::as_str),
3165            Some("2026-09-12T00:00:00Z")
3166        );
3167        assert_eq!(
3168            rec.context.get("backup_location").map(String::as_str),
3169            Some("/tmp/backup")
3170        );
3171        assert_eq!(
3172            rec.context.get("backup_sha256").map(String::as_str),
3173            Some("deadbeef")
3174        );
3175        assert_eq!(
3176            rec.context.get("backup_size").map(String::as_str),
3177            Some("5")
3178        );
3179        assert_eq!(
3180            rec.context.get("auth_policy").map(String::as_str),
3181            Some("device-owner")
3182        );
3183    }
3184
3185    #[test]
3186    fn build_audit_record_omits_absent_optional_context_keys() {
3187        let rec = build_audit_record(
3188            AuditOutcome {
3189                command: vec!["drive".to_string(), "lease-acquire".to_string()],
3190                integration: "drive",
3191                file_id: "f1".to_string(),
3192                verdict: "denied".to_string(),
3193                error: Some("no".to_string()),
3194                ..Default::default()
3195            },
3196            RequestLogContext::default(),
3197        );
3198        assert_eq!(rec.error.as_deref(), Some("no"));
3199        assert_eq!(rec.context.get("lease_id"), None);
3200        assert_eq!(rec.context.get("version_before"), None);
3201        assert_eq!(rec.context.get("version_after"), None);
3202        assert_eq!(rec.context.get("backup_location"), None);
3203        assert_eq!(rec.context.get("backup_sha256"), None);
3204        assert_eq!(rec.context.get("backup_size"), None);
3205        assert_eq!(rec.context.get("auth_policy"), None);
3206    }
3207
3208    #[test]
3209    fn record_audit_event_writes_to_the_audit_sink() {
3210        let dir = tempfile::tempdir().unwrap();
3211        let path = dir.path().join("audit.jsonl");
3212        let env = MapEnv::new().with("OMNI_DEV_AUDIT_LOG_FILE", path.to_str().unwrap());
3213
3214        record_audit_event_with(
3215            &env,
3216            AuditOutcome {
3217                command: vec!["drive".to_string(), "lease-acquire".to_string()],
3218                integration: "drive",
3219                file_id: "f1".to_string(),
3220                verdict: "acquired".to_string(),
3221                ..Default::default()
3222            },
3223        )
3224        .unwrap();
3225
3226        let contents = std::fs::read_to_string(&path).unwrap();
3227        let back: LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
3228        assert_eq!(back.kind, RecordKind::Audit);
3229        assert_eq!(
3230            back.context.get("verdict").map(String::as_str),
3231            Some("acquired")
3232        );
3233    }
3234
3235    #[test]
3236    fn record_kind_audit_serializes_as_audit_and_round_trips() {
3237        let rec = LogRecord {
3238            kind: RecordKind::Audit,
3239            id: new_id(),
3240            invocation_id: new_id(),
3241            ..LogRecord::default()
3242        };
3243        let line = serde_json::to_string(&rec).unwrap();
3244        assert!(line.contains("\"kind\":\"audit\""), "line was: {line}");
3245        assert_eq!(RecordKind::Audit.as_str(), "audit");
3246        let back: LogRecord = serde_json::from_str(&line).unwrap();
3247        assert_eq!(back.kind, RecordKind::Audit);
3248    }
3249
3250    #[test]
3251    fn audit_file_path_honors_env_override() {
3252        let env = MapEnv::new().with("OMNI_DEV_AUDIT_LOG_FILE", "/tmp/omni-dev-test-audit.jsonl");
3253        assert_eq!(
3254            audit_file_path_with(&env),
3255            Some(PathBuf::from("/tmp/omni-dev-test-audit.jsonl"))
3256        );
3257    }
3258
3259    #[test]
3260    fn audit_file_path_never_resolves_to_the_real_machine_default_when_unset() {
3261        // Regression test for a real incident: before this fallback
3262        // existed, any test exercising a leased write or `drive lease
3263        // acquire` without itself redirecting the audit log silently
3264        // wrote into this machine's actual `audit.jsonl`. With the var
3265        // unset, every call in a test build must land on the same shared
3266        // scratch path — stable across calls (so it's memoized once, not
3267        // re-created per call) and never the real
3268        // `dirs::state_dir()`/`dirs::data_dir()`-based default the
3269        // non-test build resolves to, whether resolved through the pure
3270        // `audit_file_path_with` seam or the ambient `audit_file_path`.
3271        let real_default = dirs::state_dir()
3272            .or_else(dirs::data_dir)
3273            .unwrap()
3274            .join("omni-dev")
3275            .join(AUDIT_FILE_NAME);
3276
3277        let unset = MapEnv::new();
3278        let unrouted = audit_file_path_with(&unset).unwrap();
3279        assert_eq!(
3280            unrouted,
3281            audit_file_path_with(&unset).unwrap(),
3282            "the fallback path must be stable across calls"
3283        );
3284        assert_ne!(
3285            unrouted, real_default,
3286            "must never fall back to the real machine's default audit log path in a test build"
3287        );
3288
3289        assert_eq!(
3290            audit_file_path().unwrap(),
3291            unrouted,
3292            "the ambient entry point with no thread-local route lands on the same scratch file"
3293        );
3294    }
3295
3296    #[test]
3297    fn record_audit_writes_a_line_that_round_trips() {
3298        let dir = tempfile::tempdir().unwrap();
3299        let path = dir.path().join("audit.jsonl");
3300        let env = MapEnv::new().with("OMNI_DEV_AUDIT_LOG_FILE", path.to_str().unwrap());
3301
3302        let rec = LogRecord {
3303            kind: RecordKind::Audit,
3304            id: new_id(),
3305            invocation_id: new_id(),
3306            ..LogRecord::default()
3307        };
3308        record_audit_with(&env, &rec).unwrap();
3309
3310        let contents = std::fs::read_to_string(&path).unwrap();
3311        let back: LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
3312        assert_eq!(back.kind, RecordKind::Audit);
3313        assert_eq!(back.id, rec.id);
3314    }
3315
3316    #[test]
3317    fn record_audit_locks_and_syncs_the_handle_when_bodies_are_enabled() {
3318        // `bodies_enabled()` gates a separate, otherwise-untested append
3319        // path (an advisory `flock` around the write) inside
3320        // `append_line_unrotated`; `record_audit`'s `sync=true` additionally
3321        // exercises the `sync_data` call on the locked handle.
3322        let dir = tempfile::tempdir().unwrap();
3323        let path = dir.path().join("audit.jsonl");
3324        let env = MapEnv::new()
3325            .with("OMNI_DEV_LOG_BODIES", "1")
3326            .with("OMNI_DEV_AUDIT_LOG_FILE", path.to_str().unwrap());
3327        assert!(bodies_enabled_with(&env));
3328
3329        let rec = LogRecord {
3330            kind: RecordKind::Audit,
3331            id: new_id(),
3332            invocation_id: new_id(),
3333            ..LogRecord::default()
3334        };
3335        record_audit_with(&env, &rec).unwrap();
3336
3337        let contents = std::fs::read_to_string(&path).unwrap();
3338        let back: LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
3339        assert_eq!(back.id, rec.id);
3340    }
3341
3342    #[test]
3343    fn record_audit_fails_closed_when_the_write_errors() {
3344        // A directory is not a valid target for `OpenOptions::append`, so this
3345        // forces the same write failure a permission error or a missing parent
3346        // would — the point is that `record_audit`, unlike `record`, hands the
3347        // error back rather than swallowing it.
3348        let dir = tempfile::tempdir().unwrap();
3349        let rec = LogRecord {
3350            kind: RecordKind::Audit,
3351            id: new_id(),
3352            invocation_id: new_id(),
3353            ..LogRecord::default()
3354        };
3355        let err = append_record_to(dir.path(), &rec, append_line_no_rotation).unwrap_err();
3356        assert!(!err.to_string().is_empty());
3357    }
3358
3359    #[test]
3360    fn record_audit_is_exempt_from_omni_dev_log_disable() {
3361        let dir = tempfile::tempdir().unwrap();
3362        let path = dir.path().join("audit.jsonl");
3363        let env = MapEnv::new()
3364            .with("OMNI_DEV_LOG_DISABLE", "1")
3365            .with("OMNI_DEV_AUDIT_LOG_FILE", path.to_str().unwrap());
3366        assert!(disabled_with(&env));
3367
3368        let rec = LogRecord {
3369            kind: RecordKind::Audit,
3370            id: new_id(),
3371            invocation_id: new_id(),
3372            ..LogRecord::default()
3373        };
3374        record_audit_with(&env, &rec).unwrap();
3375
3376        assert!(path.exists());
3377    }
3378
3379    #[test]
3380    fn the_audit_write_path_never_gates_on_log_disable() {
3381        // The exemption test above pins `record_audit_with`, the injected-env
3382        // test seam — never the ambient `record_audit`/`record_audit_to` that
3383        // production actually calls. This closes that gap by grepping their
3384        // real source: a regression that added `if disabled() { return Ok(()) }`
3385        // to either, mirroring `record`'s own pattern, would fail here even
3386        // though it slips past the env-injected test above.
3387        let source = include_str!("request_log.rs");
3388        let start = source
3389            .find("pub fn record_audit(entry: &LogRecord) -> anyhow::Result<()> {")
3390            .expect("record_audit signature not found");
3391        let end = source[start..]
3392            .find("/// Serializes `entry` and appends it to `path` via `append`,")
3393            .expect("append_record_to doc comment not found")
3394            + start;
3395        let audit_write_path = &source[start..end];
3396        assert!(
3397            !audit_write_path.contains("disabled"),
3398            "the fail-closed audit sink must never gate on OMNI_DEV_LOG_DISABLE:\n{audit_write_path}"
3399        );
3400    }
3401
3402    #[test]
3403    fn try_record_refuses_an_audit_kind_entry() {
3404        let rec = LogRecord {
3405            kind: RecordKind::Audit,
3406            id: new_id(),
3407            invocation_id: new_id(),
3408            ..LogRecord::default()
3409        };
3410        let err = try_record(&rec).unwrap_err();
3411        assert!(err.to_string().contains("record_audit()"), "{err}");
3412    }
3413
3414    #[test]
3415    fn record_audit_refuses_a_non_audit_kind_entry() {
3416        let dir = tempfile::tempdir().unwrap();
3417        let env = MapEnv::new().with(
3418            "OMNI_DEV_AUDIT_LOG_FILE",
3419            dir.path().join("audit.jsonl").to_str().unwrap(),
3420        );
3421
3422        let rec = LogRecord {
3423            kind: RecordKind::Invocation,
3424            id: new_id(),
3425            invocation_id: new_id(),
3426            ..LogRecord::default()
3427        };
3428        let err = record_audit_with(&env, &rec).unwrap_err();
3429
3430        assert!(err.to_string().contains("RecordKind::Audit"), "{err}");
3431    }
3432
3433    #[test]
3434    fn log_file_path_with_refuses_when_it_resolves_to_the_audit_file() {
3435        let dir = tempfile::tempdir().unwrap();
3436        let shared = dir.path().join("shared.jsonl");
3437        let shared_str = shared.to_str().unwrap();
3438        let env = MapEnv::new()
3439            .with("OMNI_DEV_LOG_FILE", shared_str)
3440            .with("OMNI_DEV_AUDIT_LOG_FILE", shared_str);
3441
3442        assert!(log_file_path_with(&env).is_none());
3443    }
3444
3445    #[test]
3446    fn log_file_path_with_refuses_a_dot_dot_spelling_of_the_audit_file() {
3447        // Mirrors `same_file_matches_a_dot_dot_spelling_routed_through_a_nonexistent_directory`:
3448        // the guard must compare by file identity, not raw spelling.
3449        let dir = tempfile::tempdir().unwrap();
3450        let audit = dir.path().join("audit.jsonl");
3451        std::fs::write(&audit, "{}\n").unwrap();
3452        let dotted = dir.path().join("sub/../audit.jsonl");
3453        let env = MapEnv::new()
3454            .with("OMNI_DEV_LOG_FILE", dotted.to_str().unwrap())
3455            .with("OMNI_DEV_AUDIT_LOG_FILE", audit.to_str().unwrap());
3456
3457        assert!(log_file_path_with(&env).is_none());
3458    }
3459
3460    #[test]
3461    fn record_audit_with_succeeds_when_the_log_file_env_aliases_the_audit_path() {
3462        // Once `log_file_path_with` itself refuses to resolve an aliasing
3463        // `OMNI_DEV_LOG_FILE`, `record_audit_to` has no colliding request-log
3464        // path to compare against, so the write it exists to protect
3465        // proceeds normally — the refusal moved to path resolution
3466        // (#1747), it did not disappear.
3467        let dir = tempfile::tempdir().unwrap();
3468        let shared = dir.path().join("shared.jsonl");
3469        let shared_str = shared.to_str().unwrap();
3470        let env = MapEnv::new()
3471            .with("OMNI_DEV_LOG_FILE", shared_str)
3472            .with("OMNI_DEV_AUDIT_LOG_FILE", shared_str);
3473
3474        let rec = LogRecord {
3475            kind: RecordKind::Audit,
3476            id: new_id(),
3477            invocation_id: new_id(),
3478            ..LogRecord::default()
3479        };
3480        record_audit_with(&env, &rec).unwrap();
3481
3482        assert!(shared.exists());
3483        assert!(log_file_path_with(&env).is_none());
3484    }
3485
3486    #[test]
3487    fn scrub_argv_redacts_sensitive_header_in_both_forms() {
3488        let out = scrub_argv(&argv(&[
3489            "omni-dev",
3490            "--header",
3491            "Authorization: Bearer sekret",
3492            "--header=Cookie: session=abc",
3493        ]));
3494        assert_eq!(
3495            out,
3496            argv(&[
3497                "omni-dev",
3498                "--header",
3499                "Authorization: REDACTED",
3500                "--header=Cookie: REDACTED",
3501            ])
3502        );
3503    }
3504
3505    #[test]
3506    fn scrub_argv_keeps_non_sensitive_headers() {
3507        let input = argv(&["omni-dev", "--header", "Content-Type: application/json"]);
3508        assert_eq!(scrub_argv(&input), input);
3509    }
3510
3511    #[test]
3512    fn scrub_argv_redacts_colonless_header_wholesale() {
3513        let out = scrub_argv(&argv(&["omni-dev", "--header", "sekret"]));
3514        assert_eq!(out, argv(&["omni-dev", "--header", "REDACTED"]));
3515    }
3516
3517    #[test]
3518    fn scrub_argv_redacts_inline_body_but_keeps_at_file() {
3519        let out = scrub_argv(&argv(&["omni-dev", "--body", r#"{"secret":1}"#]));
3520        assert_eq!(out, argv(&["omni-dev", "--body", "REDACTED"]));
3521
3522        let file_form = argv(&["omni-dev", "--body", "@payload.json"]);
3523        assert_eq!(scrub_argv(&file_form), file_form);
3524
3525        let out = scrub_argv(&argv(&["omni-dev", "--body=sekret"]));
3526        assert_eq!(out, argv(&["omni-dev", "--body=REDACTED"]));
3527    }
3528
3529    #[test]
3530    fn scrub_argv_redacts_find_replace_content_in_both_flag_forms() {
3531        let out = scrub_argv(&argv(&[
3532            "omni-dev",
3533            "--find",
3534            "customer@example.com",
3535            "--replacement=redacted",
3536        ]));
3537        assert_eq!(
3538            out,
3539            argv(&["omni-dev", "--find", "REDACTED", "--replacement=REDACTED",])
3540        );
3541    }
3542
3543    #[test]
3544    fn scrub_argv_redacts_secretish_flag_values() {
3545        let out = scrub_argv(&argv(&["omni-dev", "--api-key", "abc", "--auth-token=xyz"]));
3546        assert_eq!(
3547            out,
3548            argv(&["omni-dev", "--api-key", "REDACTED", "--auth-token=REDACTED"])
3549        );
3550    }
3551
3552    #[test]
3553    fn scrub_argv_exempts_path_flags_and_positionals() {
3554        let input = argv(&["omni-dev", "--token-file", "/tmp/t", "PROJ-123"]);
3555        assert_eq!(scrub_argv(&input), input);
3556    }
3557
3558    #[test]
3559    fn scrub_argv_redacts_secret_bearing_url_query_in_both_forms() {
3560        // `--url` is not a secret-ish flag name, so its value is caught by the
3561        // redact_url layer, not the flag layer (#1162). Both argv shapes plus a
3562        // bare positional URL are covered; the benign `page` param survives.
3563        let space = scrub_argv(&argv(&[
3564            "omni-dev",
3565            "browser",
3566            "bridge",
3567            "request",
3568            "--url",
3569            "/api/export?access_token=hunter2&sig=deadbeef&page=3",
3570        ]));
3571        assert_eq!(
3572            *space.last().unwrap(),
3573            "/api/export?access_token=REDACTED&sig=REDACTED&page=3"
3574        );
3575
3576        let eq_form = scrub_argv(&argv(&[
3577            "omni-dev",
3578            "--url=/api/export?access_token=hunter2&page=3",
3579        ]));
3580        assert_eq!(
3581            *eq_form.last().unwrap(),
3582            "--url=/api/export?access_token=REDACTED&page=3"
3583        );
3584
3585        let positional = scrub_argv(&argv(&["omni-dev", "https://h/cb#id_token=xyz"]));
3586        assert_eq!(
3587            *positional.last().unwrap(),
3588            "https://h/cb#id_token=REDACTED"
3589        );
3590    }
3591
3592    #[test]
3593    fn scrub_argv_leaves_benign_argv_byte_identical() {
3594        let input = argv(&[
3595            "omni-dev",
3596            "browser",
3597            "bridge",
3598            "request",
3599            "--control-port",
3600            "19998",
3601            "--url",
3602            "/api/export?page=3&sort=asc",
3603        ]);
3604        assert_eq!(scrub_argv(&input), input);
3605    }
3606
3607    #[test]
3608    fn scrub_argv_handles_trailing_flag_without_value() {
3609        let input = argv(&["omni-dev", "--body"]);
3610        assert_eq!(scrub_argv(&input), input);
3611    }
3612
3613    #[cfg(unix)]
3614    #[test]
3615    fn append_line_creates_file_owner_only() {
3616        use std::os::unix::fs::PermissionsExt;
3617        let dir = tempfile::tempdir().unwrap();
3618        let path = dir.path().join("log.jsonl");
3619        append_line(&path, "{\"kind\":\"http\"}\n").unwrap();
3620        assert_eq!(
3621            std::fs::read_to_string(&path).unwrap(),
3622            "{\"kind\":\"http\"}\n"
3623        );
3624        assert_eq!(
3625            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
3626            0o600
3627        );
3628    }
3629
3630    #[cfg(unix)]
3631    #[test]
3632    fn append_line_retightens_preexisting_loose_file() {
3633        use std::os::unix::fs::PermissionsExt;
3634        let dir = tempfile::tempdir().unwrap();
3635        let path = dir.path().join("log.jsonl");
3636        std::fs::write(&path, "old\n").unwrap();
3637        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3638        append_line(&path, "new\n").unwrap();
3639        assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
3640        assert_eq!(
3641            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
3642            0o600
3643        );
3644    }
3645
3646    /// Issue #1697 made the parent-directory `fsync` conditional on the
3647    /// append having created the file. The condition is not observable from
3648    /// outside (an `fsync` leaves no trace), so what is pinned here is what
3649    /// a mistake in it would actually break: the creating append and every
3650    /// append after it must still land, in order, at `0600`.
3651    #[cfg(unix)]
3652    #[test]
3653    fn append_line_synced_appends_in_order_across_the_creating_and_later_writes() {
3654        use std::os::unix::fs::PermissionsExt;
3655        let dir = tempfile::tempdir().unwrap();
3656        let path = dir.path().join("audit.jsonl");
3657
3658        append_line_synced(&path, "first\n").unwrap();
3659        append_line_synced(&path, "second\n").unwrap();
3660        append_line_synced(&path, "third\n").unwrap();
3661
3662        assert_eq!(
3663            std::fs::read_to_string(&path).unwrap(),
3664            "first\nsecond\nthird\n"
3665        );
3666        assert_eq!(
3667            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
3668            0o600
3669        );
3670    }
3671
3672    /// The re-tightening in `ensure_handle_0600` runs before the new
3673    /// emptiness probe reads the same handle, so a pre-existing loose file
3674    /// is still clamped on the synced path, not only on `append_line`'s.
3675    #[cfg(unix)]
3676    #[test]
3677    fn append_line_synced_retightens_preexisting_loose_file() {
3678        use std::os::unix::fs::PermissionsExt;
3679        let dir = tempfile::tempdir().unwrap();
3680        let path = dir.path().join("audit.jsonl");
3681        std::fs::write(&path, "old\n").unwrap();
3682        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3683
3684        append_line_synced(&path, "new\n").unwrap();
3685
3686        assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
3687        assert_eq!(
3688            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
3689            0o600
3690        );
3691    }
3692
3693    /// The synced appender's error arm. `record_audit_fails_closed_when_the_
3694    /// write_errors` covers `append_line_no_rotation`'s, so without this the
3695    /// one appender whose failure *refuses a Drive write* had none of its
3696    /// own.
3697    #[test]
3698    fn append_line_synced_errors_when_the_target_is_a_directory() {
3699        let dir = tempfile::tempdir().unwrap();
3700        let path = dir.path().join("audit.jsonl");
3701        std::fs::create_dir(&path).unwrap();
3702
3703        assert!(append_line_synced(&path, "line\n").is_err());
3704    }
3705
3706    /// An empty-but-existing file takes the conservative branch (one spare
3707    /// directory `fsync`) rather than being mistaken for something that
3708    /// cannot be appended to.
3709    #[test]
3710    fn append_line_synced_appends_to_an_existing_empty_file() {
3711        let dir = tempfile::tempdir().unwrap();
3712        let path = dir.path().join("audit.jsonl");
3713        std::fs::write(&path, "").unwrap();
3714
3715        append_line_synced(&path, "line\n").unwrap();
3716
3717        assert_eq!(std::fs::read_to_string(&path).unwrap(), "line\n");
3718    }
3719
3720    #[test]
3721    fn off_list_secretish_headers_are_redacted() {
3722        let mut headers = BTreeMap::new();
3723        for name in [
3724            "X-Auth-Token",
3725            "x-amz-security-token",
3726            "X-Goog-Api-Key",
3727            "x-csrf-token",
3728            "X-Vendor-Token",
3729            "X-Omni-Bridge",
3730        ] {
3731            headers.insert(name.to_string(), "secret-value".to_string());
3732        }
3733        for name in [
3734            "Content-Type",
3735            "Accept",
3736            "User-Agent",
3737            "x-request-id",
3738            "traceparent",
3739        ] {
3740            headers.insert(name.to_string(), "plain-value".to_string());
3741        }
3742        let out = redact_headers(&headers);
3743        assert_eq!(out["X-Auth-Token"], "REDACTED");
3744        assert_eq!(out["x-amz-security-token"], "REDACTED");
3745        assert_eq!(out["X-Goog-Api-Key"], "REDACTED");
3746        assert_eq!(out["x-csrf-token"], "REDACTED");
3747        assert_eq!(out["X-Vendor-Token"], "REDACTED");
3748        assert_eq!(out["X-Omni-Bridge"], "REDACTED");
3749        assert_eq!(out["Content-Type"], "plain-value");
3750        assert_eq!(out["Accept"], "plain-value");
3751        assert_eq!(out["User-Agent"], "plain-value");
3752        assert_eq!(out["x-request-id"], "plain-value");
3753        assert_eq!(out["traceparent"], "plain-value");
3754    }
3755
3756    #[test]
3757    fn url_without_query_is_unchanged() {
3758        assert_eq!(redact_url("https://h/p"), "https://h/p");
3759        assert_eq!(redact_url("/relative/p"), "/relative/p");
3760    }
3761
3762    #[test]
3763    fn benign_query_is_byte_identical() {
3764        let url = "https://h/p?q=a%20b&page=2&&x=y+z&keyword=k&sort_key=s&token_type=bearer";
3765        assert_eq!(redact_url(url), url);
3766    }
3767
3768    #[test]
3769    fn sensitive_query_values_are_redacted() {
3770        let url = "https://h/p?token=a&access_token=b&client_secret=c&api_key=d&x=1";
3771        assert_eq!(
3772            redact_url(url),
3773            "https://h/p?token=REDACTED&access_token=REDACTED&client_secret=REDACTED\
3774             &api_key=REDACTED&x=1"
3775        );
3776    }
3777
3778    #[test]
3779    fn presigned_s3_query_is_redacted() {
3780        let url = "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=AWS4-HMAC-SHA256\
3781                   &X-Amz-Credential=AKIA%2F20260703%2Fus-east-1%2Fs3%2Faws4_request\
3782                   &X-Amz-Date=20260703T000000Z&X-Amz-Expires=3600\
3783                   &X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef";
3784        assert_eq!(
3785            redact_url(url),
3786            "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=REDACTED\
3787             &X-Amz-Credential=REDACTED&X-Amz-Date=REDACTED&X-Amz-Expires=REDACTED\
3788             &X-Amz-SignedHeaders=REDACTED&X-Amz-Signature=REDACTED"
3789        );
3790    }
3791
3792    #[test]
3793    fn key_matching_is_case_insensitive() {
3794        assert_eq!(
3795            redact_url("/p?TOKEN=x&Api_Key=y&X-Amz-Signature=z"),
3796            "/p?TOKEN=REDACTED&Api_Key=REDACTED&X-Amz-Signature=REDACTED"
3797        );
3798    }
3799
3800    #[test]
3801    fn repeated_sensitive_keys_are_each_redacted() {
3802        assert_eq!(redact_url("/p?sig=a&sig=b"), "/p?sig=REDACTED&sig=REDACTED");
3803    }
3804
3805    #[test]
3806    fn valueless_key_is_left_alone() {
3807        assert_eq!(redact_url("/p?token"), "/p?token");
3808        assert_eq!(redact_url("/p?token="), "/p?token=REDACTED");
3809    }
3810
3811    #[test]
3812    fn relative_url_query_is_redacted() {
3813        assert_eq!(
3814            redact_url("/api/foo?sig=abc&x=y"),
3815            "/api/foo?sig=REDACTED&x=y"
3816        );
3817    }
3818
3819    #[test]
3820    fn fragment_credentials_are_redacted() {
3821        assert_eq!(
3822            redact_url("https://h/cb#access_token=xyz&token_type=bearer"),
3823            "https://h/cb#access_token=REDACTED&token_type=bearer"
3824        );
3825    }
3826
3827    #[test]
3828    fn query_and_fragment_are_scrubbed_independently() {
3829        assert_eq!(
3830            redact_url("/p?sig=a#id_token=b"),
3831            "/p?sig=REDACTED#id_token=REDACTED"
3832        );
3833    }
3834
3835    #[test]
3836    fn question_mark_in_fragment_is_not_parsed_as_query() {
3837        // The fragment is split off before the query, so `?` inside it never
3838        // starts a query; the pseudo-key `frag?token` still redacts via the
3839        // suffix rule (over-redaction in the safe direction).
3840        assert_eq!(
3841            redact_url("https://h/p#frag?token=x"),
3842            "https://h/p#frag?token=REDACTED"
3843        );
3844    }
3845
3846    #[test]
3847    fn encoded_sensitive_key_is_decoded_before_matching() {
3848        assert_eq!(
3849            redact_url("/p?access%5Ftoken=v"),
3850            "/p?access%5Ftoken=REDACTED"
3851        );
3852    }
3853
3854    #[test]
3855    fn empty_query_is_unchanged() {
3856        assert_eq!(redact_url("https://h/p?"), "https://h/p?");
3857        assert_eq!(redact_url("https://h/p?#f"), "https://h/p?#f");
3858    }
3859
3860    #[test]
3861    fn parse_size_handles_units_and_bare_bytes() {
3862        assert_eq!(parse_size("1048576").unwrap(), 1024 * 1024);
3863        assert_eq!(parse_size("512b").unwrap(), 512);
3864        assert_eq!(parse_size("10kb").unwrap(), 10 * 1024);
3865        assert_eq!(parse_size("2K").unwrap(), 2 * 1024);
3866        assert_eq!(parse_size("3mb").unwrap(), 3 * 1024 * 1024);
3867        assert_eq!(parse_size("1gb").unwrap(), 1024 * 1024 * 1024);
3868        assert_eq!(parse_size("1.5mb").unwrap(), (1.5 * 1024.0 * 1024.0) as u64);
3869        assert_eq!(parse_size(" 4mib ").unwrap(), 4 * 1024 * 1024);
3870    }
3871
3872    #[test]
3873    fn parse_size_rejects_garbage() {
3874        assert!(parse_size("").is_err());
3875        assert!(parse_size("mb").is_err());
3876        assert!(parse_size("10tb").is_err());
3877        assert!(parse_size("-5mb").is_err());
3878    }
3879
3880    #[test]
3881    fn sibling_appends_to_final_component() {
3882        let base = Path::new("/tmp/omni/log.jsonl");
3883        assert_eq!(sibling(base, ".1"), Path::new("/tmp/omni/log.jsonl.1"));
3884        assert_eq!(
3885            sibling(base, ".lock"),
3886            Path::new("/tmp/omni/log.jsonl.lock")
3887        );
3888    }
3889
3890    #[test]
3891    fn keep_by_size_keeps_most_recent_that_fit() {
3892        // Four 10-byte lines (11 bytes on disk each with the newline).
3893        let lines = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"];
3894        let refs: Vec<&str> = lines.to_vec();
3895
3896        // Budget for exactly two lines (22 bytes) keeps the last two.
3897        assert_eq!(keep_by_size(&refs, 22), &["cccccccccc", "dddddddddd"]);
3898        // A budget smaller than one line still keeps the single most recent.
3899        assert_eq!(keep_by_size(&refs, 1), &["dddddddddd"]);
3900        // A generous budget keeps everything.
3901        assert_eq!(keep_by_size(&refs, 10_000), &refs[..]);
3902        // Empty input yields empty output (no panic).
3903        assert!(keep_by_size(&[], 100).is_empty());
3904    }
3905
3906    #[test]
3907    fn keep_by_age_is_conservative_on_undateable_lines() {
3908        let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
3909            .unwrap()
3910            .with_timezone(&Utc);
3911        let old = r#"{"kind":"http","timestamp":"2026-01-01T00:00:00.000Z"}"#;
3912        let new = r#"{"kind":"http","timestamp":"2026-12-01T00:00:00.000Z"}"#;
3913        let undated = r#"{"kind":"http"}"#;
3914        let malformed = "not json at all";
3915
3916        assert!(!keep_by_age(old, Some(cutoff)));
3917        assert!(keep_by_age(new, Some(cutoff)));
3918        assert!(keep_by_age(undated, Some(cutoff)), "undated is kept");
3919        assert!(keep_by_age(malformed, Some(cutoff)), "malformed is kept");
3920        assert!(keep_by_age(old, None), "no filter keeps everything");
3921    }
3922
3923    fn http_line(id: &str, ts: &str) -> String {
3924        format!(r#"{{"id":"{id}","kind":"http","timestamp":"{ts}"}}"#)
3925    }
3926
3927    #[test]
3928    fn prune_by_age_drops_old_records_and_rewrites_atomically() {
3929        use std::os::unix::fs::PermissionsExt;
3930
3931        let dir = tempfile::tempdir().unwrap();
3932        let path = dir.path().join("log.jsonl");
3933        let body = format!(
3934            "{}\n{}\n{}\n",
3935            http_line("1", "2026-01-01T00:00:00.000Z"),
3936            http_line("2", "2026-06-15T00:00:00.000Z"),
3937            http_line("3", "2026-12-31T00:00:00.000Z"),
3938        );
3939        std::fs::write(&path, &body).unwrap();
3940        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3941
3942        let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
3943            .unwrap()
3944            .with_timezone(&Utc);
3945        let outcome = prune(
3946            &path,
3947            &PruneOptions {
3948                older_than: Some(cutoff),
3949                max_size: None,
3950                dry_run: false,
3951            },
3952        )
3953        .unwrap();
3954
3955        assert_eq!(outcome.removed, 1);
3956        assert_eq!(outcome.kept, 2);
3957        let contents = std::fs::read_to_string(&path).unwrap();
3958        assert!(!contents.contains(r#""id":"1""#));
3959        assert!(contents.contains(r#""id":"2""#));
3960        assert!(contents.contains(r#""id":"3""#));
3961        // The atomic rewrite lands a fresh 0600 file regardless of the old mode.
3962        assert_eq!(
3963            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
3964            0o600
3965        );
3966    }
3967
3968    #[test]
3969    fn prune_dry_run_reports_without_modifying() {
3970        let dir = tempfile::tempdir().unwrap();
3971        let path = dir.path().join("log.jsonl");
3972        let body = format!(
3973            "{}\n{}\n",
3974            http_line("1", "2026-01-01T00:00:00.000Z"),
3975            http_line("2", "2026-12-31T00:00:00.000Z"),
3976        );
3977        std::fs::write(&path, &body).unwrap();
3978
3979        let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
3980            .unwrap()
3981            .with_timezone(&Utc);
3982        let outcome = prune(
3983            &path,
3984            &PruneOptions {
3985                older_than: Some(cutoff),
3986                max_size: None,
3987                dry_run: true,
3988            },
3989        )
3990        .unwrap();
3991
3992        assert_eq!(outcome.removed, 1);
3993        // File is untouched by a dry run.
3994        assert_eq!(std::fs::read_to_string(&path).unwrap(), body);
3995    }
3996
3997    #[test]
3998    fn prune_by_size_keeps_the_newest_that_fit() {
3999        let dir = tempfile::tempdir().unwrap();
4000        let path = dir.path().join("log.jsonl");
4001        let l1 = http_line("1", "2026-01-01T00:00:00.000Z");
4002        let l2 = http_line("2", "2026-06-15T00:00:00.000Z");
4003        let l3 = http_line("3", "2026-12-31T00:00:00.000Z");
4004        std::fs::write(&path, format!("{l1}\n{l2}\n{l3}\n")).unwrap();
4005
4006        // Budget that fits only the last two lines.
4007        let budget = (l2.len() + 1 + l3.len() + 1) as u64;
4008        let outcome = prune(
4009            &path,
4010            &PruneOptions {
4011                older_than: None,
4012                max_size: Some(budget),
4013                dry_run: false,
4014            },
4015        )
4016        .unwrap();
4017
4018        assert_eq!(outcome.removed, 1);
4019        assert_eq!(outcome.kept, 2);
4020        let contents = std::fs::read_to_string(&path).unwrap();
4021        assert!(!contents.contains(r#""id":"1""#));
4022        assert!(contents.contains(r#""id":"3""#));
4023    }
4024
4025    #[test]
4026    fn prune_missing_file_is_a_noop() {
4027        let dir = tempfile::tempdir().unwrap();
4028        let path = dir.path().join("absent.jsonl");
4029        let outcome = prune(
4030            &path,
4031            &PruneOptions {
4032                older_than: None,
4033                max_size: Some(1),
4034                dry_run: false,
4035            },
4036        )
4037        .unwrap();
4038        assert_eq!(outcome.removed, 0);
4039        assert_eq!(outcome.kept, 0);
4040        assert!(!path.exists());
4041    }
4042
4043    #[cfg(unix)]
4044    #[test]
4045    fn rotation_shifts_numbered_files_and_drops_the_oldest() {
4046        use std::os::unix::fs::PermissionsExt;
4047
4048        let dir = tempfile::tempdir().unwrap();
4049        let path = dir.path().join("log.jsonl");
4050        // A tiny cap so every second short line rotates.
4051        let cfg = RotationConfig {
4052            max_size: 20,
4053            keep_files: 2,
4054        };
4055
4056        let line = "0123456789012345\n"; // 17 bytes
4057        for _ in 0..4 {
4058            append_with_rotation(&path, line, &cfg).unwrap();
4059        }
4060
4061        // The live file plus at most keep_files (2) rotated files exist; a .3
4062        // must never appear.
4063        assert!(path.exists());
4064        assert!(sibling(&path, ".1").exists());
4065        assert!(sibling(&path, ".2").exists());
4066        assert!(!sibling(&path, ".3").exists());
4067        // Rotated files keep the 0600 posture.
4068        assert_eq!(
4069            std::fs::metadata(sibling(&path, ".1"))
4070                .unwrap()
4071                .permissions()
4072                .mode()
4073                & 0o777,
4074            0o600
4075        );
4076    }
4077
4078    #[cfg(unix)]
4079    #[test]
4080    fn rotation_keep_zero_discards_on_overflow() {
4081        let dir = tempfile::tempdir().unwrap();
4082        let path = dir.path().join("log.jsonl");
4083        let cfg = RotationConfig {
4084            max_size: 20,
4085            keep_files: 0,
4086        };
4087        let line = "0123456789012345\n"; // 17 bytes
4088        append_with_rotation(&path, line, &cfg).unwrap();
4089        append_with_rotation(&path, line, &cfg).unwrap();
4090        // No .1 is retained; only the current (single-line) file survives.
4091        assert!(!sibling(&path, ".1").exists());
4092        assert_eq!(std::fs::read_to_string(&path).unwrap(), line);
4093    }
4094
4095    #[test]
4096    fn parse_size_rejects_overflow_to_infinity() {
4097        // A number too large for f64 parses to a non-finite value, not a size.
4098        assert!(parse_size(&"9".repeat(400)).is_err());
4099    }
4100
4101    // --- same_file / normalize / lexically_normalize (issue #1694) ---
4102
4103    #[test]
4104    fn same_file_matches_identical_paths() {
4105        let dir = tempfile::tempdir().unwrap();
4106        let path = dir.path().join("audit.jsonl");
4107        assert!(same_file(&path, &path));
4108    }
4109
4110    #[test]
4111    fn same_file_matches_a_dot_dot_spelling_routed_through_a_nonexistent_directory() {
4112        // `metadata` fails for the dotted spelling (it must traverse the
4113        // nonexistent `sub` to apply `..`), forcing the fallback through
4114        // `lexically_normalize`, which collapses the `..` with no filesystem
4115        // access before canonicalizing what remains.
4116        let dir = tempfile::tempdir().unwrap();
4117        let audit = dir.path().join("audit.jsonl");
4118        std::fs::write(&audit, "{}\n").unwrap();
4119        let dotted = dir.path().join("sub/../audit.jsonl");
4120        assert!(same_file(&audit, &dotted));
4121    }
4122
4123    #[test]
4124    fn same_file_matches_a_hard_link() {
4125        let dir = tempfile::tempdir().unwrap();
4126        let original = dir.path().join("audit.jsonl");
4127        std::fs::write(&original, "{}\n").unwrap();
4128        let linked = dir.path().join("linked.jsonl");
4129        std::fs::hard_link(&original, &linked).unwrap();
4130        assert!(same_file(&original, &linked));
4131    }
4132
4133    #[cfg(unix)]
4134    #[test]
4135    fn same_file_matches_a_symlink_to_an_existing_file() {
4136        let dir = tempfile::tempdir().unwrap();
4137        let original = dir.path().join("audit.jsonl");
4138        std::fs::write(&original, "{}\n").unwrap();
4139        let link = dir.path().join("link.jsonl");
4140        std::os::unix::fs::symlink(&original, &link).unwrap();
4141        assert!(same_file(&original, &link));
4142    }
4143
4144    #[cfg(unix)]
4145    #[test]
4146    fn same_file_matches_a_symlink_to_a_not_yet_existing_target() {
4147        // `fs::metadata` follows symlinks, so a link whose target does not
4148        // exist yet fails both `metadata` calls; the fallback must resolve
4149        // the link by hand rather than comparing the link's own name against
4150        // the target's, which would answer "different" right up until the
4151        // first write creates the target through the link.
4152        let dir = tempfile::tempdir().unwrap();
4153        let target = dir.path().join("audit.jsonl"); // never created
4154        let link = dir.path().join("link.jsonl");
4155        std::os::unix::fs::symlink(&target, &link).unwrap();
4156        assert!(same_file(&target, &link));
4157    }
4158
4159    #[test]
4160    fn same_file_rejects_two_distinct_existing_files() {
4161        let dir = tempfile::tempdir().unwrap();
4162        let a = dir.path().join("a.jsonl");
4163        let b = dir.path().join("b.jsonl");
4164        std::fs::write(&a, "a").unwrap();
4165        std::fs::write(&b, "b").unwrap();
4166        assert!(!same_file(&a, &b));
4167    }
4168
4169    #[test]
4170    fn same_file_rejects_two_distinct_paths_that_both_do_not_exist() {
4171        let dir = tempfile::tempdir().unwrap();
4172        let a = dir.path().join("a.jsonl");
4173        let b = dir.path().join("b.jsonl");
4174        assert!(!same_file(&a, &b));
4175    }
4176
4177    #[test]
4178    fn lexically_normalize_collapses_dot_dot_without_touching_disk() {
4179        assert_eq!(
4180            lexically_normalize(Path::new("a/sub/../b")),
4181            PathBuf::from("a/b")
4182        );
4183        assert_eq!(
4184            lexically_normalize(Path::new("./a/./b")),
4185            PathBuf::from("a/b")
4186        );
4187        // A leading `..` has nothing to pop, and must be kept rather than
4188        // dropped or turned into an error.
4189        assert_eq!(
4190            lexically_normalize(Path::new("../a")),
4191            PathBuf::from("../a")
4192        );
4193    }
4194
4195    #[test]
4196    fn normalize_returns_the_lexical_form_unchanged_when_there_is_nothing_left_to_canonicalize() {
4197        // An empty path has no file name and does not canonicalize; the
4198        // defensive fallback must return it unchanged rather than panicking
4199        // on a `None` `file_name()`.
4200        assert_eq!(normalize(Path::new("")), PathBuf::new());
4201    }
4202
4203    #[test]
4204    fn prune_surfaces_a_read_error() {
4205        // Reading a directory as the log yields an error other than NotFound,
4206        // which prune propagates rather than treating as an empty log.
4207        let dir = tempfile::tempdir().unwrap();
4208        let result = prune(
4209            dir.path(),
4210            &PruneOptions {
4211                older_than: None,
4212                max_size: Some(1),
4213                dry_run: false,
4214            },
4215        );
4216        assert!(result.is_err());
4217    }
4218
4219    #[test]
4220    fn prune_refuses_the_audit_file_by_direct_path() {
4221        let dir = tempfile::tempdir().unwrap();
4222        let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
4223        let audit_path = dir.path().join("audit.jsonl");
4224        std::fs::write(&audit_path, "{}\n").unwrap();
4225
4226        let err = prune(
4227            &audit_path,
4228            &PruneOptions {
4229                older_than: None,
4230                max_size: Some(1),
4231                dry_run: false,
4232            },
4233        )
4234        .err()
4235        .unwrap();
4236
4237        assert!(err.to_string().contains("audit log"), "{err}");
4238        assert_eq!(std::fs::read_to_string(&audit_path).unwrap(), "{}\n");
4239    }
4240
4241    #[test]
4242    fn prune_refuses_a_dot_dot_spelling_of_the_audit_file() {
4243        let dir = tempfile::tempdir().unwrap();
4244        let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
4245        let audit_path = dir.path().join("audit.jsonl");
4246        std::fs::write(&audit_path, "{}\n").unwrap();
4247        let dotted = dir.path().join("sub/../audit.jsonl");
4248
4249        let err = prune(
4250            &dotted,
4251            &PruneOptions {
4252                older_than: None,
4253                max_size: Some(1),
4254                dry_run: false,
4255            },
4256        )
4257        .err()
4258        .unwrap();
4259
4260        assert!(err.to_string().contains("audit log"), "{err}");
4261        assert_eq!(std::fs::read_to_string(&audit_path).unwrap(), "{}\n");
4262    }
4263
4264    #[test]
4265    fn prune_refuses_the_audit_file_even_under_dry_run() {
4266        // Exemption from pruning is the point, and `omni-dev log --audit` is
4267        // the supported way to read the file — a read-only dry run is not a
4268        // carve-out.
4269        let dir = tempfile::tempdir().unwrap();
4270        let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
4271        let audit_path = dir.path().join("audit.jsonl");
4272        std::fs::write(&audit_path, "{}\n").unwrap();
4273
4274        let err = prune(
4275            &audit_path,
4276            &PruneOptions {
4277                older_than: None,
4278                max_size: Some(1),
4279                dry_run: true,
4280            },
4281        )
4282        .err()
4283        .unwrap();
4284
4285        assert!(err.to_string().contains("audit log"), "{err}");
4286    }
4287
4288    #[cfg(unix)]
4289    #[test]
4290    fn append_with_rotation_refuses_to_rotate_the_audit_file() {
4291        let dir = tempfile::tempdir().unwrap();
4292        let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
4293        let audit_path = dir.path().join("audit.jsonl");
4294        // Seed a file already over the cap so a request-log path would rotate.
4295        std::fs::write(&audit_path, "0123456789012345\n").unwrap();
4296        let cfg = RotationConfig {
4297            max_size: 5,
4298            keep_files: 1,
4299        };
4300
4301        append_with_rotation(&audit_path, "new-line\n", &cfg).unwrap();
4302
4303        assert!(
4304            !sibling(&audit_path, ".1").exists(),
4305            "the audit file must never be rotated"
4306        );
4307        assert!(
4308            !sibling(&audit_path, ".lock").exists(),
4309            "a refused rotation must not create a lock file next to the audit file"
4310        );
4311        assert!(
4312            std::fs::read_to_string(&audit_path)
4313                .unwrap()
4314                .contains("new-line"),
4315            "the line is still appended (best effort)"
4316        );
4317    }
4318
4319    #[cfg(unix)]
4320    #[test]
4321    fn append_with_rotation_appends_even_when_rotate_fails() {
4322        let dir = tempfile::tempdir().unwrap();
4323        let path = dir.path().join("log.jsonl");
4324        // Seed a file already over the cap so the next write attempts to rotate.
4325        std::fs::write(&path, "0123456789012345\n").unwrap();
4326        // Make the rotation target a directory so `rename(log, log.1)` fails.
4327        std::fs::create_dir(sibling(&path, ".1")).unwrap();
4328        let cfg = RotationConfig {
4329            max_size: 5,
4330            keep_files: 1,
4331        };
4332        // Rotation fails, but the line is still appended (best effort).
4333        append_with_rotation(&path, "new-line\n", &cfg).unwrap();
4334        assert!(
4335            std::fs::read_to_string(&path).unwrap().contains("new-line"),
4336            "the record is appended despite the rotation failure"
4337        );
4338    }
4339
4340    #[test]
4341    fn prune_cleans_up_temp_on_rewrite_failure() {
4342        let dir = tempfile::tempdir().unwrap();
4343        let path = dir.path().join("log.jsonl");
4344        std::fs::write(
4345            &path,
4346            format!(
4347                "{}\n{}\n",
4348                http_line("a", "2999-01-01T00:00:00.000Z"),
4349                http_line("b", "2999-01-01T00:00:00.000Z"),
4350            ),
4351        )
4352        .unwrap();
4353        // Pre-create the exact temp path (same-process pid) as a directory so
4354        // the atomic rewrite's open fails, exercising the cleanup path.
4355        let tmp = sibling(&path, &format!(".prune.{}.tmp", std::process::id()));
4356        std::fs::create_dir(&tmp).unwrap();
4357
4358        let result = prune(
4359            &path,
4360            &PruneOptions {
4361                older_than: None,
4362                max_size: Some(1),
4363                dry_run: false,
4364            },
4365        );
4366        assert!(result.is_err(), "a failing rewrite surfaces as an error");
4367        let _ = std::fs::remove_dir(&tmp);
4368    }
4369
4370    #[tokio::test]
4371    async fn scope_origin_id_overwrites_id_but_preserves_source() {
4372        // A daemon-side base context: source = Daemon (so `via_daemon` detection
4373        // keeps working) with the daemon's own invocation id.
4374        let base = RequestLogContext {
4375            invocation_id: "daemon-1".to_string(),
4376            source: Source::Daemon,
4377            mcp_tool: None,
4378        };
4379        CTX.scope(base, async {
4380            scope_origin_id("cli-42".to_string(), async {
4381                let ctx = current_context();
4382                // Correlation id now points at the originating CLI invocation…
4383                assert_eq!(ctx.invocation_id, "cli-42");
4384                // …while the source stays Daemon, so `via_daemon` is unaffected.
4385                assert_eq!(ctx.source, Source::Daemon);
4386            })
4387            .await;
4388            // The override is scoped: outside it, the base id is restored.
4389            assert_eq!(current_context().invocation_id, "daemon-1");
4390        })
4391        .await;
4392    }
4393}