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.
14//! - **No secrets.** Auth headers/tokens are never written; only a non-secret
15//!   `auth_principal` identity is kept. Headers are redacted centrally
16//!   ([`redact_headers`]), secret-bearing URL query/fragment parameter values
17//!   are redacted (`redact_url`) before writing, and request/response bodies
18//!   are opt-in via `OMNI_DEV_LOG_BODIES=1`.
19//! - **Forward compatible.** A single [`LogRecord`] is used for both writing
20//!   and reading: every field is `#[serde(default)]`, and every optional field
21//!   is `skip_serializing_if`, so a newer reader never chokes on an older line
22//!   and an older reader never chokes on a newer one — the same forward-rolling
23//!   contract the daemon wire types use.
24
25use std::collections::BTreeMap;
26use std::io::Write;
27use std::path::{Path, PathBuf};
28use std::sync::OnceLock;
29use std::time::{Duration, Instant};
30
31use chrono::{DateTime, SecondsFormat, Utc};
32use serde::{Deserialize, Serialize};
33
34/// Default log file name under the runtime directory.
35const LOG_FILE_NAME: &str = "log.jsonl";
36
37/// Number of rotated log files kept by default when
38/// [`OMNI_DEV_LOG_MAX_SIZE`](rotation_config) enables rotation but
39/// `OMNI_DEV_LOG_KEEP_FILES` is unset. (Rotation on write is unix-only.)
40#[cfg(unix)]
41const DEFAULT_KEEP_FILES: u32 = 3;
42
43/// Which kind of record a line holds. Unknown future kinds deserialize to
44/// [`RecordKind::Unknown`] rather than failing the read.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum RecordKind {
48    /// One per process invocation (or per MCP tool call).
49    #[default]
50    Invocation,
51    /// One per outbound HTTP request.
52    Http,
53    /// A kind written by a newer version that this reader does not know.
54    #[serde(other)]
55    Unknown,
56}
57
58/// What drove an invocation. Unknown future sources deserialize to
59/// [`Source::Unknown`].
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum Source {
63    /// A direct `omni-dev` CLI invocation.
64    #[default]
65    Cli,
66    /// An `omni-dev-mcp` tool call.
67    Mcp,
68    /// Work performed inside the long-lived daemon process.
69    Daemon,
70    /// A source written by a newer version that this reader does not know.
71    #[serde(other)]
72    Unknown,
73}
74
75/// One line of the log. Used for both writing and reading; every field is
76/// `#[serde(default)]` (tolerant reads) and every optional field is
77/// `skip_serializing_if` (compact, forward-compatible writes).
78#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79pub struct LogRecord {
80    // --- Core fields (present on every record) ---
81    /// Per-record, time-sortable id (see [`new_id`]).
82    #[serde(default)]
83    pub id: String,
84    /// Shared by an invocation record and every HTTP record it spawned.
85    #[serde(default)]
86    pub invocation_id: String,
87    /// Discriminates the record type.
88    #[serde(default)]
89    pub kind: RecordKind,
90    /// RFC3339 timestamp with milliseconds.
91    #[serde(default)]
92    pub timestamp: String,
93    /// Host the record was written on.
94    #[serde(default)]
95    pub hostname: String,
96    /// Writing process id.
97    #[serde(default)]
98    pub pid: u32,
99    /// `omni-dev` version that wrote the record.
100    #[serde(default)]
101    pub omni_dev_version: String,
102    /// Working directory at write time.
103    #[serde(default)]
104    pub cwd: String,
105    /// OS user that owns the process.
106    #[serde(default)]
107    pub system_user: String,
108
109    // --- `kind: "invocation"` fields ---
110    /// Resolved clap subcommand path, e.g. `["jira","read"]`.
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub command: Vec<String>,
113    /// Full argv.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub command_line: Vec<String>,
116    /// Process exit code (0 success, 1 error — matches `die`).
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub exit_code: Option<i32>,
119    /// Wall time of the whole invocation.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub duration_ms: Option<u64>,
122    /// Whitelisted, non-secret `OMNI_DEV_*` env snapshot.
123    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
124    pub env: BTreeMap<String, String>,
125    /// What drove the run (`cli`/`mcp`/`daemon`).
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub source: Option<Source>,
128    /// When `source = mcp`, the tool name that drove the run.
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub mcp_tool: Option<String>,
131
132    // --- `kind: "http"` fields ---
133    /// Coarse service tag (`jira`/`confluence`/`datadog`/…) for fast filtering.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub service: Option<String>,
136    /// HTTP method.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub method: Option<String>,
139    /// Request URL.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub url: Option<String>,
142    /// Response status; absent on a network/transport error.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub status_code: Option<u16>,
145    /// Elapsed time of the request.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub elapsed_ms: Option<u64>,
148    /// True when the request ran inside the daemon (bridge/Snowflake pool).
149    #[serde(default, skip_serializing_if = "is_false")]
150    pub via_daemon: bool,
151    /// Which pooled daemon session served the request.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub daemon_session_id: Option<String>,
154    /// Non-secret identity actually used (token id / OAuth principal) — never
155    /// the secret itself.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub auth_principal: Option<String>,
158    /// Redacted request headers (only when `OMNI_DEV_LOG_HEADERS=1`).
159    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
160    pub request_headers: BTreeMap<String, String>,
161    /// Redacted response headers (only when `OMNI_DEV_LOG_HEADERS=1`).
162    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
163    pub response_headers: BTreeMap<String, String>,
164    /// Request body (only when `OMNI_DEV_LOG_BODIES=1`).
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub request_body: Option<String>,
167    /// Response body (only when `OMNI_DEV_LOG_BODIES=1`).
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub response_body: Option<String>,
170    /// Free-form correlation tags.
171    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
172    pub context: BTreeMap<String, String>,
173
174    // --- shared optional ---
175    /// Top-level error chain (invocation) or per-request error (http).
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub error: Option<String>,
178}
179
180/// `skip_serializing_if` predicate for `bool` fields that default to `false`.
181#[allow(clippy::trivially_copy_pass_by_ref)] // serde requires `fn(&T) -> bool`
182fn is_false(b: &bool) -> bool {
183    !*b
184}
185
186impl LogRecord {
187    /// Builds a record carrying only the always-present core fields.
188    fn new(kind: RecordKind, invocation_id: String) -> Self {
189        Self {
190            id: new_id(),
191            invocation_id,
192            kind,
193            timestamp: now_rfc3339_millis(),
194            hostname: hostname(),
195            pid: std::process::id(),
196            omni_dev_version: crate::VERSION.to_string(),
197            cwd: cwd(),
198            system_user: system_user(),
199            ..Self::default()
200        }
201    }
202}
203
204/// The per-invocation context every record is stamped with.
205///
206/// Held once per process in [`GLOBAL`] (CLI/daemon) and overridden per task in
207/// [`CTX`] (the multiplexed MCP server), so HTTP records can find their parent
208/// invocation without threading state through every call site.
209#[derive(Debug, Clone)]
210pub struct RequestLogContext {
211    /// Shared id linking an invocation to the HTTP it spawned.
212    pub invocation_id: String,
213    /// What drove the run.
214    pub source: Source,
215    /// MCP tool name when `source = mcp`.
216    pub mcp_tool: Option<String>,
217}
218
219impl Default for RequestLogContext {
220    fn default() -> Self {
221        Self {
222            invocation_id: new_id(),
223            source: Source::Cli,
224            mcp_tool: None,
225        }
226    }
227}
228
229impl RequestLogContext {
230    /// A CLI context with a freshly minted invocation id.
231    pub fn cli() -> Self {
232        Self {
233            invocation_id: new_id(),
234            source: Source::Cli,
235            mcp_tool: None,
236        }
237    }
238
239    /// An MCP context for a single tool call.
240    pub fn mcp(tool: impl Into<String>) -> Self {
241        Self {
242            invocation_id: new_id(),
243            source: Source::Mcp,
244            mcp_tool: Some(tool.into()),
245        }
246    }
247}
248
249static GLOBAL: OnceLock<RequestLogContext> = OnceLock::new();
250
251tokio::task_local! {
252    /// Per-task context override, set around each MCP tool dispatch.
253    pub static CTX: RequestLogContext;
254}
255
256/// Installs the process-global context. The first call wins (the CLI/daemon
257/// shell sets it once, very early); later calls are ignored.
258pub fn set_global(ctx: RequestLogContext) {
259    let _ = GLOBAL.set(ctx);
260}
261
262/// Resolves the active context: task-local override first, then the
263/// process-global default, then a synthesized fallback.
264pub fn current_context() -> RequestLogContext {
265    if let Ok(ctx) = CTX.try_with(RequestLogContext::clone) {
266        return ctx;
267    }
268    if let Some(ctx) = GLOBAL.get() {
269        return ctx.clone();
270    }
271    RequestLogContext::default()
272}
273
274/// Whether logging is disabled entirely (`OMNI_DEV_LOG_DISABLE=1`).
275pub fn disabled() -> bool {
276    env_flag("OMNI_DEV_LOG_DISABLE")
277}
278
279/// Whether request/response bodies may be recorded (`OMNI_DEV_LOG_BODIES=1`).
280pub fn bodies_enabled() -> bool {
281    env_flag("OMNI_DEV_LOG_BODIES")
282}
283
284/// Whether (redacted) headers may be recorded (`OMNI_DEV_LOG_HEADERS=1`).
285pub fn headers_enabled() -> bool {
286    env_flag("OMNI_DEV_LOG_HEADERS")
287}
288
289/// Reads a boolean-ish env var (`1`/`true`/`yes`, case-insensitive).
290fn env_flag(name: &str) -> bool {
291    std::env::var(name).is_ok_and(|v| {
292        let v = v.trim().to_ascii_lowercase();
293        v == "1" || v == "true" || v == "yes"
294    })
295}
296
297/// Resolves the log file path: `OMNI_DEV_LOG_FILE` override, else
298/// `state_dir` (falling back to `data_dir`) joined with `omni-dev/log.jsonl`.
299pub fn log_file_path() -> Option<PathBuf> {
300    if let Ok(path) = std::env::var("OMNI_DEV_LOG_FILE") {
301        if !path.is_empty() {
302            return Some(PathBuf::from(path));
303        }
304    }
305    let base = dirs::state_dir().or_else(dirs::data_dir)?;
306    Some(base.join("omni-dev").join(LOG_FILE_NAME))
307}
308
309/// Appends one record. Best effort: every error is swallowed (logged at
310/// `tracing::debug`) so logging can never affect the caller's exit code.
311pub fn record(entry: &LogRecord) {
312    if disabled() {
313        return;
314    }
315    if let Err(e) = try_record(entry) {
316        tracing::debug!("request_log: failed to append record: {e}");
317    }
318}
319
320/// The fallible append used by [`record`]; all errors flow back to be swallowed.
321fn try_record(entry: &LogRecord) -> anyhow::Result<()> {
322    use anyhow::Context;
323
324    let path = log_file_path().context("could not resolve the log file path")?;
325    // Only create and tighten the parent when it's missing — re-`chmod`ing an
326    // existing dir (e.g. a user-chosen OMNI_DEV_LOG_FILE location, or a shared
327    // temp dir) is both wrong and may fail; the file itself is always 0600.
328    if let Some(parent) = path.parent() {
329        if !parent.as_os_str().is_empty() && !parent.exists() {
330            crate::daemon::paths::ensure_dir_0700(parent)?;
331        }
332    }
333    let mut line = serde_json::to_string(entry).context("failed to serialize record")?;
334    line.push('\n');
335    append_line(&path, &line)?;
336    Ok(())
337}
338
339/// Appends a single line with `O_APPEND | O_CREATE`, creating the file `0600`.
340/// A pre-existing looser-perm file (an older version's, or a user-set
341/// `OMNI_DEV_LOG_FILE` target) is re-tightened to `0600` on every open, via
342/// the handle so there is no path race (#1139).
343/// When bodies are enabled (lines may exceed the atomic-write size) an advisory
344/// exclusive lock guards the write; the common no-body path relies on
345/// `O_APPEND` single-write atomicity and takes no lock.
346#[cfg(unix)]
347fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
348    use std::os::unix::fs::OpenOptionsExt;
349
350    // Opt-in size-capped rotation takes over the write: it must stat, maybe
351    // rotate, then open a fresh file, all under a stable-path lock (#1121).
352    if let Some(cfg) = rotation_config() {
353        return append_with_rotation(path, line, &cfg);
354    }
355
356    let file = std::fs::OpenOptions::new()
357        .append(true)
358        .create(true)
359        .mode(0o600)
360        .open(path)?;
361    crate::daemon::paths::ensure_handle_0600(&file)?;
362
363    if bodies_enabled() {
364        match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive) {
365            Ok(mut guard) => {
366                guard.write_all(line.as_bytes())?;
367            }
368            Err((mut file, _)) => {
369                file.write_all(line.as_bytes())?;
370            }
371        }
372    } else {
373        let mut file = file;
374        file.write_all(line.as_bytes())?;
375    }
376    Ok(())
377}
378
379/// Non-unix fallback: `O_APPEND | O_CREATE` single write, no advisory lock and
380/// no mode tightening (those are unix concepts). Size-capped rotation is a
381/// unix-only feature and is not applied here.
382#[cfg(not(unix))]
383fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
384    let mut file = std::fs::OpenOptions::new()
385        .append(true)
386        .create(true)
387        .open(path)?;
388    file.write_all(line.as_bytes())?;
389    Ok(())
390}
391
392// --- Size management: rotation on write + `omni-dev log prune` ---
393//
394// The log is default-on for every invocation and every outbound request, so on
395// an active machine it would otherwise grow without bound (#1121). Two bounds
396// are offered, both opt-in:
397//
398//   * Automatic size-capped rotation on write, gated on `OMNI_DEV_LOG_MAX_SIZE`
399//     (+ `OMNI_DEV_LOG_KEEP_FILES`) — numbered `log.jsonl.1`, `.2`, … files.
400//   * The explicit `omni-dev log prune` command (age- and/or size-based), which
401//     rewrites the file in place via a same-dir temp file + atomic rename.
402
403/// Returns `path` with `suffix` appended to its final component (kept in the
404/// same directory), e.g. `…/log.jsonl` + `.1` → `…/log.jsonl.1`.
405fn sibling(path: &Path, suffix: &str) -> PathBuf {
406    let mut name = path.as_os_str().to_owned();
407    name.push(suffix);
408    PathBuf::from(name)
409}
410
411/// Parses a human byte size: a number (with optional decimal) and an optional
412/// unit suffix — `b` (bytes, the default), `k`/`kb`/`kib`, `m`/`mb`/`mib`,
413/// `g`/`gb`/`gib` (case-insensitive, all binary/1024-based).
414pub(crate) fn parse_size(s: &str) -> anyhow::Result<u64> {
415    use anyhow::Context as _;
416
417    let lower = s.trim().to_ascii_lowercase();
418    if lower.is_empty() {
419        anyhow::bail!("empty size (expected e.g. 10mb, 512kb, 1048576)");
420    }
421    let split = lower
422        .find(|c: char| !c.is_ascii_digit() && c != '.')
423        .unwrap_or(lower.len());
424    let (num, unit) = lower.split_at(split);
425    let value: f64 = num
426        .parse()
427        .with_context(|| format!("invalid size number: {s}"))?;
428    if !value.is_finite() || value < 0.0 {
429        anyhow::bail!("invalid size: {s}");
430    }
431    let mult: u64 = match unit.trim() {
432        "" | "b" => 1,
433        "k" | "kb" | "kib" => 1024,
434        "m" | "mb" | "mib" => 1024 * 1024,
435        "g" | "gb" | "gib" => 1024 * 1024 * 1024,
436        other => anyhow::bail!("invalid size unit: {other} (use b, kb, mb, or gb)"),
437    };
438    Ok((value * mult as f64) as u64)
439}
440
441/// Resolved rotation policy from the environment. `None` means rotation is off
442/// (the default): `OMNI_DEV_LOG_MAX_SIZE` unset, empty, invalid, or `0`.
443/// Rotation on write is a unix-only feature.
444#[cfg(unix)]
445struct RotationConfig {
446    /// Rotate before an append that would push the file past this many bytes.
447    max_size: u64,
448    /// Number of rotated `log.jsonl.N` files to retain.
449    keep_files: u32,
450}
451
452/// Reads the rotation policy from `OMNI_DEV_LOG_MAX_SIZE` /
453/// `OMNI_DEV_LOG_KEEP_FILES`. A set-but-invalid `OMNI_DEV_LOG_MAX_SIZE` logs at
454/// debug and disables rotation rather than failing the write.
455#[cfg(unix)]
456fn rotation_config() -> Option<RotationConfig> {
457    let raw = std::env::var("OMNI_DEV_LOG_MAX_SIZE").ok()?;
458    if raw.trim().is_empty() {
459        return None;
460    }
461    let max_size = match parse_size(&raw) {
462        Ok(0) => return None,
463        Ok(n) => n,
464        Err(e) => {
465            tracing::debug!("request_log: ignoring invalid OMNI_DEV_LOG_MAX_SIZE: {e}");
466            return None;
467        }
468    };
469    let keep_files = std::env::var("OMNI_DEV_LOG_KEEP_FILES")
470        .ok()
471        .and_then(|v| v.trim().parse::<u32>().ok())
472        .unwrap_or(DEFAULT_KEEP_FILES);
473    Some(RotationConfig {
474        max_size,
475        keep_files,
476    })
477}
478
479/// Rotates `log.jsonl` → `log.jsonl.1`, shifting existing numbered files up and
480/// dropping any beyond `keep_files` (`keep_files == 0` simply discards the
481/// current file). Rotated files inherit the `0600` mode of their source.
482#[cfg(unix)]
483fn rotate(path: &Path, keep_files: u32) -> anyhow::Result<()> {
484    if keep_files == 0 {
485        // Retain no history: dropping the current file lets the caller start a
486        // fresh one on the following append.
487        let _ = std::fs::remove_file(path);
488        return Ok(());
489    }
490    // Drop the oldest retained file, then shift .(N-1) → .N … .1 → .2.
491    let _ = std::fs::remove_file(sibling(path, &format!(".{keep_files}")));
492    for i in (1..keep_files).rev() {
493        let from = sibling(path, &format!(".{i}"));
494        if from.exists() {
495            std::fs::rename(&from, sibling(path, &format!(".{}", i + 1)))?;
496        }
497    }
498    std::fs::rename(path, sibling(path, ".1"))?;
499    Ok(())
500}
501
502/// Size-capped append (unix): under an exclusive lock on a stable `<log>.lock`
503/// file — so all rotation-aware writers serialize on an inode that is never
504/// itself rotated — stat the log, rotate if this line would push a non-empty
505/// file past the cap, then append to the (possibly fresh) file. A rotation
506/// failure is logged at debug and the line is still appended (best effort).
507#[cfg(unix)]
508fn append_with_rotation(path: &Path, line: &str, cfg: &RotationConfig) -> anyhow::Result<()> {
509    use std::os::unix::fs::OpenOptionsExt;
510
511    let lock_path = sibling(path, ".lock");
512    let lock_file = std::fs::OpenOptions::new()
513        .create(true)
514        .write(true)
515        .truncate(false)
516        .mode(0o600)
517        .open(&lock_path)?;
518    crate::daemon::paths::ensure_handle_0600(&lock_file)?;
519    // Hold the lock for the whole check-rotate-append. If the lock cannot be
520    // taken, fall through unlocked rather than dropping the record.
521    let _guard = nix::fcntl::Flock::lock(lock_file, nix::fcntl::FlockArg::LockExclusive).ok();
522
523    let current = std::fs::metadata(path).map_or(0, |m| m.len());
524    if current > 0 && current.saturating_add(line.len() as u64) > cfg.max_size {
525        if let Err(e) = rotate(path, cfg.keep_files) {
526            tracing::debug!("request_log: rotation failed, appending without rotating: {e}");
527        }
528    }
529
530    let mut file = std::fs::OpenOptions::new()
531        .append(true)
532        .create(true)
533        .mode(0o600)
534        .open(path)?;
535    crate::daemon::paths::ensure_handle_0600(&file)?;
536    file.write_all(line.as_bytes())?;
537    Ok(())
538}
539
540/// Options controlling [`prune`].
541pub struct PruneOptions {
542    /// Drop records whose timestamp is strictly older than this cutoff. A
543    /// record with a missing/unparseable timestamp (or a malformed line) is
544    /// conservatively kept.
545    pub older_than: Option<DateTime<Utc>>,
546    /// After age pruning, drop the oldest records until the file is at most
547    /// this many bytes. At least the single most recent record is always kept.
548    pub max_size: Option<u64>,
549    /// Compute and report the outcome without modifying the file.
550    pub dry_run: bool,
551}
552
553/// What a [`prune`] run did (or, when `dry_run`, would do).
554pub struct PruneOutcome {
555    /// Records removed.
556    pub removed: usize,
557    /// Records retained.
558    pub kept: usize,
559    /// File size before.
560    pub bytes_before: u64,
561    /// File size after (the size the retained records occupy).
562    pub bytes_after: u64,
563}
564
565/// Prunes the log at `path` by age and/or size, rewriting it in place.
566///
567/// Non-empty lines are retained by two successive filters: age (`older_than`)
568/// then size (`max_size`, keeping the most recent records that fit). The kept
569/// lines are written to a same-directory temp file (`0600` on unix) and
570/// atomically renamed over the original, so a reader never sees a half-written
571/// file. A missing log is a no-op; a no-change prune skips the rewrite (leaving
572/// the file's inode — and any concurrent appends — untouched).
573pub fn prune(path: &Path, opts: &PruneOptions) -> anyhow::Result<PruneOutcome> {
574    use anyhow::Context as _;
575
576    let data = match std::fs::read(path) {
577        Ok(data) => data,
578        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
579            return Ok(PruneOutcome {
580                removed: 0,
581                kept: 0,
582                bytes_before: 0,
583                bytes_after: 0,
584            });
585        }
586        Err(e) => return Err(e).context("failed to read the log file"),
587    };
588    let bytes_before = data.len() as u64;
589    let text = String::from_utf8_lossy(&data);
590
591    // Every non-empty line, then the subset passing the age filter (both in
592    // original — chronological — order).
593    let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
594    let aged: Vec<&str> = all
595        .iter()
596        .copied()
597        .filter(|line| keep_by_age(line, opts.older_than))
598        .collect();
599
600    let kept: &[&str] = match opts.max_size {
601        None => &aged,
602        Some(max) => keep_by_size(&aged, max),
603    };
604
605    let bytes_after: u64 = kept.iter().map(|l| l.len() as u64 + 1).sum();
606    let outcome = PruneOutcome {
607        removed: all.len() - kept.len(),
608        kept: kept.len(),
609        bytes_before,
610        bytes_after,
611    };
612
613    if !opts.dry_run && outcome.removed > 0 {
614        rewrite_atomically(path, kept)?;
615    }
616    Ok(outcome)
617}
618
619/// Whether a raw line survives the age filter. Absent filter keeps everything;
620/// an undateable or malformed line is conservatively kept.
621fn keep_by_age(line: &str, older_than: Option<DateTime<Utc>>) -> bool {
622    let Some(cutoff) = older_than else {
623        return true;
624    };
625    match serde_json::from_str::<LogRecord>(line) {
626        Ok(rec) => match DateTime::parse_from_rfc3339(&rec.timestamp) {
627            Ok(ts) => ts.with_timezone(&Utc) >= cutoff,
628            Err(_) => true,
629        },
630        Err(_) => true,
631    }
632}
633
634/// Longest suffix of `lines` whose bytes (each line + its newline) fit in `max`,
635/// but never fewer than the single most recent line.
636fn keep_by_size<'a>(lines: &'a [&'a str], max: u64) -> &'a [&'a str] {
637    let mut acc = 0u64;
638    let mut start = lines.len();
639    for (i, line) in lines.iter().enumerate().rev() {
640        acc += line.len() as u64 + 1;
641        if acc > max {
642            break;
643        }
644        start = i;
645    }
646    if start == lines.len() && !lines.is_empty() {
647        start = lines.len() - 1; // keep at least the most recent record
648    }
649    &lines[start..]
650}
651
652/// Writes `lines` (each newline-terminated) to a same-directory temp file and
653/// atomically renames it over `path`, preserving the `0600` posture on unix.
654fn rewrite_atomically(path: &Path, lines: &[&str]) -> anyhow::Result<()> {
655    let tmp = sibling(path, &format!(".prune.{}.tmp", std::process::id()));
656    let result = (|| -> anyhow::Result<()> {
657        let mut options = std::fs::OpenOptions::new();
658        options.create(true).write(true).truncate(true);
659        #[cfg(unix)]
660        {
661            use std::os::unix::fs::OpenOptionsExt;
662            options.mode(0o600);
663        }
664        let mut file = options.open(&tmp)?;
665        #[cfg(unix)]
666        crate::daemon::paths::ensure_handle_0600(&file)?;
667        for line in lines {
668            file.write_all(line.as_bytes())?;
669            file.write_all(b"\n")?;
670        }
671        file.flush()?;
672        std::fs::rename(&tmp, path)?;
673        Ok(())
674    })();
675    if result.is_err() {
676        let _ = std::fs::remove_file(&tmp);
677    }
678    result
679}
680
681/// The outcome of an invocation, recorded once after `cli.execute()` returns.
682#[derive(Debug, Clone)]
683pub struct InvocationOutcome {
684    /// Resolved clap subcommand path.
685    pub command: Vec<String>,
686    /// Full argv.
687    pub command_line: Vec<String>,
688    /// Process exit code.
689    pub exit_code: i32,
690    /// Rendered error chain, when the command failed.
691    pub error: Option<String>,
692    /// Wall time of the whole invocation.
693    pub duration: Duration,
694}
695
696/// Appends one `kind: "invocation"` record from the active context.
697pub fn record_invocation(outcome: InvocationOutcome) {
698    let ctx = current_context();
699    let mut rec = LogRecord::new(RecordKind::Invocation, ctx.invocation_id);
700    rec.source = Some(ctx.source);
701    rec.mcp_tool = ctx.mcp_tool;
702    rec.command = outcome.command;
703    rec.command_line = scrub_argv(&outcome.command_line);
704    rec.exit_code = Some(outcome.exit_code);
705    rec.error = outcome.error;
706    rec.duration_ms = Some(outcome.duration.as_millis() as u64);
707    rec.env = whitelisted_env();
708    record(&rec);
709}
710
711/// Optional, non-secret extras for an HTTP record. Bodies/headers are gated and
712/// redacted centrally in [`record_http_with`], so callers may pass them freely.
713#[derive(Debug, Clone, Default)]
714pub struct HttpExtra {
715    /// True when served inside the daemon.
716    pub via_daemon: bool,
717    /// Pooled daemon session id that served the request.
718    pub daemon_session_id: Option<String>,
719    /// Non-secret identity used (never the secret).
720    pub auth_principal: Option<String>,
721    /// Raw request headers (redacted + gated before writing).
722    pub request_headers: BTreeMap<String, String>,
723    /// Raw response headers (redacted + gated before writing).
724    pub response_headers: BTreeMap<String, String>,
725    /// Request body (gated before writing).
726    pub request_body: Option<String>,
727    /// Response body (gated before writing).
728    pub response_body: Option<String>,
729    /// Free-form correlation tags.
730    pub context: BTreeMap<String, String>,
731}
732
733/// Appends one `kind: "http"` record with method/url/status/elapsed/error.
734pub fn record_http(
735    service: &str,
736    method: &str,
737    url: &str,
738    started: Instant,
739    status: Option<u16>,
740    error: Option<&str>,
741) {
742    record_http_with(
743        service,
744        method,
745        url,
746        started,
747        status,
748        error,
749        HttpExtra::default(),
750    );
751}
752
753/// Appends one `kind: "http"` record from a `reqwest` send result, mapping
754/// `Ok` → status code and `Err` → error message.
755///
756/// Collapses the `match result { Ok → status, Err → error }` shape the REST
757/// clients previously each open-coded around [`record_http`] (#1152).
758pub fn record_http_result(
759    service: &str,
760    method: &str,
761    url: &str,
762    started: Instant,
763    result: &reqwest::Result<reqwest::Response>,
764) {
765    match result {
766        Ok(response) => {
767            record_http(
768                service,
769                method,
770                url,
771                started,
772                Some(response.status().as_u16()),
773                None,
774            );
775        }
776        Err(error) => {
777            record_http(
778                service,
779                method,
780                url,
781                started,
782                None,
783                Some(&error.to_string()),
784            );
785        }
786    }
787}
788
789/// Appends one `kind: "http"` record with extra, non-secret fields.
790///
791/// Headers and bodies are dropped unless their opt-in env var is set, headers
792/// are always redacted, and URL query/fragment values under secret-looking
793/// keys are replaced with `REDACTED` (`redact_url`) — so no secret can be
794/// written here under any caller.
795#[allow(clippy::too_many_arguments)]
796pub fn record_http_with(
797    service: &str,
798    method: &str,
799    url: &str,
800    started: Instant,
801    status: Option<u16>,
802    error: Option<&str>,
803    extra: HttpExtra,
804) {
805    if disabled() {
806        return;
807    }
808    let ctx = current_context();
809    let mut rec = LogRecord::new(RecordKind::Http, ctx.invocation_id);
810    rec.source = Some(ctx.source);
811    rec.mcp_tool = ctx.mcp_tool;
812    rec.service = Some(service.to_string());
813    rec.method = Some(method.to_string());
814    rec.url = Some(redact_url(url));
815    rec.status_code = status;
816    rec.elapsed_ms = Some(started.elapsed().as_millis() as u64);
817    rec.error = error.map(str::to_string);
818    rec.via_daemon = extra.via_daemon;
819    rec.daemon_session_id = extra.daemon_session_id;
820    rec.auth_principal = extra.auth_principal;
821    rec.context = extra.context;
822    if headers_enabled() {
823        rec.request_headers = redact_headers(&extra.request_headers);
824        rec.response_headers = redact_headers(&extra.response_headers);
825    }
826    if bodies_enabled() {
827        rec.request_body = extra.request_body;
828        rec.response_body = extra.response_body;
829    }
830    record(&rec);
831}
832
833/// Header names whose values must never be written (compared lowercased).
834const SENSITIVE_HEADERS: &[&str] = &[
835    "authorization",
836    "proxy-authorization",
837    "cookie",
838    "set-cookie",
839    "x-api-key",
840    "api-key",
841    "dd-api-key",
842    "dd-application-key",
843    "x-datadog-api-key",
844    "x-datadog-application-key",
845    "x-omni-bridge",
846    "x-omni-bridge-target",
847];
848
849/// Substrings that mark a header name as secret-bearing (compared lowercased),
850/// guarding against off-list auth headers (e.g. `x-auth-token`,
851/// `x-goog-api-key`). False positives redact harmlessly.
852const SENSITIVE_HEADER_MARKERS: &[&str] = &[
853    "auth",
854    "token",
855    "secret",
856    "key",
857    "cookie",
858    "password",
859    "session",
860    "signature",
861    "credential",
862];
863
864/// Replaces sensitive header values with `REDACTED`, passing others through.
865///
866/// A header is sensitive when its lowercased name is in [`SENSITIVE_HEADERS`]
867/// or contains any [`SENSITIVE_HEADER_MARKERS`] substring.
868pub fn redact_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
869    headers
870        .iter()
871        .map(|(name, value)| {
872            let lower = name.to_ascii_lowercase();
873            let redacted = SENSITIVE_HEADERS.contains(&lower.as_str())
874                || SENSITIVE_HEADER_MARKERS
875                    .iter()
876                    .any(|marker| lower.contains(marker));
877            (
878                name.clone(),
879                if redacted {
880                    "REDACTED".to_string()
881                } else {
882                    value.clone()
883                },
884            )
885        })
886        .collect()
887}
888
889/// Flag-name segments marking a long flag's value as secret-bearing — the argv
890/// counterpart of [`SECRETISH`]. Matched per `-`/`_`-separated segment of the
891/// flag name so `--api-key` is caught but a name like `--keyword` is not.
892const SECRETISH_FLAG_WORDS: &[&str] = &["token", "secret", "password", "passwd", "key"];
893
894/// True when the long flag `--<name>` takes a secret-bearing value. Flags whose
895/// last segment is `file` or `path` carry paths, not secrets, and are exempt
896/// (e.g. `--token-file`).
897fn is_secretish_flag(name: &str) -> bool {
898    let segments: Vec<String> = name
899        .split(['-', '_'])
900        .map(str::to_ascii_lowercase)
901        .collect();
902    let takes_path = matches!(segments.last().map(String::as_str), Some("file" | "path"));
903    !takes_path
904        && segments
905            .iter()
906            .any(|segment| SECRETISH_FLAG_WORDS.contains(&segment.as_str()))
907}
908
909/// Scrubs one `--header` value (`Name: Value`): values of [`SENSITIVE_HEADERS`]
910/// are redacted keeping the name, other headers pass through (`None`), and a
911/// value with no colon is redacted wholesale.
912fn scrub_header_arg(value: &str) -> Option<String> {
913    let Some((name, _)) = value.split_once(':') else {
914        return Some("REDACTED".to_string());
915    };
916    SENSITIVE_HEADERS
917        .contains(&name.trim().to_ascii_lowercase().as_str())
918        .then(|| format!("{}: REDACTED", name.trim()))
919}
920
921/// Returns the scrubbed replacement for the value of flag `--<name>`, or
922/// `None` when the value is safe to log verbatim. `--body` keeps `@file`
923/// references (a path, not a secret).
924fn scrub_flag_value(name: &str, value: &str) -> Option<String> {
925    match name {
926        "header" => scrub_header_arg(value),
927        "body" => (!value.starts_with('@')).then(|| "REDACTED".to_string()),
928        _ if is_secretish_flag(name) => Some("REDACTED".to_string()),
929        _ => None,
930    }
931}
932
933/// Scrubs secret-bearing values out of a raw argv before it is logged. Two
934/// write-side layers, so the on-disk line is clean and every reader/format is
935/// covered with no reader changes:
936///
937/// 1. [`scrub_flag_secrets`] — flag-aware whole-value redaction (`--header`/
938///    `--body` plus any [`is_secretish_flag`] name, in both `--flag value` and
939///    `--flag=value` forms).
940/// 2. [`redact_url`] over every resulting element — a secret-bearing query or
941///    fragment parameter on a URL argument (most naturally
942///    `--url /path?access_token=…`, which no flag-name rule catches) has its
943///    value redacted, while benign argv passes through byte-identical (#1162).
944fn scrub_argv(argv: &[String]) -> Vec<String> {
945    scrub_flag_secrets(argv)
946        .iter()
947        .map(|arg| redact_url(arg))
948        .collect()
949}
950
951/// Flag-aware first layer of [`scrub_argv`]: redacts secret-bearing flag values
952/// (`--header`/`--body` plus any [`is_secretish_flag`] name, in both
953/// `--flag value` and `--flag=value` forms). Everything else passes through to
954/// the URL layer.
955fn scrub_flag_secrets(argv: &[String]) -> Vec<String> {
956    let mut out = Vec::with_capacity(argv.len());
957    let mut i = 0;
958    while i < argv.len() {
959        let arg = &argv[i];
960        i += 1;
961        let Some(flag_body) = arg.strip_prefix("--") else {
962            out.push(arg.clone());
963            continue;
964        };
965        if let Some((name, value)) = flag_body.split_once('=') {
966            match scrub_flag_value(name, value) {
967                Some(scrubbed) => out.push(format!("--{name}={scrubbed}")),
968                None => out.push(arg.clone()),
969            }
970        } else {
971            out.push(arg.clone());
972            let takes_secret_value =
973                matches!(flag_body, "header" | "body") || is_secretish_flag(flag_body);
974            if takes_secret_value {
975                if let Some(value) = argv.get(i) {
976                    i += 1;
977                    out.push(scrub_flag_value(flag_body, value).unwrap_or_else(|| value.clone()));
978                }
979            }
980        }
981    }
982    out
983}
984
985/// Query/fragment keys that are secrets outright (compared decoded + lowercased).
986const SENSITIVE_QUERY_KEYS: &[&str] = &["sig", "sas", "jwt", "auth"];
987
988/// Key suffixes marking the open-ended secret families (`access_token`,
989/// `client_secret`, `api_key`, …).
990const SENSITIVE_QUERY_KEY_SUFFIXES: &[&str] = &[
991    "token",
992    "secret",
993    "password",
994    "passwd",
995    "signature",
996    "apikey",
997    "api_key",
998    "api-key",
999];
1000
1001/// Key prefixes for cloud-storage signed-URL parameter families.
1002const SENSITIVE_QUERY_KEY_PREFIXES: &[&str] = &["x-amz-", "x-goog-"];
1003
1004/// Returns whether a decoded query/fragment key looks secret-bearing.
1005fn sensitive_query_key(key: &str) -> bool {
1006    let key = key.to_ascii_lowercase();
1007    SENSITIVE_QUERY_KEYS.contains(&key.as_str())
1008        || SENSITIVE_QUERY_KEY_SUFFIXES
1009            .iter()
1010            .any(|suffix| key.ends_with(suffix))
1011        || SENSITIVE_QUERY_KEY_PREFIXES
1012            .iter()
1013            .any(|prefix| key.starts_with(prefix))
1014}
1015
1016/// Rewrites one `&`-separated pair list, replacing the values of
1017/// secret-bearing keys with `REDACTED` and passing every other segment
1018/// through byte-verbatim.
1019fn redact_pairs(pairs: &str) -> String {
1020    pairs
1021        .split('&')
1022        .map(|segment| match segment.split_once('=') {
1023            Some((raw_key, _)) => {
1024                // Decode only the key (handles `access%5Ftoken` and `+`); the
1025                // raw key text is preserved in the output.
1026                let sensitive = url::form_urlencoded::parse(raw_key.as_bytes())
1027                    .next()
1028                    .is_some_and(|(key, _)| sensitive_query_key(&key));
1029                if sensitive {
1030                    format!("{raw_key}=REDACTED")
1031                } else {
1032                    segment.to_string()
1033                }
1034            }
1035            // A bare key (no `=`) carries no value to leak.
1036            None => segment.to_string(),
1037        })
1038        .collect::<Vec<_>>()
1039        .join("&")
1040}
1041
1042/// Redacts secret-bearing query and fragment parameter values in a URL,
1043/// preserving scheme, host, path, and all parameter keys so `--url` substring
1044/// filtering stays useful. Handles relative URLs (the browser bridge logs
1045/// page-origin targets like `/api/foo?sig=…`), so this never requires the
1046/// input to parse as an absolute [`url::Url`].
1047fn redact_url(url: &str) -> String {
1048    let (rest, fragment) = url
1049        .split_once('#')
1050        .map_or((url, None), |(rest, fragment)| (rest, Some(fragment)));
1051    let (prefix, query) = rest
1052        .split_once('?')
1053        .map_or((rest, None), |(prefix, query)| (prefix, Some(query)));
1054    let mut out = prefix.to_string();
1055    if let Some(query) = query {
1056        out.push('?');
1057        out.push_str(&redact_pairs(query));
1058    }
1059    if let Some(fragment) = fragment {
1060        out.push('#');
1061        out.push_str(&redact_pairs(fragment));
1062    }
1063    out
1064}
1065
1066/// A time-sortable id: 13-digit zero-padded epoch-millis, a dash, then 16 hex.
1067///
1068/// Lexical order ≈ chronological order, which is all the reader needs. Mirrors
1069/// the uuid-shaped minting in [`crate::snowflake::client`] without adding a
1070/// crate.
1071pub fn new_id() -> String {
1072    let millis = chrono::Utc::now().timestamp_millis().max(0);
1073    let suffix = rand::random::<u64>();
1074    format!("{millis:013}-{suffix:016x}")
1075}
1076
1077/// Current time as RFC3339 with millisecond precision, in UTC.
1078fn now_rfc3339_millis() -> String {
1079    chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
1080}
1081
1082/// Best-effort current working directory.
1083fn cwd() -> String {
1084    std::env::current_dir()
1085        .map(|p| p.display().to_string())
1086        .unwrap_or_default()
1087}
1088
1089/// Best-effort OS username (`$USER`, then the passwd entry for the euid).
1090fn system_user() -> String {
1091    if let Ok(user) = std::env::var("USER") {
1092        if !user.is_empty() {
1093            return user;
1094        }
1095    }
1096    #[cfg(unix)]
1097    {
1098        if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid()) {
1099            return user.name;
1100        }
1101    }
1102    String::new()
1103}
1104
1105/// Best-effort hostname (`gethostname`, then `$HOSTNAME`, then empty).
1106fn hostname() -> String {
1107    #[cfg(unix)]
1108    {
1109        if let Ok(name) = nix::unistd::gethostname() {
1110            if let Some(name) = name.to_str() {
1111                if !name.is_empty() {
1112                    return name.to_string();
1113                }
1114            }
1115        }
1116    }
1117    std::env::var("HOSTNAME").unwrap_or_default()
1118}
1119
1120/// Names matching these substrings have their env values redacted, guarding
1121/// against any future secret-bearing `OMNI_DEV_*` var.
1122const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
1123
1124/// Snapshot of `OMNI_DEV_*` env vars, with secret-looking values redacted.
1125fn whitelisted_env() -> BTreeMap<String, String> {
1126    std::env::vars()
1127        .filter(|(k, _)| k.starts_with("OMNI_DEV_"))
1128        .map(|(k, v)| {
1129            let secretish = SECRETISH.iter().any(|needle| k.contains(needle));
1130            let value = if secretish { "REDACTED".to_string() } else { v };
1131            (k, value)
1132        })
1133        .collect()
1134}
1135
1136#[cfg(test)]
1137#[allow(clippy::unwrap_used, clippy::expect_used)]
1138mod tests {
1139    use super::*;
1140
1141    #[test]
1142    fn record_round_trips_through_json() {
1143        let mut rec = LogRecord::new(RecordKind::Http, "inv-1".to_string());
1144        rec.service = Some("jira".to_string());
1145        rec.method = Some("GET".to_string());
1146        rec.url = Some("https://example.atlassian.net/rest/api/3/issue/X-1".to_string());
1147        rec.status_code = Some(200);
1148        rec.elapsed_ms = Some(42);
1149
1150        let line = serde_json::to_string(&rec).unwrap();
1151        let back: LogRecord = serde_json::from_str(&line).unwrap();
1152        assert_eq!(back.invocation_id, "inv-1");
1153        assert_eq!(back.kind, RecordKind::Http);
1154        assert_eq!(back.service.as_deref(), Some("jira"));
1155        assert_eq!(back.status_code, Some(200));
1156    }
1157
1158    #[test]
1159    fn reader_tolerates_unknown_fields() {
1160        let line = r#"{"id":"x","invocation_id":"i","kind":"http","method":"GET",
1161            "future_field":{"nested":true},"another":42}"#;
1162        let rec: LogRecord = serde_json::from_str(line).unwrap();
1163        assert_eq!(rec.kind, RecordKind::Http);
1164        assert_eq!(rec.method.as_deref(), Some("GET"));
1165    }
1166
1167    #[test]
1168    fn reader_tolerates_missing_newer_fields() {
1169        // An "old" line with only a couple of fields present.
1170        let line = r#"{"kind":"invocation","command":["git","view"]}"#;
1171        let rec: LogRecord = serde_json::from_str(line).unwrap();
1172        assert_eq!(rec.kind, RecordKind::Invocation);
1173        assert_eq!(rec.command, vec!["git", "view"]);
1174        assert!(rec.status_code.is_none());
1175        assert!(rec.id.is_empty());
1176    }
1177
1178    #[test]
1179    fn unknown_kind_and_source_do_not_fail() {
1180        let line = r#"{"kind":"telemetry","source":"webhook"}"#;
1181        let rec: LogRecord = serde_json::from_str(line).unwrap();
1182        assert_eq!(rec.kind, RecordKind::Unknown);
1183        assert_eq!(rec.source, Some(Source::Unknown));
1184    }
1185
1186    #[test]
1187    fn optional_fields_are_skipped_when_empty() {
1188        let rec = LogRecord::new(RecordKind::Invocation, "i".to_string());
1189        let line = serde_json::to_string(&rec).unwrap();
1190        // Empty collections / None options must not appear on the wire.
1191        assert!(!line.contains("status_code"));
1192        assert!(!line.contains("request_headers"));
1193        assert!(!line.contains("via_daemon"));
1194        assert!(!line.contains("\"env\""));
1195    }
1196
1197    #[test]
1198    fn ids_are_time_sortable() {
1199        let a = new_id();
1200        std::thread::sleep(std::time::Duration::from_millis(2));
1201        let b = new_id();
1202        assert!(a < b, "{a} should sort before {b}");
1203    }
1204
1205    #[test]
1206    fn sensitive_headers_are_redacted() {
1207        let mut headers = BTreeMap::new();
1208        headers.insert("Authorization".to_string(), "Bearer secret".to_string());
1209        headers.insert("X-Api-Key".to_string(), "abc123".to_string());
1210        headers.insert("Content-Type".to_string(), "application/json".to_string());
1211        let out = redact_headers(&headers);
1212        assert_eq!(out["Authorization"], "REDACTED");
1213        assert_eq!(out["X-Api-Key"], "REDACTED");
1214        assert_eq!(out["Content-Type"], "application/json");
1215    }
1216
1217    fn argv(args: &[&str]) -> Vec<String> {
1218        args.iter().copied().map(String::from).collect()
1219    }
1220
1221    #[test]
1222    fn scrub_argv_redacts_sensitive_header_in_both_forms() {
1223        let out = scrub_argv(&argv(&[
1224            "omni-dev",
1225            "--header",
1226            "Authorization: Bearer sekret",
1227            "--header=Cookie: session=abc",
1228        ]));
1229        assert_eq!(
1230            out,
1231            argv(&[
1232                "omni-dev",
1233                "--header",
1234                "Authorization: REDACTED",
1235                "--header=Cookie: REDACTED",
1236            ])
1237        );
1238    }
1239
1240    #[test]
1241    fn scrub_argv_keeps_non_sensitive_headers() {
1242        let input = argv(&["omni-dev", "--header", "Content-Type: application/json"]);
1243        assert_eq!(scrub_argv(&input), input);
1244    }
1245
1246    #[test]
1247    fn scrub_argv_redacts_colonless_header_wholesale() {
1248        let out = scrub_argv(&argv(&["omni-dev", "--header", "sekret"]));
1249        assert_eq!(out, argv(&["omni-dev", "--header", "REDACTED"]));
1250    }
1251
1252    #[test]
1253    fn scrub_argv_redacts_inline_body_but_keeps_at_file() {
1254        let out = scrub_argv(&argv(&["omni-dev", "--body", r#"{"secret":1}"#]));
1255        assert_eq!(out, argv(&["omni-dev", "--body", "REDACTED"]));
1256
1257        let file_form = argv(&["omni-dev", "--body", "@payload.json"]);
1258        assert_eq!(scrub_argv(&file_form), file_form);
1259
1260        let out = scrub_argv(&argv(&["omni-dev", "--body=sekret"]));
1261        assert_eq!(out, argv(&["omni-dev", "--body=REDACTED"]));
1262    }
1263
1264    #[test]
1265    fn scrub_argv_redacts_secretish_flag_values() {
1266        let out = scrub_argv(&argv(&["omni-dev", "--api-key", "abc", "--auth-token=xyz"]));
1267        assert_eq!(
1268            out,
1269            argv(&["omni-dev", "--api-key", "REDACTED", "--auth-token=REDACTED"])
1270        );
1271    }
1272
1273    #[test]
1274    fn scrub_argv_exempts_path_flags_and_positionals() {
1275        let input = argv(&["omni-dev", "--token-file", "/tmp/t", "PROJ-123"]);
1276        assert_eq!(scrub_argv(&input), input);
1277    }
1278
1279    #[test]
1280    fn scrub_argv_redacts_secret_bearing_url_query_in_both_forms() {
1281        // `--url` is not a secret-ish flag name, so its value is caught by the
1282        // redact_url layer, not the flag layer (#1162). Both argv shapes plus a
1283        // bare positional URL are covered; the benign `page` param survives.
1284        let space = scrub_argv(&argv(&[
1285            "omni-dev",
1286            "browser",
1287            "bridge",
1288            "request",
1289            "--url",
1290            "/api/export?access_token=hunter2&sig=deadbeef&page=3",
1291        ]));
1292        assert_eq!(
1293            *space.last().unwrap(),
1294            "/api/export?access_token=REDACTED&sig=REDACTED&page=3"
1295        );
1296
1297        let eq_form = scrub_argv(&argv(&[
1298            "omni-dev",
1299            "--url=/api/export?access_token=hunter2&page=3",
1300        ]));
1301        assert_eq!(
1302            *eq_form.last().unwrap(),
1303            "--url=/api/export?access_token=REDACTED&page=3"
1304        );
1305
1306        let positional = scrub_argv(&argv(&["omni-dev", "https://h/cb#id_token=xyz"]));
1307        assert_eq!(
1308            *positional.last().unwrap(),
1309            "https://h/cb#id_token=REDACTED"
1310        );
1311    }
1312
1313    #[test]
1314    fn scrub_argv_leaves_benign_argv_byte_identical() {
1315        let input = argv(&[
1316            "omni-dev",
1317            "browser",
1318            "bridge",
1319            "request",
1320            "--control-port",
1321            "19998",
1322            "--url",
1323            "/api/export?page=3&sort=asc",
1324        ]);
1325        assert_eq!(scrub_argv(&input), input);
1326    }
1327
1328    #[test]
1329    fn scrub_argv_handles_trailing_flag_without_value() {
1330        let input = argv(&["omni-dev", "--body"]);
1331        assert_eq!(scrub_argv(&input), input);
1332    }
1333
1334    #[cfg(unix)]
1335    #[test]
1336    fn append_line_creates_file_owner_only() {
1337        use std::os::unix::fs::PermissionsExt;
1338        let dir = tempfile::tempdir().unwrap();
1339        let path = dir.path().join("log.jsonl");
1340        append_line(&path, "{\"kind\":\"http\"}\n").unwrap();
1341        assert_eq!(
1342            std::fs::read_to_string(&path).unwrap(),
1343            "{\"kind\":\"http\"}\n"
1344        );
1345        assert_eq!(
1346            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1347            0o600
1348        );
1349    }
1350
1351    #[cfg(unix)]
1352    #[test]
1353    fn append_line_retightens_preexisting_loose_file() {
1354        use std::os::unix::fs::PermissionsExt;
1355        let dir = tempfile::tempdir().unwrap();
1356        let path = dir.path().join("log.jsonl");
1357        std::fs::write(&path, "old\n").unwrap();
1358        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1359        append_line(&path, "new\n").unwrap();
1360        assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
1361        assert_eq!(
1362            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1363            0o600
1364        );
1365    }
1366
1367    #[test]
1368    fn off_list_secretish_headers_are_redacted() {
1369        let mut headers = BTreeMap::new();
1370        for name in [
1371            "X-Auth-Token",
1372            "x-amz-security-token",
1373            "X-Goog-Api-Key",
1374            "x-csrf-token",
1375            "X-Vendor-Token",
1376            "X-Omni-Bridge",
1377        ] {
1378            headers.insert(name.to_string(), "secret-value".to_string());
1379        }
1380        for name in [
1381            "Content-Type",
1382            "Accept",
1383            "User-Agent",
1384            "x-request-id",
1385            "traceparent",
1386        ] {
1387            headers.insert(name.to_string(), "plain-value".to_string());
1388        }
1389        let out = redact_headers(&headers);
1390        assert_eq!(out["X-Auth-Token"], "REDACTED");
1391        assert_eq!(out["x-amz-security-token"], "REDACTED");
1392        assert_eq!(out["X-Goog-Api-Key"], "REDACTED");
1393        assert_eq!(out["x-csrf-token"], "REDACTED");
1394        assert_eq!(out["X-Vendor-Token"], "REDACTED");
1395        assert_eq!(out["X-Omni-Bridge"], "REDACTED");
1396        assert_eq!(out["Content-Type"], "plain-value");
1397        assert_eq!(out["Accept"], "plain-value");
1398        assert_eq!(out["User-Agent"], "plain-value");
1399        assert_eq!(out["x-request-id"], "plain-value");
1400        assert_eq!(out["traceparent"], "plain-value");
1401    }
1402
1403    #[test]
1404    fn url_without_query_is_unchanged() {
1405        assert_eq!(redact_url("https://h/p"), "https://h/p");
1406        assert_eq!(redact_url("/relative/p"), "/relative/p");
1407    }
1408
1409    #[test]
1410    fn benign_query_is_byte_identical() {
1411        let url = "https://h/p?q=a%20b&page=2&&x=y+z&keyword=k&sort_key=s&token_type=bearer";
1412        assert_eq!(redact_url(url), url);
1413    }
1414
1415    #[test]
1416    fn sensitive_query_values_are_redacted() {
1417        let url = "https://h/p?token=a&access_token=b&client_secret=c&api_key=d&x=1";
1418        assert_eq!(
1419            redact_url(url),
1420            "https://h/p?token=REDACTED&access_token=REDACTED&client_secret=REDACTED\
1421             &api_key=REDACTED&x=1"
1422        );
1423    }
1424
1425    #[test]
1426    fn presigned_s3_query_is_redacted() {
1427        let url = "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=AWS4-HMAC-SHA256\
1428                   &X-Amz-Credential=AKIA%2F20260703%2Fus-east-1%2Fs3%2Faws4_request\
1429                   &X-Amz-Date=20260703T000000Z&X-Amz-Expires=3600\
1430                   &X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef";
1431        assert_eq!(
1432            redact_url(url),
1433            "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=REDACTED\
1434             &X-Amz-Credential=REDACTED&X-Amz-Date=REDACTED&X-Amz-Expires=REDACTED\
1435             &X-Amz-SignedHeaders=REDACTED&X-Amz-Signature=REDACTED"
1436        );
1437    }
1438
1439    #[test]
1440    fn key_matching_is_case_insensitive() {
1441        assert_eq!(
1442            redact_url("/p?TOKEN=x&Api_Key=y&X-Amz-Signature=z"),
1443            "/p?TOKEN=REDACTED&Api_Key=REDACTED&X-Amz-Signature=REDACTED"
1444        );
1445    }
1446
1447    #[test]
1448    fn repeated_sensitive_keys_are_each_redacted() {
1449        assert_eq!(redact_url("/p?sig=a&sig=b"), "/p?sig=REDACTED&sig=REDACTED");
1450    }
1451
1452    #[test]
1453    fn valueless_key_is_left_alone() {
1454        assert_eq!(redact_url("/p?token"), "/p?token");
1455        assert_eq!(redact_url("/p?token="), "/p?token=REDACTED");
1456    }
1457
1458    #[test]
1459    fn relative_url_query_is_redacted() {
1460        assert_eq!(
1461            redact_url("/api/foo?sig=abc&x=y"),
1462            "/api/foo?sig=REDACTED&x=y"
1463        );
1464    }
1465
1466    #[test]
1467    fn fragment_credentials_are_redacted() {
1468        assert_eq!(
1469            redact_url("https://h/cb#access_token=xyz&token_type=bearer"),
1470            "https://h/cb#access_token=REDACTED&token_type=bearer"
1471        );
1472    }
1473
1474    #[test]
1475    fn query_and_fragment_are_scrubbed_independently() {
1476        assert_eq!(
1477            redact_url("/p?sig=a#id_token=b"),
1478            "/p?sig=REDACTED#id_token=REDACTED"
1479        );
1480    }
1481
1482    #[test]
1483    fn question_mark_in_fragment_is_not_parsed_as_query() {
1484        // The fragment is split off before the query, so `?` inside it never
1485        // starts a query; the pseudo-key `frag?token` still redacts via the
1486        // suffix rule (over-redaction in the safe direction).
1487        assert_eq!(
1488            redact_url("https://h/p#frag?token=x"),
1489            "https://h/p#frag?token=REDACTED"
1490        );
1491    }
1492
1493    #[test]
1494    fn encoded_sensitive_key_is_decoded_before_matching() {
1495        assert_eq!(
1496            redact_url("/p?access%5Ftoken=v"),
1497            "/p?access%5Ftoken=REDACTED"
1498        );
1499    }
1500
1501    #[test]
1502    fn empty_query_is_unchanged() {
1503        assert_eq!(redact_url("https://h/p?"), "https://h/p?");
1504        assert_eq!(redact_url("https://h/p?#f"), "https://h/p?#f");
1505    }
1506
1507    #[test]
1508    fn env_flag_parses_truthy_values() {
1509        std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "1");
1510        assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1511        std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "TRUE");
1512        assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1513        std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "0");
1514        assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1515        std::env::remove_var("OMNI_DEV_TEST_FLAG_ABC");
1516        assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1517    }
1518
1519    #[test]
1520    fn parse_size_handles_units_and_bare_bytes() {
1521        assert_eq!(parse_size("1048576").unwrap(), 1024 * 1024);
1522        assert_eq!(parse_size("512b").unwrap(), 512);
1523        assert_eq!(parse_size("10kb").unwrap(), 10 * 1024);
1524        assert_eq!(parse_size("2K").unwrap(), 2 * 1024);
1525        assert_eq!(parse_size("3mb").unwrap(), 3 * 1024 * 1024);
1526        assert_eq!(parse_size("1gb").unwrap(), 1024 * 1024 * 1024);
1527        assert_eq!(parse_size("1.5mb").unwrap(), (1.5 * 1024.0 * 1024.0) as u64);
1528        assert_eq!(parse_size(" 4mib ").unwrap(), 4 * 1024 * 1024);
1529    }
1530
1531    #[test]
1532    fn parse_size_rejects_garbage() {
1533        assert!(parse_size("").is_err());
1534        assert!(parse_size("mb").is_err());
1535        assert!(parse_size("10tb").is_err());
1536        assert!(parse_size("-5mb").is_err());
1537    }
1538
1539    #[test]
1540    fn sibling_appends_to_final_component() {
1541        let base = Path::new("/tmp/omni/log.jsonl");
1542        assert_eq!(sibling(base, ".1"), Path::new("/tmp/omni/log.jsonl.1"));
1543        assert_eq!(
1544            sibling(base, ".lock"),
1545            Path::new("/tmp/omni/log.jsonl.lock")
1546        );
1547    }
1548
1549    #[test]
1550    fn keep_by_size_keeps_most_recent_that_fit() {
1551        // Four 10-byte lines (11 bytes on disk each with the newline).
1552        let lines = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"];
1553        let refs: Vec<&str> = lines.to_vec();
1554
1555        // Budget for exactly two lines (22 bytes) keeps the last two.
1556        assert_eq!(keep_by_size(&refs, 22), &["cccccccccc", "dddddddddd"]);
1557        // A budget smaller than one line still keeps the single most recent.
1558        assert_eq!(keep_by_size(&refs, 1), &["dddddddddd"]);
1559        // A generous budget keeps everything.
1560        assert_eq!(keep_by_size(&refs, 10_000), &refs[..]);
1561        // Empty input yields empty output (no panic).
1562        assert!(keep_by_size(&[], 100).is_empty());
1563    }
1564
1565    #[test]
1566    fn keep_by_age_is_conservative_on_undateable_lines() {
1567        let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1568            .unwrap()
1569            .with_timezone(&Utc);
1570        let old = r#"{"kind":"http","timestamp":"2026-01-01T00:00:00.000Z"}"#;
1571        let new = r#"{"kind":"http","timestamp":"2026-12-01T00:00:00.000Z"}"#;
1572        let undated = r#"{"kind":"http"}"#;
1573        let malformed = "not json at all";
1574
1575        assert!(!keep_by_age(old, Some(cutoff)));
1576        assert!(keep_by_age(new, Some(cutoff)));
1577        assert!(keep_by_age(undated, Some(cutoff)), "undated is kept");
1578        assert!(keep_by_age(malformed, Some(cutoff)), "malformed is kept");
1579        assert!(keep_by_age(old, None), "no filter keeps everything");
1580    }
1581
1582    fn http_line(id: &str, ts: &str) -> String {
1583        format!(r#"{{"id":"{id}","kind":"http","timestamp":"{ts}"}}"#)
1584    }
1585
1586    #[test]
1587    fn prune_by_age_drops_old_records_and_rewrites_atomically() {
1588        use std::os::unix::fs::PermissionsExt;
1589
1590        let dir = tempfile::tempdir().unwrap();
1591        let path = dir.path().join("log.jsonl");
1592        let body = format!(
1593            "{}\n{}\n{}\n",
1594            http_line("1", "2026-01-01T00:00:00.000Z"),
1595            http_line("2", "2026-06-15T00:00:00.000Z"),
1596            http_line("3", "2026-12-31T00:00:00.000Z"),
1597        );
1598        std::fs::write(&path, &body).unwrap();
1599        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1600
1601        let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1602            .unwrap()
1603            .with_timezone(&Utc);
1604        let outcome = prune(
1605            &path,
1606            &PruneOptions {
1607                older_than: Some(cutoff),
1608                max_size: None,
1609                dry_run: false,
1610            },
1611        )
1612        .unwrap();
1613
1614        assert_eq!(outcome.removed, 1);
1615        assert_eq!(outcome.kept, 2);
1616        let contents = std::fs::read_to_string(&path).unwrap();
1617        assert!(!contents.contains(r#""id":"1""#));
1618        assert!(contents.contains(r#""id":"2""#));
1619        assert!(contents.contains(r#""id":"3""#));
1620        // The atomic rewrite lands a fresh 0600 file regardless of the old mode.
1621        assert_eq!(
1622            std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1623            0o600
1624        );
1625    }
1626
1627    #[test]
1628    fn prune_dry_run_reports_without_modifying() {
1629        let dir = tempfile::tempdir().unwrap();
1630        let path = dir.path().join("log.jsonl");
1631        let body = format!(
1632            "{}\n{}\n",
1633            http_line("1", "2026-01-01T00:00:00.000Z"),
1634            http_line("2", "2026-12-31T00:00:00.000Z"),
1635        );
1636        std::fs::write(&path, &body).unwrap();
1637
1638        let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1639            .unwrap()
1640            .with_timezone(&Utc);
1641        let outcome = prune(
1642            &path,
1643            &PruneOptions {
1644                older_than: Some(cutoff),
1645                max_size: None,
1646                dry_run: true,
1647            },
1648        )
1649        .unwrap();
1650
1651        assert_eq!(outcome.removed, 1);
1652        // File is untouched by a dry run.
1653        assert_eq!(std::fs::read_to_string(&path).unwrap(), body);
1654    }
1655
1656    #[test]
1657    fn prune_by_size_keeps_the_newest_that_fit() {
1658        let dir = tempfile::tempdir().unwrap();
1659        let path = dir.path().join("log.jsonl");
1660        let l1 = http_line("1", "2026-01-01T00:00:00.000Z");
1661        let l2 = http_line("2", "2026-06-15T00:00:00.000Z");
1662        let l3 = http_line("3", "2026-12-31T00:00:00.000Z");
1663        std::fs::write(&path, format!("{l1}\n{l2}\n{l3}\n")).unwrap();
1664
1665        // Budget that fits only the last two lines.
1666        let budget = (l2.len() + 1 + l3.len() + 1) as u64;
1667        let outcome = prune(
1668            &path,
1669            &PruneOptions {
1670                older_than: None,
1671                max_size: Some(budget),
1672                dry_run: false,
1673            },
1674        )
1675        .unwrap();
1676
1677        assert_eq!(outcome.removed, 1);
1678        assert_eq!(outcome.kept, 2);
1679        let contents = std::fs::read_to_string(&path).unwrap();
1680        assert!(!contents.contains(r#""id":"1""#));
1681        assert!(contents.contains(r#""id":"3""#));
1682    }
1683
1684    #[test]
1685    fn prune_missing_file_is_a_noop() {
1686        let dir = tempfile::tempdir().unwrap();
1687        let path = dir.path().join("absent.jsonl");
1688        let outcome = prune(
1689            &path,
1690            &PruneOptions {
1691                older_than: None,
1692                max_size: Some(1),
1693                dry_run: false,
1694            },
1695        )
1696        .unwrap();
1697        assert_eq!(outcome.removed, 0);
1698        assert_eq!(outcome.kept, 0);
1699        assert!(!path.exists());
1700    }
1701
1702    #[cfg(unix)]
1703    #[test]
1704    fn rotation_shifts_numbered_files_and_drops_the_oldest() {
1705        use std::os::unix::fs::PermissionsExt;
1706
1707        let dir = tempfile::tempdir().unwrap();
1708        let path = dir.path().join("log.jsonl");
1709        // A tiny cap so every second short line rotates.
1710        let cfg = RotationConfig {
1711            max_size: 20,
1712            keep_files: 2,
1713        };
1714
1715        let line = "0123456789012345\n"; // 17 bytes
1716        for _ in 0..4 {
1717            append_with_rotation(&path, line, &cfg).unwrap();
1718        }
1719
1720        // The live file plus at most keep_files (2) rotated files exist; a .3
1721        // must never appear.
1722        assert!(path.exists());
1723        assert!(sibling(&path, ".1").exists());
1724        assert!(sibling(&path, ".2").exists());
1725        assert!(!sibling(&path, ".3").exists());
1726        // Rotated files keep the 0600 posture.
1727        assert_eq!(
1728            std::fs::metadata(sibling(&path, ".1"))
1729                .unwrap()
1730                .permissions()
1731                .mode()
1732                & 0o777,
1733            0o600
1734        );
1735    }
1736
1737    #[cfg(unix)]
1738    #[test]
1739    fn rotation_keep_zero_discards_on_overflow() {
1740        let dir = tempfile::tempdir().unwrap();
1741        let path = dir.path().join("log.jsonl");
1742        let cfg = RotationConfig {
1743            max_size: 20,
1744            keep_files: 0,
1745        };
1746        let line = "0123456789012345\n"; // 17 bytes
1747        append_with_rotation(&path, line, &cfg).unwrap();
1748        append_with_rotation(&path, line, &cfg).unwrap();
1749        // No .1 is retained; only the current (single-line) file survives.
1750        assert!(!sibling(&path, ".1").exists());
1751        assert_eq!(std::fs::read_to_string(&path).unwrap(), line);
1752    }
1753
1754    #[test]
1755    fn parse_size_rejects_overflow_to_infinity() {
1756        // A number too large for f64 parses to a non-finite value, not a size.
1757        assert!(parse_size(&"9".repeat(400)).is_err());
1758    }
1759
1760    #[test]
1761    fn prune_surfaces_a_read_error() {
1762        // Reading a directory as the log yields an error other than NotFound,
1763        // which prune propagates rather than treating as an empty log.
1764        let dir = tempfile::tempdir().unwrap();
1765        let result = prune(
1766            dir.path(),
1767            &PruneOptions {
1768                older_than: None,
1769                max_size: Some(1),
1770                dry_run: false,
1771            },
1772        );
1773        assert!(result.is_err());
1774    }
1775
1776    #[cfg(unix)]
1777    #[test]
1778    fn append_with_rotation_appends_even_when_rotate_fails() {
1779        let dir = tempfile::tempdir().unwrap();
1780        let path = dir.path().join("log.jsonl");
1781        // Seed a file already over the cap so the next write attempts to rotate.
1782        std::fs::write(&path, "0123456789012345\n").unwrap();
1783        // Make the rotation target a directory so `rename(log, log.1)` fails.
1784        std::fs::create_dir(sibling(&path, ".1")).unwrap();
1785        let cfg = RotationConfig {
1786            max_size: 5,
1787            keep_files: 1,
1788        };
1789        // Rotation fails, but the line is still appended (best effort).
1790        append_with_rotation(&path, "new-line\n", &cfg).unwrap();
1791        assert!(
1792            std::fs::read_to_string(&path).unwrap().contains("new-line"),
1793            "the record is appended despite the rotation failure"
1794        );
1795    }
1796
1797    #[test]
1798    fn prune_cleans_up_temp_on_rewrite_failure() {
1799        let dir = tempfile::tempdir().unwrap();
1800        let path = dir.path().join("log.jsonl");
1801        std::fs::write(
1802            &path,
1803            format!(
1804                "{}\n{}\n",
1805                http_line("a", "2999-01-01T00:00:00.000Z"),
1806                http_line("b", "2999-01-01T00:00:00.000Z"),
1807            ),
1808        )
1809        .unwrap();
1810        // Pre-create the exact temp path (same-process pid) as a directory so
1811        // the atomic rewrite's open fails, exercising the cleanup path.
1812        let tmp = sibling(&path, &format!(".prune.{}.tmp", std::process::id()));
1813        std::fs::create_dir(&tmp).unwrap();
1814
1815        let result = prune(
1816            &path,
1817            &PruneOptions {
1818                older_than: None,
1819                max_size: Some(1),
1820                dry_run: false,
1821            },
1822        );
1823        assert!(result.is_err(), "a failing rewrite surfaces as an error");
1824        let _ = std::fs::remove_dir(&tmp);
1825    }
1826}