1use 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
34const LOG_FILE_NAME: &str = "log.jsonl";
36
37#[cfg(unix)]
41const DEFAULT_KEEP_FILES: u32 = 3;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
46#[serde(rename_all = "lowercase")]
47pub enum RecordKind {
48 #[default]
50 Invocation,
51 Http,
53 Gh,
57 Worktree,
61 #[serde(other)]
63 Unknown,
64}
65
66impl RecordKind {
67 #[must_use]
70 pub fn as_str(self) -> &'static str {
71 match self {
72 Self::Invocation => "invocation",
73 Self::Http => "http",
74 Self::Gh => "gh",
75 Self::Worktree => "worktree",
76 Self::Unknown => "unknown",
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
84#[serde(rename_all = "lowercase")]
85pub enum Source {
86 #[default]
88 Cli,
89 Mcp,
91 Daemon,
93 #[serde(other)]
95 Unknown,
96}
97
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102pub struct LogRecord {
103 #[serde(default)]
106 pub id: String,
107 #[serde(default)]
109 pub invocation_id: String,
110 #[serde(default)]
112 pub kind: RecordKind,
113 #[serde(default)]
115 pub timestamp: String,
116 #[serde(default)]
118 pub hostname: String,
119 #[serde(default)]
121 pub pid: u32,
122 #[serde(default)]
124 pub omni_dev_version: String,
125 #[serde(default)]
127 pub cwd: String,
128 #[serde(default)]
130 pub system_user: String,
131
132 #[serde(default, skip_serializing_if = "Vec::is_empty")]
135 pub command: Vec<String>,
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
138 pub command_line: Vec<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub exit_code: Option<i32>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub duration_ms: Option<u64>,
145 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
147 pub env: BTreeMap<String, String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub source: Option<Source>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub mcp_tool: Option<String>,
154
155 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub service: Option<String>,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub method: Option<String>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub url: Option<String>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub status_code: Option<u16>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub elapsed_ms: Option<u64>,
171 #[serde(default, skip_serializing_if = "is_false")]
173 pub via_daemon: bool,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub daemon_session_id: Option<String>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub auth_principal: Option<String>,
181 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
183 pub request_headers: BTreeMap<String, String>,
184 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
186 pub response_headers: BTreeMap<String, String>,
187 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub request_body: Option<String>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub response_body: Option<String>,
193 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
195 pub context: BTreeMap<String, String>,
196
197 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub error: Option<String>,
201}
202
203#[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool {
206 !*b
207}
208
209impl LogRecord {
210 fn new(kind: RecordKind, invocation_id: String) -> Self {
212 Self {
213 id: new_id(),
214 invocation_id,
215 kind,
216 timestamp: now_rfc3339_millis(),
217 hostname: hostname(),
218 pid: std::process::id(),
219 omni_dev_version: crate::VERSION.to_string(),
220 cwd: cwd(),
221 system_user: system_user(),
222 ..Self::default()
223 }
224 }
225}
226
227#[derive(Debug, Clone)]
233pub struct RequestLogContext {
234 pub invocation_id: String,
236 pub source: Source,
238 pub mcp_tool: Option<String>,
240}
241
242impl Default for RequestLogContext {
243 fn default() -> Self {
244 Self {
245 invocation_id: new_id(),
246 source: Source::Cli,
247 mcp_tool: None,
248 }
249 }
250}
251
252impl RequestLogContext {
253 pub fn cli() -> Self {
255 Self {
256 invocation_id: new_id(),
257 source: Source::Cli,
258 mcp_tool: None,
259 }
260 }
261
262 pub fn mcp(tool: impl Into<String>) -> Self {
264 Self {
265 invocation_id: new_id(),
266 source: Source::Mcp,
267 mcp_tool: Some(tool.into()),
268 }
269 }
270}
271
272static GLOBAL: OnceLock<RequestLogContext> = OnceLock::new();
273
274tokio::task_local! {
275 pub static CTX: RequestLogContext;
277}
278
279pub fn set_global(ctx: RequestLogContext) {
282 let _ = GLOBAL.set(ctx);
283}
284
285pub fn current_context() -> RequestLogContext {
288 if let Ok(ctx) = CTX.try_with(RequestLogContext::clone) {
289 return ctx;
290 }
291 if let Some(ctx) = GLOBAL.get() {
292 return ctx.clone();
293 }
294 RequestLogContext::default()
295}
296
297pub async fn scope_origin_id<F, T>(origin_id: String, fut: F) -> T
307where
308 F: std::future::Future<Output = T>,
309{
310 let mut ctx = current_context();
311 ctx.invocation_id = origin_id;
312 CTX.scope(ctx, fut).await
313}
314
315pub fn disabled() -> bool {
317 env_flag("OMNI_DEV_LOG_DISABLE")
318}
319
320pub fn bodies_enabled() -> bool {
322 env_flag("OMNI_DEV_LOG_BODIES")
323}
324
325pub fn headers_enabled() -> bool {
327 env_flag("OMNI_DEV_LOG_HEADERS")
328}
329
330fn env_flag(name: &str) -> bool {
332 std::env::var(name).is_ok_and(|v| {
333 let v = v.trim().to_ascii_lowercase();
334 v == "1" || v == "true" || v == "yes"
335 })
336}
337
338pub fn log_file_path() -> Option<PathBuf> {
341 if let Ok(path) = std::env::var("OMNI_DEV_LOG_FILE") {
342 if !path.is_empty() {
343 return Some(PathBuf::from(path));
344 }
345 }
346 let base = dirs::state_dir().or_else(dirs::data_dir)?;
347 Some(base.join("omni-dev").join(LOG_FILE_NAME))
348}
349
350pub fn record(entry: &LogRecord) {
353 if disabled() {
354 return;
355 }
356 if let Err(e) = try_record(entry) {
357 tracing::debug!("request_log: failed to append record: {e}");
358 }
359}
360
361fn try_record(entry: &LogRecord) -> anyhow::Result<()> {
363 use anyhow::Context;
364
365 let path = log_file_path().context("could not resolve the log file path")?;
366 if let Some(parent) = path.parent() {
370 if !parent.as_os_str().is_empty() && !parent.exists() {
371 crate::daemon::paths::ensure_dir_0700(parent)?;
372 }
373 }
374 let mut line = serde_json::to_string(entry).context("failed to serialize record")?;
375 line.push('\n');
376 append_line(&path, &line)?;
377 Ok(())
378}
379
380#[cfg(unix)]
388fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
389 use std::os::unix::fs::OpenOptionsExt;
390
391 if let Some(cfg) = rotation_config() {
394 return append_with_rotation(path, line, &cfg);
395 }
396
397 let file = std::fs::OpenOptions::new()
398 .append(true)
399 .create(true)
400 .mode(0o600)
401 .open(path)?;
402 crate::daemon::paths::ensure_handle_0600(&file)?;
403
404 if bodies_enabled() {
405 match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive) {
406 Ok(mut guard) => {
407 guard.write_all(line.as_bytes())?;
408 }
409 Err((mut file, _)) => {
410 file.write_all(line.as_bytes())?;
411 }
412 }
413 } else {
414 let mut file = file;
415 file.write_all(line.as_bytes())?;
416 }
417 Ok(())
418}
419
420#[cfg(not(unix))]
424fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
425 let mut file = std::fs::OpenOptions::new()
426 .append(true)
427 .create(true)
428 .open(path)?;
429 file.write_all(line.as_bytes())?;
430 Ok(())
431}
432
433fn sibling(path: &Path, suffix: &str) -> PathBuf {
447 let mut name = path.as_os_str().to_owned();
448 name.push(suffix);
449 PathBuf::from(name)
450}
451
452pub(crate) fn parse_size(s: &str) -> anyhow::Result<u64> {
456 use anyhow::Context as _;
457
458 let lower = s.trim().to_ascii_lowercase();
459 if lower.is_empty() {
460 anyhow::bail!("empty size (expected e.g. 10mb, 512kb, 1048576)");
461 }
462 let split = lower
463 .find(|c: char| !c.is_ascii_digit() && c != '.')
464 .unwrap_or(lower.len());
465 let (num, unit) = lower.split_at(split);
466 let value: f64 = num
467 .parse()
468 .with_context(|| format!("invalid size number: {s}"))?;
469 if !value.is_finite() || value < 0.0 {
470 anyhow::bail!("invalid size: {s}");
471 }
472 let mult: u64 = match unit.trim() {
473 "" | "b" => 1,
474 "k" | "kb" | "kib" => 1024,
475 "m" | "mb" | "mib" => 1024 * 1024,
476 "g" | "gb" | "gib" => 1024 * 1024 * 1024,
477 other => anyhow::bail!("invalid size unit: {other} (use b, kb, mb, or gb)"),
478 };
479 Ok((value * mult as f64) as u64)
480}
481
482#[cfg(unix)]
486struct RotationConfig {
487 max_size: u64,
489 keep_files: u32,
491}
492
493#[cfg(unix)]
497fn rotation_config() -> Option<RotationConfig> {
498 let raw = std::env::var("OMNI_DEV_LOG_MAX_SIZE").ok()?;
499 if raw.trim().is_empty() {
500 return None;
501 }
502 let max_size = match parse_size(&raw) {
503 Ok(0) => return None,
504 Ok(n) => n,
505 Err(e) => {
506 tracing::debug!("request_log: ignoring invalid OMNI_DEV_LOG_MAX_SIZE: {e}");
507 return None;
508 }
509 };
510 let keep_files = std::env::var("OMNI_DEV_LOG_KEEP_FILES")
511 .ok()
512 .and_then(|v| v.trim().parse::<u32>().ok())
513 .unwrap_or(DEFAULT_KEEP_FILES);
514 Some(RotationConfig {
515 max_size,
516 keep_files,
517 })
518}
519
520#[cfg(unix)]
524fn rotate(path: &Path, keep_files: u32) -> anyhow::Result<()> {
525 if keep_files == 0 {
526 let _ = std::fs::remove_file(path);
529 return Ok(());
530 }
531 let _ = std::fs::remove_file(sibling(path, &format!(".{keep_files}")));
533 for i in (1..keep_files).rev() {
534 let from = sibling(path, &format!(".{i}"));
535 if from.exists() {
536 std::fs::rename(&from, sibling(path, &format!(".{}", i + 1)))?;
537 }
538 }
539 std::fs::rename(path, sibling(path, ".1"))?;
540 Ok(())
541}
542
543#[cfg(unix)]
549fn append_with_rotation(path: &Path, line: &str, cfg: &RotationConfig) -> anyhow::Result<()> {
550 use std::os::unix::fs::OpenOptionsExt;
551
552 let lock_path = sibling(path, ".lock");
553 let lock_file = std::fs::OpenOptions::new()
554 .create(true)
555 .write(true)
556 .truncate(false)
557 .mode(0o600)
558 .open(&lock_path)?;
559 crate::daemon::paths::ensure_handle_0600(&lock_file)?;
560 let _guard = nix::fcntl::Flock::lock(lock_file, nix::fcntl::FlockArg::LockExclusive).ok();
563
564 let current = std::fs::metadata(path).map_or(0, |m| m.len());
565 if current > 0 && current.saturating_add(line.len() as u64) > cfg.max_size {
566 if let Err(e) = rotate(path, cfg.keep_files) {
567 tracing::debug!("request_log: rotation failed, appending without rotating: {e}");
568 }
569 }
570
571 let mut file = std::fs::OpenOptions::new()
572 .append(true)
573 .create(true)
574 .mode(0o600)
575 .open(path)?;
576 crate::daemon::paths::ensure_handle_0600(&file)?;
577 file.write_all(line.as_bytes())?;
578 Ok(())
579}
580
581pub struct PruneOptions {
583 pub older_than: Option<DateTime<Utc>>,
587 pub max_size: Option<u64>,
590 pub dry_run: bool,
592}
593
594pub struct PruneOutcome {
596 pub removed: usize,
598 pub kept: usize,
600 pub bytes_before: u64,
602 pub bytes_after: u64,
604}
605
606pub fn prune(path: &Path, opts: &PruneOptions) -> anyhow::Result<PruneOutcome> {
615 use anyhow::Context as _;
616
617 let data = match std::fs::read(path) {
618 Ok(data) => data,
619 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
620 return Ok(PruneOutcome {
621 removed: 0,
622 kept: 0,
623 bytes_before: 0,
624 bytes_after: 0,
625 });
626 }
627 Err(e) => return Err(e).context("failed to read the log file"),
628 };
629 let bytes_before = data.len() as u64;
630 let text = String::from_utf8_lossy(&data);
631
632 let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
635 let aged: Vec<&str> = all
636 .iter()
637 .copied()
638 .filter(|line| keep_by_age(line, opts.older_than))
639 .collect();
640
641 let kept: &[&str] = match opts.max_size {
642 None => &aged,
643 Some(max) => keep_by_size(&aged, max),
644 };
645
646 let bytes_after: u64 = kept.iter().map(|l| l.len() as u64 + 1).sum();
647 let outcome = PruneOutcome {
648 removed: all.len() - kept.len(),
649 kept: kept.len(),
650 bytes_before,
651 bytes_after,
652 };
653
654 if !opts.dry_run && outcome.removed > 0 {
655 rewrite_atomically(path, kept)?;
656 }
657 Ok(outcome)
658}
659
660fn keep_by_age(line: &str, older_than: Option<DateTime<Utc>>) -> bool {
663 let Some(cutoff) = older_than else {
664 return true;
665 };
666 match serde_json::from_str::<LogRecord>(line) {
667 Ok(rec) => match DateTime::parse_from_rfc3339(&rec.timestamp) {
668 Ok(ts) => ts.with_timezone(&Utc) >= cutoff,
669 Err(_) => true,
670 },
671 Err(_) => true,
672 }
673}
674
675fn keep_by_size<'a>(lines: &'a [&'a str], max: u64) -> &'a [&'a str] {
678 let mut acc = 0u64;
679 let mut start = lines.len();
680 for (i, line) in lines.iter().enumerate().rev() {
681 acc += line.len() as u64 + 1;
682 if acc > max {
683 break;
684 }
685 start = i;
686 }
687 if start == lines.len() && !lines.is_empty() {
688 start = lines.len() - 1; }
690 &lines[start..]
691}
692
693fn rewrite_atomically(path: &Path, lines: &[&str]) -> anyhow::Result<()> {
696 let tmp = sibling(path, &format!(".prune.{}.tmp", std::process::id()));
697 let result = (|| -> anyhow::Result<()> {
698 let mut options = std::fs::OpenOptions::new();
699 options.create(true).write(true).truncate(true);
700 #[cfg(unix)]
701 {
702 use std::os::unix::fs::OpenOptionsExt;
703 options.mode(0o600);
704 }
705 let mut file = options.open(&tmp)?;
706 #[cfg(unix)]
707 crate::daemon::paths::ensure_handle_0600(&file)?;
708 for line in lines {
709 file.write_all(line.as_bytes())?;
710 file.write_all(b"\n")?;
711 }
712 file.flush()?;
713 std::fs::rename(&tmp, path)?;
714 Ok(())
715 })();
716 if result.is_err() {
717 let _ = std::fs::remove_file(&tmp);
718 }
719 result
720}
721
722#[derive(Debug, Clone)]
724pub struct InvocationOutcome {
725 pub command: Vec<String>,
727 pub command_line: Vec<String>,
729 pub exit_code: i32,
731 pub error: Option<String>,
733 pub duration: Duration,
735}
736
737pub fn record_invocation(outcome: InvocationOutcome) {
739 let ctx = current_context();
740 let mut rec = LogRecord::new(RecordKind::Invocation, ctx.invocation_id);
741 rec.source = Some(ctx.source);
742 rec.mcp_tool = ctx.mcp_tool;
743 rec.command = outcome.command;
744 rec.command_line = scrub_argv(&outcome.command_line);
745 rec.exit_code = Some(outcome.exit_code);
746 rec.error = outcome.error;
747 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
748 rec.env = whitelisted_env();
749 record(&rec);
750}
751
752#[derive(Debug, Clone)]
755pub struct GhOutcome {
756 pub label: String,
759 pub argv: Vec<String>,
761 pub exit_code: Option<i32>,
763 pub duration: Duration,
765 pub error: Option<String>,
767}
768
769pub fn record_gh(outcome: GhOutcome) {
774 record(&build_gh_record(outcome, current_context()));
775}
776
777fn build_gh_record(outcome: GhOutcome, ctx: RequestLogContext) -> LogRecord {
781 let mut rec = LogRecord::new(RecordKind::Gh, ctx.invocation_id);
782 rec.source = Some(ctx.source);
783 rec.mcp_tool = ctx.mcp_tool;
784 rec.command = outcome
785 .label
786 .split(' ')
787 .filter(|s| !s.is_empty())
788 .map(str::to_string)
789 .collect();
790 rec.command_line = scrub_argv(&outcome.argv);
794 rec.exit_code = outcome.exit_code;
795 rec.error = outcome.error;
796 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
797 rec
798}
799
800#[derive(Debug, Clone)]
803pub struct WorktreeOutcome {
804 pub verb: String,
808 pub argv: Vec<String>,
811 pub exit_code: Option<i32>,
813 pub duration: Duration,
815 pub error: Option<String>,
817 pub context: BTreeMap<String, String>,
820}
821
822pub fn record_worktree(outcome: WorktreeOutcome) {
828 record(&build_worktree_record(outcome, current_context()));
829}
830
831fn build_worktree_record(outcome: WorktreeOutcome, ctx: RequestLogContext) -> LogRecord {
836 let mut rec = LogRecord::new(RecordKind::Worktree, ctx.invocation_id);
837 rec.source = Some(ctx.source);
838 rec.mcp_tool = ctx.mcp_tool;
839 rec.service = Some("worktree".to_string());
841 rec.command = vec!["git".to_string(), "worktree".to_string(), outcome.verb];
842 rec.command_line = scrub_argv(&outcome.argv);
843 rec.exit_code = outcome.exit_code;
844 rec.error = outcome.error;
845 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
846 rec.context = outcome.context;
847 rec
848}
849
850#[derive(Debug, Clone, Default)]
853pub struct HttpExtra {
854 pub via_daemon: bool,
856 pub daemon_session_id: Option<String>,
858 pub auth_principal: Option<String>,
860 pub request_headers: BTreeMap<String, String>,
862 pub response_headers: BTreeMap<String, String>,
864 pub request_body: Option<String>,
866 pub response_body: Option<String>,
868 pub context: BTreeMap<String, String>,
870}
871
872pub fn record_http(
874 service: &str,
875 method: &str,
876 url: &str,
877 started: Instant,
878 status: Option<u16>,
879 error: Option<&str>,
880) {
881 record_http_with(
882 service,
883 method,
884 url,
885 started,
886 status,
887 error,
888 HttpExtra::default(),
889 );
890}
891
892pub fn record_http_result(
898 service: &str,
899 method: &str,
900 url: &str,
901 started: Instant,
902 result: &reqwest::Result<reqwest::Response>,
903) {
904 match result {
905 Ok(response) => {
906 record_http(
907 service,
908 method,
909 url,
910 started,
911 Some(response.status().as_u16()),
912 None,
913 );
914 }
915 Err(error) => {
916 record_http(
917 service,
918 method,
919 url,
920 started,
921 None,
922 Some(&error.to_string()),
923 );
924 }
925 }
926}
927
928#[allow(clippy::too_many_arguments)]
935pub fn record_http_with(
936 service: &str,
937 method: &str,
938 url: &str,
939 started: Instant,
940 status: Option<u16>,
941 error: Option<&str>,
942 extra: HttpExtra,
943) {
944 if disabled() {
945 return;
946 }
947 let ctx = current_context();
948 let mut rec = LogRecord::new(RecordKind::Http, ctx.invocation_id);
949 rec.source = Some(ctx.source);
950 rec.mcp_tool = ctx.mcp_tool;
951 rec.service = Some(service.to_string());
952 rec.method = Some(method.to_string());
953 rec.url = Some(redact_url(url));
954 rec.status_code = status;
955 rec.elapsed_ms = Some(started.elapsed().as_millis() as u64);
956 rec.error = error.map(str::to_string);
957 rec.via_daemon = extra.via_daemon;
958 rec.daemon_session_id = extra.daemon_session_id;
959 rec.auth_principal = extra.auth_principal;
960 rec.context = extra.context;
961 if headers_enabled() {
962 rec.request_headers = redact_headers(&extra.request_headers);
963 rec.response_headers = redact_headers(&extra.response_headers);
964 }
965 if bodies_enabled() {
966 rec.request_body = extra.request_body;
967 rec.response_body = extra.response_body;
968 }
969 record(&rec);
970}
971
972const SENSITIVE_HEADERS: &[&str] = &[
974 "authorization",
975 "proxy-authorization",
976 "cookie",
977 "set-cookie",
978 "x-api-key",
979 "api-key",
980 "dd-api-key",
981 "dd-application-key",
982 "x-datadog-api-key",
983 "x-datadog-application-key",
984 "x-omni-bridge",
985 "x-omni-bridge-target",
986];
987
988const SENSITIVE_HEADER_MARKERS: &[&str] = &[
992 "auth",
993 "token",
994 "secret",
995 "key",
996 "cookie",
997 "password",
998 "session",
999 "signature",
1000 "credential",
1001];
1002
1003pub fn redact_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
1008 headers
1009 .iter()
1010 .map(|(name, value)| {
1011 let lower = name.to_ascii_lowercase();
1012 let redacted = SENSITIVE_HEADERS.contains(&lower.as_str())
1013 || SENSITIVE_HEADER_MARKERS
1014 .iter()
1015 .any(|marker| lower.contains(marker));
1016 (
1017 name.clone(),
1018 if redacted {
1019 "REDACTED".to_string()
1020 } else {
1021 value.clone()
1022 },
1023 )
1024 })
1025 .collect()
1026}
1027
1028const SECRETISH_FLAG_WORDS: &[&str] = &["token", "secret", "password", "passwd", "key"];
1032
1033fn is_secretish_flag(name: &str) -> bool {
1037 let segments: Vec<String> = name
1038 .split(['-', '_'])
1039 .map(str::to_ascii_lowercase)
1040 .collect();
1041 let takes_path = matches!(segments.last().map(String::as_str), Some("file" | "path"));
1042 !takes_path
1043 && segments
1044 .iter()
1045 .any(|segment| SECRETISH_FLAG_WORDS.contains(&segment.as_str()))
1046}
1047
1048fn scrub_header_arg(value: &str) -> Option<String> {
1052 let Some((name, _)) = value.split_once(':') else {
1053 return Some("REDACTED".to_string());
1054 };
1055 SENSITIVE_HEADERS
1056 .contains(&name.trim().to_ascii_lowercase().as_str())
1057 .then(|| format!("{}: REDACTED", name.trim()))
1058}
1059
1060fn scrub_flag_value(name: &str, value: &str) -> Option<String> {
1064 match name {
1065 "header" => scrub_header_arg(value),
1066 "body" => (!value.starts_with('@')).then(|| "REDACTED".to_string()),
1067 _ if is_secretish_flag(name) => Some("REDACTED".to_string()),
1068 _ => None,
1069 }
1070}
1071
1072fn scrub_argv(argv: &[String]) -> Vec<String> {
1084 scrub_flag_secrets(argv)
1085 .iter()
1086 .map(|arg| redact_url(arg))
1087 .collect()
1088}
1089
1090fn scrub_flag_secrets(argv: &[String]) -> Vec<String> {
1095 let mut out = Vec::with_capacity(argv.len());
1096 let mut i = 0;
1097 while i < argv.len() {
1098 let arg = &argv[i];
1099 i += 1;
1100 let Some(flag_body) = arg.strip_prefix("--") else {
1101 out.push(arg.clone());
1102 continue;
1103 };
1104 if let Some((name, value)) = flag_body.split_once('=') {
1105 match scrub_flag_value(name, value) {
1106 Some(scrubbed) => out.push(format!("--{name}={scrubbed}")),
1107 None => out.push(arg.clone()),
1108 }
1109 } else {
1110 out.push(arg.clone());
1111 let takes_secret_value =
1112 matches!(flag_body, "header" | "body") || is_secretish_flag(flag_body);
1113 if takes_secret_value {
1114 if let Some(value) = argv.get(i) {
1115 i += 1;
1116 out.push(scrub_flag_value(flag_body, value).unwrap_or_else(|| value.clone()));
1117 }
1118 }
1119 }
1120 }
1121 out
1122}
1123
1124const SENSITIVE_QUERY_KEYS: &[&str] = &["sig", "sas", "jwt", "auth"];
1126
1127const SENSITIVE_QUERY_KEY_SUFFIXES: &[&str] = &[
1130 "token",
1131 "secret",
1132 "password",
1133 "passwd",
1134 "signature",
1135 "apikey",
1136 "api_key",
1137 "api-key",
1138];
1139
1140const SENSITIVE_QUERY_KEY_PREFIXES: &[&str] = &["x-amz-", "x-goog-"];
1142
1143fn sensitive_query_key(key: &str) -> bool {
1145 let key = key.to_ascii_lowercase();
1146 SENSITIVE_QUERY_KEYS.contains(&key.as_str())
1147 || SENSITIVE_QUERY_KEY_SUFFIXES
1148 .iter()
1149 .any(|suffix| key.ends_with(suffix))
1150 || SENSITIVE_QUERY_KEY_PREFIXES
1151 .iter()
1152 .any(|prefix| key.starts_with(prefix))
1153}
1154
1155fn redact_pairs(pairs: &str) -> String {
1159 pairs
1160 .split('&')
1161 .map(|segment| match segment.split_once('=') {
1162 Some((raw_key, _)) => {
1163 let sensitive = url::form_urlencoded::parse(raw_key.as_bytes())
1166 .next()
1167 .is_some_and(|(key, _)| sensitive_query_key(&key));
1168 if sensitive {
1169 format!("{raw_key}=REDACTED")
1170 } else {
1171 segment.to_string()
1172 }
1173 }
1174 None => segment.to_string(),
1176 })
1177 .collect::<Vec<_>>()
1178 .join("&")
1179}
1180
1181fn redact_url(url: &str) -> String {
1187 let (rest, fragment) = url
1188 .split_once('#')
1189 .map_or((url, None), |(rest, fragment)| (rest, Some(fragment)));
1190 let (prefix, query) = rest
1191 .split_once('?')
1192 .map_or((rest, None), |(prefix, query)| (prefix, Some(query)));
1193 let mut out = prefix.to_string();
1194 if let Some(query) = query {
1195 out.push('?');
1196 out.push_str(&redact_pairs(query));
1197 }
1198 if let Some(fragment) = fragment {
1199 out.push('#');
1200 out.push_str(&redact_pairs(fragment));
1201 }
1202 out
1203}
1204
1205pub fn new_id() -> String {
1211 let millis = chrono::Utc::now().timestamp_millis().max(0);
1212 let suffix = rand::random::<u64>();
1213 format!("{millis:013}-{suffix:016x}")
1214}
1215
1216fn now_rfc3339_millis() -> String {
1218 chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
1219}
1220
1221fn cwd() -> String {
1223 std::env::current_dir()
1224 .map(|p| p.display().to_string())
1225 .unwrap_or_default()
1226}
1227
1228fn system_user() -> String {
1230 if let Ok(user) = std::env::var("USER") {
1231 if !user.is_empty() {
1232 return user;
1233 }
1234 }
1235 #[cfg(unix)]
1236 {
1237 if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid()) {
1238 return user.name;
1239 }
1240 }
1241 String::new()
1242}
1243
1244fn hostname() -> String {
1246 #[cfg(unix)]
1247 {
1248 if let Ok(name) = nix::unistd::gethostname() {
1249 if let Some(name) = name.to_str() {
1250 if !name.is_empty() {
1251 return name.to_string();
1252 }
1253 }
1254 }
1255 }
1256 std::env::var("HOSTNAME").unwrap_or_default()
1257}
1258
1259const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
1262
1263fn whitelisted_env() -> BTreeMap<String, String> {
1265 std::env::vars()
1266 .filter(|(k, _)| k.starts_with("OMNI_DEV_"))
1267 .map(|(k, v)| {
1268 let secretish = SECRETISH.iter().any(|needle| k.contains(needle));
1269 let value = if secretish { "REDACTED".to_string() } else { v };
1270 (k, value)
1271 })
1272 .collect()
1273}
1274
1275#[cfg(test)]
1276#[allow(clippy::unwrap_used, clippy::expect_used)]
1277mod tests {
1278 use super::*;
1279
1280 #[test]
1281 fn record_round_trips_through_json() {
1282 let mut rec = LogRecord::new(RecordKind::Http, "inv-1".to_string());
1283 rec.service = Some("jira".to_string());
1284 rec.method = Some("GET".to_string());
1285 rec.url = Some("https://example.atlassian.net/rest/api/3/issue/X-1".to_string());
1286 rec.status_code = Some(200);
1287 rec.elapsed_ms = Some(42);
1288
1289 let line = serde_json::to_string(&rec).unwrap();
1290 let back: LogRecord = serde_json::from_str(&line).unwrap();
1291 assert_eq!(back.invocation_id, "inv-1");
1292 assert_eq!(back.kind, RecordKind::Http);
1293 assert_eq!(back.service.as_deref(), Some("jira"));
1294 assert_eq!(back.status_code, Some(200));
1295 }
1296
1297 #[test]
1298 fn reader_tolerates_unknown_fields() {
1299 let line = r#"{"id":"x","invocation_id":"i","kind":"http","method":"GET",
1300 "future_field":{"nested":true},"another":42}"#;
1301 let rec: LogRecord = serde_json::from_str(line).unwrap();
1302 assert_eq!(rec.kind, RecordKind::Http);
1303 assert_eq!(rec.method.as_deref(), Some("GET"));
1304 }
1305
1306 #[test]
1307 fn reader_tolerates_missing_newer_fields() {
1308 let line = r#"{"kind":"invocation","command":["git","view"]}"#;
1310 let rec: LogRecord = serde_json::from_str(line).unwrap();
1311 assert_eq!(rec.kind, RecordKind::Invocation);
1312 assert_eq!(rec.command, vec!["git", "view"]);
1313 assert!(rec.status_code.is_none());
1314 assert!(rec.id.is_empty());
1315 }
1316
1317 #[test]
1318 fn unknown_kind_and_source_do_not_fail() {
1319 let line = r#"{"kind":"telemetry","source":"webhook"}"#;
1320 let rec: LogRecord = serde_json::from_str(line).unwrap();
1321 assert_eq!(rec.kind, RecordKind::Unknown);
1322 assert_eq!(rec.source, Some(Source::Unknown));
1323 }
1324
1325 #[test]
1326 fn optional_fields_are_skipped_when_empty() {
1327 let rec = LogRecord::new(RecordKind::Invocation, "i".to_string());
1328 let line = serde_json::to_string(&rec).unwrap();
1329 assert!(!line.contains("status_code"));
1331 assert!(!line.contains("request_headers"));
1332 assert!(!line.contains("via_daemon"));
1333 assert!(!line.contains("\"env\""));
1334 }
1335
1336 #[test]
1337 fn ids_are_time_sortable() {
1338 let a = new_id();
1339 std::thread::sleep(std::time::Duration::from_millis(2));
1340 let b = new_id();
1341 assert!(a < b, "{a} should sort before {b}");
1342 }
1343
1344 #[test]
1345 fn sensitive_headers_are_redacted() {
1346 let mut headers = BTreeMap::new();
1347 headers.insert("Authorization".to_string(), "Bearer secret".to_string());
1348 headers.insert("X-Api-Key".to_string(), "abc123".to_string());
1349 headers.insert("Content-Type".to_string(), "application/json".to_string());
1350 let out = redact_headers(&headers);
1351 assert_eq!(out["Authorization"], "REDACTED");
1352 assert_eq!(out["X-Api-Key"], "REDACTED");
1353 assert_eq!(out["Content-Type"], "application/json");
1354 }
1355
1356 fn argv(args: &[&str]) -> Vec<String> {
1357 args.iter().copied().map(String::from).collect()
1358 }
1359
1360 #[test]
1361 fn build_gh_record_stamps_kind_source_and_split_command() {
1362 let ctx = RequestLogContext {
1363 invocation_id: "inv-1".to_string(),
1364 source: Source::Daemon,
1365 mcp_tool: None,
1366 };
1367 let rec = build_gh_record(
1368 GhOutcome {
1369 label: "api graphql".to_string(),
1370 argv: argv(&["api", "graphql", "-f", "query=xyz"]),
1371 exit_code: Some(0),
1372 duration: Duration::from_millis(120),
1373 error: None,
1374 },
1375 ctx,
1376 );
1377 assert_eq!(rec.kind, RecordKind::Gh);
1378 assert_eq!(rec.invocation_id, "inv-1");
1379 assert_eq!(rec.source, Some(Source::Daemon));
1380 assert_eq!(rec.command, argv(&["api", "graphql"]));
1382 assert_eq!(
1383 rec.command_line,
1384 argv(&["api", "graphql", "-f", "query=xyz"])
1385 );
1386 assert_eq!(rec.exit_code, Some(0));
1387 assert_eq!(rec.duration_ms, Some(120));
1388 assert!(rec.error.is_none());
1389 }
1390
1391 #[test]
1392 fn build_gh_record_scrubs_secret_bearing_argv() {
1393 let rec = build_gh_record(
1396 GhOutcome {
1397 label: "api graphql".to_string(),
1398 argv: argv(&["api", "--header", "Authorization: Bearer sekret"]),
1399 exit_code: Some(0),
1400 duration: Duration::from_millis(5),
1401 error: None,
1402 },
1403 RequestLogContext::default(),
1404 );
1405 assert_eq!(
1406 rec.command_line,
1407 argv(&["api", "--header", "Authorization: REDACTED"])
1408 );
1409 }
1410
1411 #[test]
1412 fn build_worktree_record_stamps_kind_service_command_and_context() {
1413 let ctx = RequestLogContext {
1414 invocation_id: "inv-2".to_string(),
1415 source: Source::Mcp,
1416 mcp_tool: Some("some_tool".to_string()),
1417 };
1418 let mut context = BTreeMap::new();
1419 context.insert("path".to_string(), "/tmp/wt".to_string());
1420 context.insert("branch".to_string(), "demo-wt".to_string());
1421 context.insert("had_uncommitted".to_string(), "true".to_string());
1422 let rec = build_worktree_record(
1423 WorktreeOutcome {
1424 verb: "remove".to_string(),
1425 argv: argv(&["worktree", "remove", "--force", "/tmp/wt"]),
1426 exit_code: Some(0),
1427 duration: Duration::from_millis(42),
1428 error: None,
1429 context,
1430 },
1431 ctx,
1432 );
1433 assert_eq!(rec.kind, RecordKind::Worktree);
1434 assert_eq!(rec.invocation_id, "inv-2");
1435 assert_eq!(rec.source, Some(Source::Mcp));
1436 assert_eq!(rec.mcp_tool.as_deref(), Some("some_tool"));
1437 assert_eq!(rec.service.as_deref(), Some("worktree"));
1438 assert_eq!(rec.command, argv(&["git", "worktree", "remove"]));
1439 assert_eq!(
1440 rec.command_line,
1441 argv(&["worktree", "remove", "--force", "/tmp/wt"])
1442 );
1443 assert_eq!(rec.exit_code, Some(0));
1444 assert_eq!(rec.duration_ms, Some(42));
1445 assert_eq!(
1446 rec.context.get("branch").map(String::as_str),
1447 Some("demo-wt")
1448 );
1449 assert_eq!(
1450 rec.context.get("had_uncommitted").map(String::as_str),
1451 Some("true")
1452 );
1453 }
1454
1455 #[test]
1456 fn record_kind_worktree_serializes_as_worktree_and_round_trips() {
1457 let rec = build_worktree_record(
1458 WorktreeOutcome {
1459 verb: "add".to_string(),
1460 argv: argv(&["worktree", "add", "wt"]),
1461 exit_code: Some(1),
1462 duration: Duration::from_millis(1),
1463 error: Some("boom".to_string()),
1464 context: BTreeMap::new(),
1465 },
1466 RequestLogContext::default(),
1467 );
1468 let line = serde_json::to_string(&rec).unwrap();
1469 assert!(line.contains("\"kind\":\"worktree\""), "line was: {line}");
1470 assert!(
1471 line.contains("\"service\":\"worktree\""),
1472 "line was: {line}"
1473 );
1474 assert_eq!(RecordKind::Worktree.as_str(), "worktree");
1476 let back: LogRecord = serde_json::from_str(&line).unwrap();
1477 assert_eq!(back.kind, RecordKind::Worktree);
1478 assert_eq!(back.command, argv(&["git", "worktree", "add"]));
1479 assert_eq!(back.error.as_deref(), Some("boom"));
1480 }
1481
1482 #[test]
1483 fn record_kind_gh_serializes_as_gh_and_round_trips() {
1484 let rec = build_gh_record(
1485 GhOutcome {
1486 label: "pr list".to_string(),
1487 argv: argv(&["pr", "list"]),
1488 exit_code: Some(1),
1489 duration: Duration::from_millis(1),
1490 error: Some("boom".to_string()),
1491 },
1492 RequestLogContext::default(),
1493 );
1494 let line = serde_json::to_string(&rec).unwrap();
1495 assert!(line.contains("\"kind\":\"gh\""), "line was: {line}");
1496 let back: LogRecord = serde_json::from_str(&line).unwrap();
1497 assert_eq!(back.kind, RecordKind::Gh);
1498 assert_eq!(back.command, argv(&["pr", "list"]));
1499 assert_eq!(back.error.as_deref(), Some("boom"));
1500 }
1501
1502 #[test]
1503 fn scrub_argv_redacts_sensitive_header_in_both_forms() {
1504 let out = scrub_argv(&argv(&[
1505 "omni-dev",
1506 "--header",
1507 "Authorization: Bearer sekret",
1508 "--header=Cookie: session=abc",
1509 ]));
1510 assert_eq!(
1511 out,
1512 argv(&[
1513 "omni-dev",
1514 "--header",
1515 "Authorization: REDACTED",
1516 "--header=Cookie: REDACTED",
1517 ])
1518 );
1519 }
1520
1521 #[test]
1522 fn scrub_argv_keeps_non_sensitive_headers() {
1523 let input = argv(&["omni-dev", "--header", "Content-Type: application/json"]);
1524 assert_eq!(scrub_argv(&input), input);
1525 }
1526
1527 #[test]
1528 fn scrub_argv_redacts_colonless_header_wholesale() {
1529 let out = scrub_argv(&argv(&["omni-dev", "--header", "sekret"]));
1530 assert_eq!(out, argv(&["omni-dev", "--header", "REDACTED"]));
1531 }
1532
1533 #[test]
1534 fn scrub_argv_redacts_inline_body_but_keeps_at_file() {
1535 let out = scrub_argv(&argv(&["omni-dev", "--body", r#"{"secret":1}"#]));
1536 assert_eq!(out, argv(&["omni-dev", "--body", "REDACTED"]));
1537
1538 let file_form = argv(&["omni-dev", "--body", "@payload.json"]);
1539 assert_eq!(scrub_argv(&file_form), file_form);
1540
1541 let out = scrub_argv(&argv(&["omni-dev", "--body=sekret"]));
1542 assert_eq!(out, argv(&["omni-dev", "--body=REDACTED"]));
1543 }
1544
1545 #[test]
1546 fn scrub_argv_redacts_secretish_flag_values() {
1547 let out = scrub_argv(&argv(&["omni-dev", "--api-key", "abc", "--auth-token=xyz"]));
1548 assert_eq!(
1549 out,
1550 argv(&["omni-dev", "--api-key", "REDACTED", "--auth-token=REDACTED"])
1551 );
1552 }
1553
1554 #[test]
1555 fn scrub_argv_exempts_path_flags_and_positionals() {
1556 let input = argv(&["omni-dev", "--token-file", "/tmp/t", "PROJ-123"]);
1557 assert_eq!(scrub_argv(&input), input);
1558 }
1559
1560 #[test]
1561 fn scrub_argv_redacts_secret_bearing_url_query_in_both_forms() {
1562 let space = scrub_argv(&argv(&[
1566 "omni-dev",
1567 "browser",
1568 "bridge",
1569 "request",
1570 "--url",
1571 "/api/export?access_token=hunter2&sig=deadbeef&page=3",
1572 ]));
1573 assert_eq!(
1574 *space.last().unwrap(),
1575 "/api/export?access_token=REDACTED&sig=REDACTED&page=3"
1576 );
1577
1578 let eq_form = scrub_argv(&argv(&[
1579 "omni-dev",
1580 "--url=/api/export?access_token=hunter2&page=3",
1581 ]));
1582 assert_eq!(
1583 *eq_form.last().unwrap(),
1584 "--url=/api/export?access_token=REDACTED&page=3"
1585 );
1586
1587 let positional = scrub_argv(&argv(&["omni-dev", "https://h/cb#id_token=xyz"]));
1588 assert_eq!(
1589 *positional.last().unwrap(),
1590 "https://h/cb#id_token=REDACTED"
1591 );
1592 }
1593
1594 #[test]
1595 fn scrub_argv_leaves_benign_argv_byte_identical() {
1596 let input = argv(&[
1597 "omni-dev",
1598 "browser",
1599 "bridge",
1600 "request",
1601 "--control-port",
1602 "19998",
1603 "--url",
1604 "/api/export?page=3&sort=asc",
1605 ]);
1606 assert_eq!(scrub_argv(&input), input);
1607 }
1608
1609 #[test]
1610 fn scrub_argv_handles_trailing_flag_without_value() {
1611 let input = argv(&["omni-dev", "--body"]);
1612 assert_eq!(scrub_argv(&input), input);
1613 }
1614
1615 #[cfg(unix)]
1616 #[test]
1617 fn append_line_creates_file_owner_only() {
1618 use std::os::unix::fs::PermissionsExt;
1619 let dir = tempfile::tempdir().unwrap();
1620 let path = dir.path().join("log.jsonl");
1621 append_line(&path, "{\"kind\":\"http\"}\n").unwrap();
1622 assert_eq!(
1623 std::fs::read_to_string(&path).unwrap(),
1624 "{\"kind\":\"http\"}\n"
1625 );
1626 assert_eq!(
1627 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1628 0o600
1629 );
1630 }
1631
1632 #[cfg(unix)]
1633 #[test]
1634 fn append_line_retightens_preexisting_loose_file() {
1635 use std::os::unix::fs::PermissionsExt;
1636 let dir = tempfile::tempdir().unwrap();
1637 let path = dir.path().join("log.jsonl");
1638 std::fs::write(&path, "old\n").unwrap();
1639 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1640 append_line(&path, "new\n").unwrap();
1641 assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
1642 assert_eq!(
1643 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1644 0o600
1645 );
1646 }
1647
1648 #[test]
1649 fn off_list_secretish_headers_are_redacted() {
1650 let mut headers = BTreeMap::new();
1651 for name in [
1652 "X-Auth-Token",
1653 "x-amz-security-token",
1654 "X-Goog-Api-Key",
1655 "x-csrf-token",
1656 "X-Vendor-Token",
1657 "X-Omni-Bridge",
1658 ] {
1659 headers.insert(name.to_string(), "secret-value".to_string());
1660 }
1661 for name in [
1662 "Content-Type",
1663 "Accept",
1664 "User-Agent",
1665 "x-request-id",
1666 "traceparent",
1667 ] {
1668 headers.insert(name.to_string(), "plain-value".to_string());
1669 }
1670 let out = redact_headers(&headers);
1671 assert_eq!(out["X-Auth-Token"], "REDACTED");
1672 assert_eq!(out["x-amz-security-token"], "REDACTED");
1673 assert_eq!(out["X-Goog-Api-Key"], "REDACTED");
1674 assert_eq!(out["x-csrf-token"], "REDACTED");
1675 assert_eq!(out["X-Vendor-Token"], "REDACTED");
1676 assert_eq!(out["X-Omni-Bridge"], "REDACTED");
1677 assert_eq!(out["Content-Type"], "plain-value");
1678 assert_eq!(out["Accept"], "plain-value");
1679 assert_eq!(out["User-Agent"], "plain-value");
1680 assert_eq!(out["x-request-id"], "plain-value");
1681 assert_eq!(out["traceparent"], "plain-value");
1682 }
1683
1684 #[test]
1685 fn url_without_query_is_unchanged() {
1686 assert_eq!(redact_url("https://h/p"), "https://h/p");
1687 assert_eq!(redact_url("/relative/p"), "/relative/p");
1688 }
1689
1690 #[test]
1691 fn benign_query_is_byte_identical() {
1692 let url = "https://h/p?q=a%20b&page=2&&x=y+z&keyword=k&sort_key=s&token_type=bearer";
1693 assert_eq!(redact_url(url), url);
1694 }
1695
1696 #[test]
1697 fn sensitive_query_values_are_redacted() {
1698 let url = "https://h/p?token=a&access_token=b&client_secret=c&api_key=d&x=1";
1699 assert_eq!(
1700 redact_url(url),
1701 "https://h/p?token=REDACTED&access_token=REDACTED&client_secret=REDACTED\
1702 &api_key=REDACTED&x=1"
1703 );
1704 }
1705
1706 #[test]
1707 fn presigned_s3_query_is_redacted() {
1708 let url = "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=AWS4-HMAC-SHA256\
1709 &X-Amz-Credential=AKIA%2F20260703%2Fus-east-1%2Fs3%2Faws4_request\
1710 &X-Amz-Date=20260703T000000Z&X-Amz-Expires=3600\
1711 &X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef";
1712 assert_eq!(
1713 redact_url(url),
1714 "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=REDACTED\
1715 &X-Amz-Credential=REDACTED&X-Amz-Date=REDACTED&X-Amz-Expires=REDACTED\
1716 &X-Amz-SignedHeaders=REDACTED&X-Amz-Signature=REDACTED"
1717 );
1718 }
1719
1720 #[test]
1721 fn key_matching_is_case_insensitive() {
1722 assert_eq!(
1723 redact_url("/p?TOKEN=x&Api_Key=y&X-Amz-Signature=z"),
1724 "/p?TOKEN=REDACTED&Api_Key=REDACTED&X-Amz-Signature=REDACTED"
1725 );
1726 }
1727
1728 #[test]
1729 fn repeated_sensitive_keys_are_each_redacted() {
1730 assert_eq!(redact_url("/p?sig=a&sig=b"), "/p?sig=REDACTED&sig=REDACTED");
1731 }
1732
1733 #[test]
1734 fn valueless_key_is_left_alone() {
1735 assert_eq!(redact_url("/p?token"), "/p?token");
1736 assert_eq!(redact_url("/p?token="), "/p?token=REDACTED");
1737 }
1738
1739 #[test]
1740 fn relative_url_query_is_redacted() {
1741 assert_eq!(
1742 redact_url("/api/foo?sig=abc&x=y"),
1743 "/api/foo?sig=REDACTED&x=y"
1744 );
1745 }
1746
1747 #[test]
1748 fn fragment_credentials_are_redacted() {
1749 assert_eq!(
1750 redact_url("https://h/cb#access_token=xyz&token_type=bearer"),
1751 "https://h/cb#access_token=REDACTED&token_type=bearer"
1752 );
1753 }
1754
1755 #[test]
1756 fn query_and_fragment_are_scrubbed_independently() {
1757 assert_eq!(
1758 redact_url("/p?sig=a#id_token=b"),
1759 "/p?sig=REDACTED#id_token=REDACTED"
1760 );
1761 }
1762
1763 #[test]
1764 fn question_mark_in_fragment_is_not_parsed_as_query() {
1765 assert_eq!(
1769 redact_url("https://h/p#frag?token=x"),
1770 "https://h/p#frag?token=REDACTED"
1771 );
1772 }
1773
1774 #[test]
1775 fn encoded_sensitive_key_is_decoded_before_matching() {
1776 assert_eq!(
1777 redact_url("/p?access%5Ftoken=v"),
1778 "/p?access%5Ftoken=REDACTED"
1779 );
1780 }
1781
1782 #[test]
1783 fn empty_query_is_unchanged() {
1784 assert_eq!(redact_url("https://h/p?"), "https://h/p?");
1785 assert_eq!(redact_url("https://h/p?#f"), "https://h/p?#f");
1786 }
1787
1788 #[test]
1789 fn env_flag_parses_truthy_values() {
1790 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "1");
1791 assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1792 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "TRUE");
1793 assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1794 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "0");
1795 assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1796 std::env::remove_var("OMNI_DEV_TEST_FLAG_ABC");
1797 assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1798 }
1799
1800 #[test]
1801 fn parse_size_handles_units_and_bare_bytes() {
1802 assert_eq!(parse_size("1048576").unwrap(), 1024 * 1024);
1803 assert_eq!(parse_size("512b").unwrap(), 512);
1804 assert_eq!(parse_size("10kb").unwrap(), 10 * 1024);
1805 assert_eq!(parse_size("2K").unwrap(), 2 * 1024);
1806 assert_eq!(parse_size("3mb").unwrap(), 3 * 1024 * 1024);
1807 assert_eq!(parse_size("1gb").unwrap(), 1024 * 1024 * 1024);
1808 assert_eq!(parse_size("1.5mb").unwrap(), (1.5 * 1024.0 * 1024.0) as u64);
1809 assert_eq!(parse_size(" 4mib ").unwrap(), 4 * 1024 * 1024);
1810 }
1811
1812 #[test]
1813 fn parse_size_rejects_garbage() {
1814 assert!(parse_size("").is_err());
1815 assert!(parse_size("mb").is_err());
1816 assert!(parse_size("10tb").is_err());
1817 assert!(parse_size("-5mb").is_err());
1818 }
1819
1820 #[test]
1821 fn sibling_appends_to_final_component() {
1822 let base = Path::new("/tmp/omni/log.jsonl");
1823 assert_eq!(sibling(base, ".1"), Path::new("/tmp/omni/log.jsonl.1"));
1824 assert_eq!(
1825 sibling(base, ".lock"),
1826 Path::new("/tmp/omni/log.jsonl.lock")
1827 );
1828 }
1829
1830 #[test]
1831 fn keep_by_size_keeps_most_recent_that_fit() {
1832 let lines = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"];
1834 let refs: Vec<&str> = lines.to_vec();
1835
1836 assert_eq!(keep_by_size(&refs, 22), &["cccccccccc", "dddddddddd"]);
1838 assert_eq!(keep_by_size(&refs, 1), &["dddddddddd"]);
1840 assert_eq!(keep_by_size(&refs, 10_000), &refs[..]);
1842 assert!(keep_by_size(&[], 100).is_empty());
1844 }
1845
1846 #[test]
1847 fn keep_by_age_is_conservative_on_undateable_lines() {
1848 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1849 .unwrap()
1850 .with_timezone(&Utc);
1851 let old = r#"{"kind":"http","timestamp":"2026-01-01T00:00:00.000Z"}"#;
1852 let new = r#"{"kind":"http","timestamp":"2026-12-01T00:00:00.000Z"}"#;
1853 let undated = r#"{"kind":"http"}"#;
1854 let malformed = "not json at all";
1855
1856 assert!(!keep_by_age(old, Some(cutoff)));
1857 assert!(keep_by_age(new, Some(cutoff)));
1858 assert!(keep_by_age(undated, Some(cutoff)), "undated is kept");
1859 assert!(keep_by_age(malformed, Some(cutoff)), "malformed is kept");
1860 assert!(keep_by_age(old, None), "no filter keeps everything");
1861 }
1862
1863 fn http_line(id: &str, ts: &str) -> String {
1864 format!(r#"{{"id":"{id}","kind":"http","timestamp":"{ts}"}}"#)
1865 }
1866
1867 #[test]
1868 fn prune_by_age_drops_old_records_and_rewrites_atomically() {
1869 use std::os::unix::fs::PermissionsExt;
1870
1871 let dir = tempfile::tempdir().unwrap();
1872 let path = dir.path().join("log.jsonl");
1873 let body = format!(
1874 "{}\n{}\n{}\n",
1875 http_line("1", "2026-01-01T00:00:00.000Z"),
1876 http_line("2", "2026-06-15T00:00:00.000Z"),
1877 http_line("3", "2026-12-31T00:00:00.000Z"),
1878 );
1879 std::fs::write(&path, &body).unwrap();
1880 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1881
1882 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1883 .unwrap()
1884 .with_timezone(&Utc);
1885 let outcome = prune(
1886 &path,
1887 &PruneOptions {
1888 older_than: Some(cutoff),
1889 max_size: None,
1890 dry_run: false,
1891 },
1892 )
1893 .unwrap();
1894
1895 assert_eq!(outcome.removed, 1);
1896 assert_eq!(outcome.kept, 2);
1897 let contents = std::fs::read_to_string(&path).unwrap();
1898 assert!(!contents.contains(r#""id":"1""#));
1899 assert!(contents.contains(r#""id":"2""#));
1900 assert!(contents.contains(r#""id":"3""#));
1901 assert_eq!(
1903 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1904 0o600
1905 );
1906 }
1907
1908 #[test]
1909 fn prune_dry_run_reports_without_modifying() {
1910 let dir = tempfile::tempdir().unwrap();
1911 let path = dir.path().join("log.jsonl");
1912 let body = format!(
1913 "{}\n{}\n",
1914 http_line("1", "2026-01-01T00:00:00.000Z"),
1915 http_line("2", "2026-12-31T00:00:00.000Z"),
1916 );
1917 std::fs::write(&path, &body).unwrap();
1918
1919 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1920 .unwrap()
1921 .with_timezone(&Utc);
1922 let outcome = prune(
1923 &path,
1924 &PruneOptions {
1925 older_than: Some(cutoff),
1926 max_size: None,
1927 dry_run: true,
1928 },
1929 )
1930 .unwrap();
1931
1932 assert_eq!(outcome.removed, 1);
1933 assert_eq!(std::fs::read_to_string(&path).unwrap(), body);
1935 }
1936
1937 #[test]
1938 fn prune_by_size_keeps_the_newest_that_fit() {
1939 let dir = tempfile::tempdir().unwrap();
1940 let path = dir.path().join("log.jsonl");
1941 let l1 = http_line("1", "2026-01-01T00:00:00.000Z");
1942 let l2 = http_line("2", "2026-06-15T00:00:00.000Z");
1943 let l3 = http_line("3", "2026-12-31T00:00:00.000Z");
1944 std::fs::write(&path, format!("{l1}\n{l2}\n{l3}\n")).unwrap();
1945
1946 let budget = (l2.len() + 1 + l3.len() + 1) as u64;
1948 let outcome = prune(
1949 &path,
1950 &PruneOptions {
1951 older_than: None,
1952 max_size: Some(budget),
1953 dry_run: false,
1954 },
1955 )
1956 .unwrap();
1957
1958 assert_eq!(outcome.removed, 1);
1959 assert_eq!(outcome.kept, 2);
1960 let contents = std::fs::read_to_string(&path).unwrap();
1961 assert!(!contents.contains(r#""id":"1""#));
1962 assert!(contents.contains(r#""id":"3""#));
1963 }
1964
1965 #[test]
1966 fn prune_missing_file_is_a_noop() {
1967 let dir = tempfile::tempdir().unwrap();
1968 let path = dir.path().join("absent.jsonl");
1969 let outcome = prune(
1970 &path,
1971 &PruneOptions {
1972 older_than: None,
1973 max_size: Some(1),
1974 dry_run: false,
1975 },
1976 )
1977 .unwrap();
1978 assert_eq!(outcome.removed, 0);
1979 assert_eq!(outcome.kept, 0);
1980 assert!(!path.exists());
1981 }
1982
1983 #[cfg(unix)]
1984 #[test]
1985 fn rotation_shifts_numbered_files_and_drops_the_oldest() {
1986 use std::os::unix::fs::PermissionsExt;
1987
1988 let dir = tempfile::tempdir().unwrap();
1989 let path = dir.path().join("log.jsonl");
1990 let cfg = RotationConfig {
1992 max_size: 20,
1993 keep_files: 2,
1994 };
1995
1996 let line = "0123456789012345\n"; for _ in 0..4 {
1998 append_with_rotation(&path, line, &cfg).unwrap();
1999 }
2000
2001 assert!(path.exists());
2004 assert!(sibling(&path, ".1").exists());
2005 assert!(sibling(&path, ".2").exists());
2006 assert!(!sibling(&path, ".3").exists());
2007 assert_eq!(
2009 std::fs::metadata(sibling(&path, ".1"))
2010 .unwrap()
2011 .permissions()
2012 .mode()
2013 & 0o777,
2014 0o600
2015 );
2016 }
2017
2018 #[cfg(unix)]
2019 #[test]
2020 fn rotation_keep_zero_discards_on_overflow() {
2021 let dir = tempfile::tempdir().unwrap();
2022 let path = dir.path().join("log.jsonl");
2023 let cfg = RotationConfig {
2024 max_size: 20,
2025 keep_files: 0,
2026 };
2027 let line = "0123456789012345\n"; append_with_rotation(&path, line, &cfg).unwrap();
2029 append_with_rotation(&path, line, &cfg).unwrap();
2030 assert!(!sibling(&path, ".1").exists());
2032 assert_eq!(std::fs::read_to_string(&path).unwrap(), line);
2033 }
2034
2035 #[test]
2036 fn parse_size_rejects_overflow_to_infinity() {
2037 assert!(parse_size(&"9".repeat(400)).is_err());
2039 }
2040
2041 #[test]
2042 fn prune_surfaces_a_read_error() {
2043 let dir = tempfile::tempdir().unwrap();
2046 let result = prune(
2047 dir.path(),
2048 &PruneOptions {
2049 older_than: None,
2050 max_size: Some(1),
2051 dry_run: false,
2052 },
2053 );
2054 assert!(result.is_err());
2055 }
2056
2057 #[cfg(unix)]
2058 #[test]
2059 fn append_with_rotation_appends_even_when_rotate_fails() {
2060 let dir = tempfile::tempdir().unwrap();
2061 let path = dir.path().join("log.jsonl");
2062 std::fs::write(&path, "0123456789012345\n").unwrap();
2064 std::fs::create_dir(sibling(&path, ".1")).unwrap();
2066 let cfg = RotationConfig {
2067 max_size: 5,
2068 keep_files: 1,
2069 };
2070 append_with_rotation(&path, "new-line\n", &cfg).unwrap();
2072 assert!(
2073 std::fs::read_to_string(&path).unwrap().contains("new-line"),
2074 "the record is appended despite the rotation failure"
2075 );
2076 }
2077
2078 #[test]
2079 fn prune_cleans_up_temp_on_rewrite_failure() {
2080 let dir = tempfile::tempdir().unwrap();
2081 let path = dir.path().join("log.jsonl");
2082 std::fs::write(
2083 &path,
2084 format!(
2085 "{}\n{}\n",
2086 http_line("a", "2999-01-01T00:00:00.000Z"),
2087 http_line("b", "2999-01-01T00:00:00.000Z"),
2088 ),
2089 )
2090 .unwrap();
2091 let tmp = sibling(&path, &format!(".prune.{}.tmp", std::process::id()));
2094 std::fs::create_dir(&tmp).unwrap();
2095
2096 let result = prune(
2097 &path,
2098 &PruneOptions {
2099 older_than: None,
2100 max_size: Some(1),
2101 dry_run: false,
2102 },
2103 );
2104 assert!(result.is_err(), "a failing rewrite surfaces as an error");
2105 let _ = std::fs::remove_dir(&tmp);
2106 }
2107
2108 #[tokio::test]
2109 async fn scope_origin_id_overwrites_id_but_preserves_source() {
2110 let base = RequestLogContext {
2113 invocation_id: "daemon-1".to_string(),
2114 source: Source::Daemon,
2115 mcp_tool: None,
2116 };
2117 CTX.scope(base, async {
2118 scope_origin_id("cli-42".to_string(), async {
2119 let ctx = current_context();
2120 assert_eq!(ctx.invocation_id, "cli-42");
2122 assert_eq!(ctx.source, Source::Daemon);
2124 })
2125 .await;
2126 assert_eq!(current_context().invocation_id, "daemon-1");
2128 })
2129 .await;
2130 }
2131}