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 #[serde(other)]
59 Unknown,
60}
61
62impl RecordKind {
63 #[must_use]
66 pub fn as_str(self) -> &'static str {
67 match self {
68 Self::Invocation => "invocation",
69 Self::Http => "http",
70 Self::Gh => "gh",
71 Self::Unknown => "unknown",
72 }
73 }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
79#[serde(rename_all = "lowercase")]
80pub enum Source {
81 #[default]
83 Cli,
84 Mcp,
86 Daemon,
88 #[serde(other)]
90 Unknown,
91}
92
93#[derive(Debug, Clone, Default, Serialize, Deserialize)]
97pub struct LogRecord {
98 #[serde(default)]
101 pub id: String,
102 #[serde(default)]
104 pub invocation_id: String,
105 #[serde(default)]
107 pub kind: RecordKind,
108 #[serde(default)]
110 pub timestamp: String,
111 #[serde(default)]
113 pub hostname: String,
114 #[serde(default)]
116 pub pid: u32,
117 #[serde(default)]
119 pub omni_dev_version: String,
120 #[serde(default)]
122 pub cwd: String,
123 #[serde(default)]
125 pub system_user: String,
126
127 #[serde(default, skip_serializing_if = "Vec::is_empty")]
130 pub command: Vec<String>,
131 #[serde(default, skip_serializing_if = "Vec::is_empty")]
133 pub command_line: Vec<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub exit_code: Option<i32>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub duration_ms: Option<u64>,
140 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
142 pub env: BTreeMap<String, String>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub source: Option<Source>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub mcp_tool: Option<String>,
149
150 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub service: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub method: Option<String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub url: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub status_code: Option<u16>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub elapsed_ms: Option<u64>,
166 #[serde(default, skip_serializing_if = "is_false")]
168 pub via_daemon: bool,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub daemon_session_id: Option<String>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub auth_principal: Option<String>,
176 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
178 pub request_headers: BTreeMap<String, String>,
179 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
181 pub response_headers: BTreeMap<String, String>,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub request_body: Option<String>,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub response_body: Option<String>,
188 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
190 pub context: BTreeMap<String, String>,
191
192 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub error: Option<String>,
196}
197
198#[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool {
201 !*b
202}
203
204impl LogRecord {
205 fn new(kind: RecordKind, invocation_id: String) -> Self {
207 Self {
208 id: new_id(),
209 invocation_id,
210 kind,
211 timestamp: now_rfc3339_millis(),
212 hostname: hostname(),
213 pid: std::process::id(),
214 omni_dev_version: crate::VERSION.to_string(),
215 cwd: cwd(),
216 system_user: system_user(),
217 ..Self::default()
218 }
219 }
220}
221
222#[derive(Debug, Clone)]
228pub struct RequestLogContext {
229 pub invocation_id: String,
231 pub source: Source,
233 pub mcp_tool: Option<String>,
235}
236
237impl Default for RequestLogContext {
238 fn default() -> Self {
239 Self {
240 invocation_id: new_id(),
241 source: Source::Cli,
242 mcp_tool: None,
243 }
244 }
245}
246
247impl RequestLogContext {
248 pub fn cli() -> Self {
250 Self {
251 invocation_id: new_id(),
252 source: Source::Cli,
253 mcp_tool: None,
254 }
255 }
256
257 pub fn mcp(tool: impl Into<String>) -> Self {
259 Self {
260 invocation_id: new_id(),
261 source: Source::Mcp,
262 mcp_tool: Some(tool.into()),
263 }
264 }
265}
266
267static GLOBAL: OnceLock<RequestLogContext> = OnceLock::new();
268
269tokio::task_local! {
270 pub static CTX: RequestLogContext;
272}
273
274pub fn set_global(ctx: RequestLogContext) {
277 let _ = GLOBAL.set(ctx);
278}
279
280pub fn current_context() -> RequestLogContext {
283 if let Ok(ctx) = CTX.try_with(RequestLogContext::clone) {
284 return ctx;
285 }
286 if let Some(ctx) = GLOBAL.get() {
287 return ctx.clone();
288 }
289 RequestLogContext::default()
290}
291
292pub async fn scope_origin_id<F, T>(origin_id: String, fut: F) -> T
302where
303 F: std::future::Future<Output = T>,
304{
305 let mut ctx = current_context();
306 ctx.invocation_id = origin_id;
307 CTX.scope(ctx, fut).await
308}
309
310pub fn disabled() -> bool {
312 env_flag("OMNI_DEV_LOG_DISABLE")
313}
314
315pub fn bodies_enabled() -> bool {
317 env_flag("OMNI_DEV_LOG_BODIES")
318}
319
320pub fn headers_enabled() -> bool {
322 env_flag("OMNI_DEV_LOG_HEADERS")
323}
324
325fn env_flag(name: &str) -> bool {
327 std::env::var(name).is_ok_and(|v| {
328 let v = v.trim().to_ascii_lowercase();
329 v == "1" || v == "true" || v == "yes"
330 })
331}
332
333pub fn log_file_path() -> Option<PathBuf> {
336 if let Ok(path) = std::env::var("OMNI_DEV_LOG_FILE") {
337 if !path.is_empty() {
338 return Some(PathBuf::from(path));
339 }
340 }
341 let base = dirs::state_dir().or_else(dirs::data_dir)?;
342 Some(base.join("omni-dev").join(LOG_FILE_NAME))
343}
344
345pub fn record(entry: &LogRecord) {
348 if disabled() {
349 return;
350 }
351 if let Err(e) = try_record(entry) {
352 tracing::debug!("request_log: failed to append record: {e}");
353 }
354}
355
356fn try_record(entry: &LogRecord) -> anyhow::Result<()> {
358 use anyhow::Context;
359
360 let path = log_file_path().context("could not resolve the log file path")?;
361 if let Some(parent) = path.parent() {
365 if !parent.as_os_str().is_empty() && !parent.exists() {
366 crate::daemon::paths::ensure_dir_0700(parent)?;
367 }
368 }
369 let mut line = serde_json::to_string(entry).context("failed to serialize record")?;
370 line.push('\n');
371 append_line(&path, &line)?;
372 Ok(())
373}
374
375#[cfg(unix)]
383fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
384 use std::os::unix::fs::OpenOptionsExt;
385
386 if let Some(cfg) = rotation_config() {
389 return append_with_rotation(path, line, &cfg);
390 }
391
392 let file = std::fs::OpenOptions::new()
393 .append(true)
394 .create(true)
395 .mode(0o600)
396 .open(path)?;
397 crate::daemon::paths::ensure_handle_0600(&file)?;
398
399 if bodies_enabled() {
400 match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive) {
401 Ok(mut guard) => {
402 guard.write_all(line.as_bytes())?;
403 }
404 Err((mut file, _)) => {
405 file.write_all(line.as_bytes())?;
406 }
407 }
408 } else {
409 let mut file = file;
410 file.write_all(line.as_bytes())?;
411 }
412 Ok(())
413}
414
415#[cfg(not(unix))]
419fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
420 let mut file = std::fs::OpenOptions::new()
421 .append(true)
422 .create(true)
423 .open(path)?;
424 file.write_all(line.as_bytes())?;
425 Ok(())
426}
427
428fn sibling(path: &Path, suffix: &str) -> PathBuf {
442 let mut name = path.as_os_str().to_owned();
443 name.push(suffix);
444 PathBuf::from(name)
445}
446
447pub(crate) fn parse_size(s: &str) -> anyhow::Result<u64> {
451 use anyhow::Context as _;
452
453 let lower = s.trim().to_ascii_lowercase();
454 if lower.is_empty() {
455 anyhow::bail!("empty size (expected e.g. 10mb, 512kb, 1048576)");
456 }
457 let split = lower
458 .find(|c: char| !c.is_ascii_digit() && c != '.')
459 .unwrap_or(lower.len());
460 let (num, unit) = lower.split_at(split);
461 let value: f64 = num
462 .parse()
463 .with_context(|| format!("invalid size number: {s}"))?;
464 if !value.is_finite() || value < 0.0 {
465 anyhow::bail!("invalid size: {s}");
466 }
467 let mult: u64 = match unit.trim() {
468 "" | "b" => 1,
469 "k" | "kb" | "kib" => 1024,
470 "m" | "mb" | "mib" => 1024 * 1024,
471 "g" | "gb" | "gib" => 1024 * 1024 * 1024,
472 other => anyhow::bail!("invalid size unit: {other} (use b, kb, mb, or gb)"),
473 };
474 Ok((value * mult as f64) as u64)
475}
476
477#[cfg(unix)]
481struct RotationConfig {
482 max_size: u64,
484 keep_files: u32,
486}
487
488#[cfg(unix)]
492fn rotation_config() -> Option<RotationConfig> {
493 let raw = std::env::var("OMNI_DEV_LOG_MAX_SIZE").ok()?;
494 if raw.trim().is_empty() {
495 return None;
496 }
497 let max_size = match parse_size(&raw) {
498 Ok(0) => return None,
499 Ok(n) => n,
500 Err(e) => {
501 tracing::debug!("request_log: ignoring invalid OMNI_DEV_LOG_MAX_SIZE: {e}");
502 return None;
503 }
504 };
505 let keep_files = std::env::var("OMNI_DEV_LOG_KEEP_FILES")
506 .ok()
507 .and_then(|v| v.trim().parse::<u32>().ok())
508 .unwrap_or(DEFAULT_KEEP_FILES);
509 Some(RotationConfig {
510 max_size,
511 keep_files,
512 })
513}
514
515#[cfg(unix)]
519fn rotate(path: &Path, keep_files: u32) -> anyhow::Result<()> {
520 if keep_files == 0 {
521 let _ = std::fs::remove_file(path);
524 return Ok(());
525 }
526 let _ = std::fs::remove_file(sibling(path, &format!(".{keep_files}")));
528 for i in (1..keep_files).rev() {
529 let from = sibling(path, &format!(".{i}"));
530 if from.exists() {
531 std::fs::rename(&from, sibling(path, &format!(".{}", i + 1)))?;
532 }
533 }
534 std::fs::rename(path, sibling(path, ".1"))?;
535 Ok(())
536}
537
538#[cfg(unix)]
544fn append_with_rotation(path: &Path, line: &str, cfg: &RotationConfig) -> anyhow::Result<()> {
545 use std::os::unix::fs::OpenOptionsExt;
546
547 let lock_path = sibling(path, ".lock");
548 let lock_file = std::fs::OpenOptions::new()
549 .create(true)
550 .write(true)
551 .truncate(false)
552 .mode(0o600)
553 .open(&lock_path)?;
554 crate::daemon::paths::ensure_handle_0600(&lock_file)?;
555 let _guard = nix::fcntl::Flock::lock(lock_file, nix::fcntl::FlockArg::LockExclusive).ok();
558
559 let current = std::fs::metadata(path).map_or(0, |m| m.len());
560 if current > 0 && current.saturating_add(line.len() as u64) > cfg.max_size {
561 if let Err(e) = rotate(path, cfg.keep_files) {
562 tracing::debug!("request_log: rotation failed, appending without rotating: {e}");
563 }
564 }
565
566 let mut file = std::fs::OpenOptions::new()
567 .append(true)
568 .create(true)
569 .mode(0o600)
570 .open(path)?;
571 crate::daemon::paths::ensure_handle_0600(&file)?;
572 file.write_all(line.as_bytes())?;
573 Ok(())
574}
575
576pub struct PruneOptions {
578 pub older_than: Option<DateTime<Utc>>,
582 pub max_size: Option<u64>,
585 pub dry_run: bool,
587}
588
589pub struct PruneOutcome {
591 pub removed: usize,
593 pub kept: usize,
595 pub bytes_before: u64,
597 pub bytes_after: u64,
599}
600
601pub fn prune(path: &Path, opts: &PruneOptions) -> anyhow::Result<PruneOutcome> {
610 use anyhow::Context as _;
611
612 let data = match std::fs::read(path) {
613 Ok(data) => data,
614 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
615 return Ok(PruneOutcome {
616 removed: 0,
617 kept: 0,
618 bytes_before: 0,
619 bytes_after: 0,
620 });
621 }
622 Err(e) => return Err(e).context("failed to read the log file"),
623 };
624 let bytes_before = data.len() as u64;
625 let text = String::from_utf8_lossy(&data);
626
627 let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
630 let aged: Vec<&str> = all
631 .iter()
632 .copied()
633 .filter(|line| keep_by_age(line, opts.older_than))
634 .collect();
635
636 let kept: &[&str] = match opts.max_size {
637 None => &aged,
638 Some(max) => keep_by_size(&aged, max),
639 };
640
641 let bytes_after: u64 = kept.iter().map(|l| l.len() as u64 + 1).sum();
642 let outcome = PruneOutcome {
643 removed: all.len() - kept.len(),
644 kept: kept.len(),
645 bytes_before,
646 bytes_after,
647 };
648
649 if !opts.dry_run && outcome.removed > 0 {
650 rewrite_atomically(path, kept)?;
651 }
652 Ok(outcome)
653}
654
655fn keep_by_age(line: &str, older_than: Option<DateTime<Utc>>) -> bool {
658 let Some(cutoff) = older_than else {
659 return true;
660 };
661 match serde_json::from_str::<LogRecord>(line) {
662 Ok(rec) => match DateTime::parse_from_rfc3339(&rec.timestamp) {
663 Ok(ts) => ts.with_timezone(&Utc) >= cutoff,
664 Err(_) => true,
665 },
666 Err(_) => true,
667 }
668}
669
670fn keep_by_size<'a>(lines: &'a [&'a str], max: u64) -> &'a [&'a str] {
673 let mut acc = 0u64;
674 let mut start = lines.len();
675 for (i, line) in lines.iter().enumerate().rev() {
676 acc += line.len() as u64 + 1;
677 if acc > max {
678 break;
679 }
680 start = i;
681 }
682 if start == lines.len() && !lines.is_empty() {
683 start = lines.len() - 1; }
685 &lines[start..]
686}
687
688fn rewrite_atomically(path: &Path, lines: &[&str]) -> anyhow::Result<()> {
691 let tmp = sibling(path, &format!(".prune.{}.tmp", std::process::id()));
692 let result = (|| -> anyhow::Result<()> {
693 let mut options = std::fs::OpenOptions::new();
694 options.create(true).write(true).truncate(true);
695 #[cfg(unix)]
696 {
697 use std::os::unix::fs::OpenOptionsExt;
698 options.mode(0o600);
699 }
700 let mut file = options.open(&tmp)?;
701 #[cfg(unix)]
702 crate::daemon::paths::ensure_handle_0600(&file)?;
703 for line in lines {
704 file.write_all(line.as_bytes())?;
705 file.write_all(b"\n")?;
706 }
707 file.flush()?;
708 std::fs::rename(&tmp, path)?;
709 Ok(())
710 })();
711 if result.is_err() {
712 let _ = std::fs::remove_file(&tmp);
713 }
714 result
715}
716
717#[derive(Debug, Clone)]
719pub struct InvocationOutcome {
720 pub command: Vec<String>,
722 pub command_line: Vec<String>,
724 pub exit_code: i32,
726 pub error: Option<String>,
728 pub duration: Duration,
730}
731
732pub fn record_invocation(outcome: InvocationOutcome) {
734 let ctx = current_context();
735 let mut rec = LogRecord::new(RecordKind::Invocation, ctx.invocation_id);
736 rec.source = Some(ctx.source);
737 rec.mcp_tool = ctx.mcp_tool;
738 rec.command = outcome.command;
739 rec.command_line = scrub_argv(&outcome.command_line);
740 rec.exit_code = Some(outcome.exit_code);
741 rec.error = outcome.error;
742 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
743 rec.env = whitelisted_env();
744 record(&rec);
745}
746
747#[derive(Debug, Clone)]
750pub struct GhOutcome {
751 pub label: String,
754 pub argv: Vec<String>,
756 pub exit_code: Option<i32>,
758 pub duration: Duration,
760 pub error: Option<String>,
762}
763
764pub fn record_gh(outcome: GhOutcome) {
769 record(&build_gh_record(outcome, current_context()));
770}
771
772fn build_gh_record(outcome: GhOutcome, ctx: RequestLogContext) -> LogRecord {
776 let mut rec = LogRecord::new(RecordKind::Gh, ctx.invocation_id);
777 rec.source = Some(ctx.source);
778 rec.mcp_tool = ctx.mcp_tool;
779 rec.command = outcome
780 .label
781 .split(' ')
782 .filter(|s| !s.is_empty())
783 .map(str::to_string)
784 .collect();
785 rec.command_line = scrub_argv(&outcome.argv);
789 rec.exit_code = outcome.exit_code;
790 rec.error = outcome.error;
791 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
792 rec
793}
794
795#[derive(Debug, Clone, Default)]
798pub struct HttpExtra {
799 pub via_daemon: bool,
801 pub daemon_session_id: Option<String>,
803 pub auth_principal: Option<String>,
805 pub request_headers: BTreeMap<String, String>,
807 pub response_headers: BTreeMap<String, String>,
809 pub request_body: Option<String>,
811 pub response_body: Option<String>,
813 pub context: BTreeMap<String, String>,
815}
816
817pub fn record_http(
819 service: &str,
820 method: &str,
821 url: &str,
822 started: Instant,
823 status: Option<u16>,
824 error: Option<&str>,
825) {
826 record_http_with(
827 service,
828 method,
829 url,
830 started,
831 status,
832 error,
833 HttpExtra::default(),
834 );
835}
836
837pub fn record_http_result(
843 service: &str,
844 method: &str,
845 url: &str,
846 started: Instant,
847 result: &reqwest::Result<reqwest::Response>,
848) {
849 match result {
850 Ok(response) => {
851 record_http(
852 service,
853 method,
854 url,
855 started,
856 Some(response.status().as_u16()),
857 None,
858 );
859 }
860 Err(error) => {
861 record_http(
862 service,
863 method,
864 url,
865 started,
866 None,
867 Some(&error.to_string()),
868 );
869 }
870 }
871}
872
873#[allow(clippy::too_many_arguments)]
880pub fn record_http_with(
881 service: &str,
882 method: &str,
883 url: &str,
884 started: Instant,
885 status: Option<u16>,
886 error: Option<&str>,
887 extra: HttpExtra,
888) {
889 if disabled() {
890 return;
891 }
892 let ctx = current_context();
893 let mut rec = LogRecord::new(RecordKind::Http, ctx.invocation_id);
894 rec.source = Some(ctx.source);
895 rec.mcp_tool = ctx.mcp_tool;
896 rec.service = Some(service.to_string());
897 rec.method = Some(method.to_string());
898 rec.url = Some(redact_url(url));
899 rec.status_code = status;
900 rec.elapsed_ms = Some(started.elapsed().as_millis() as u64);
901 rec.error = error.map(str::to_string);
902 rec.via_daemon = extra.via_daemon;
903 rec.daemon_session_id = extra.daemon_session_id;
904 rec.auth_principal = extra.auth_principal;
905 rec.context = extra.context;
906 if headers_enabled() {
907 rec.request_headers = redact_headers(&extra.request_headers);
908 rec.response_headers = redact_headers(&extra.response_headers);
909 }
910 if bodies_enabled() {
911 rec.request_body = extra.request_body;
912 rec.response_body = extra.response_body;
913 }
914 record(&rec);
915}
916
917const SENSITIVE_HEADERS: &[&str] = &[
919 "authorization",
920 "proxy-authorization",
921 "cookie",
922 "set-cookie",
923 "x-api-key",
924 "api-key",
925 "dd-api-key",
926 "dd-application-key",
927 "x-datadog-api-key",
928 "x-datadog-application-key",
929 "x-omni-bridge",
930 "x-omni-bridge-target",
931];
932
933const SENSITIVE_HEADER_MARKERS: &[&str] = &[
937 "auth",
938 "token",
939 "secret",
940 "key",
941 "cookie",
942 "password",
943 "session",
944 "signature",
945 "credential",
946];
947
948pub fn redact_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
953 headers
954 .iter()
955 .map(|(name, value)| {
956 let lower = name.to_ascii_lowercase();
957 let redacted = SENSITIVE_HEADERS.contains(&lower.as_str())
958 || SENSITIVE_HEADER_MARKERS
959 .iter()
960 .any(|marker| lower.contains(marker));
961 (
962 name.clone(),
963 if redacted {
964 "REDACTED".to_string()
965 } else {
966 value.clone()
967 },
968 )
969 })
970 .collect()
971}
972
973const SECRETISH_FLAG_WORDS: &[&str] = &["token", "secret", "password", "passwd", "key"];
977
978fn is_secretish_flag(name: &str) -> bool {
982 let segments: Vec<String> = name
983 .split(['-', '_'])
984 .map(str::to_ascii_lowercase)
985 .collect();
986 let takes_path = matches!(segments.last().map(String::as_str), Some("file" | "path"));
987 !takes_path
988 && segments
989 .iter()
990 .any(|segment| SECRETISH_FLAG_WORDS.contains(&segment.as_str()))
991}
992
993fn scrub_header_arg(value: &str) -> Option<String> {
997 let Some((name, _)) = value.split_once(':') else {
998 return Some("REDACTED".to_string());
999 };
1000 SENSITIVE_HEADERS
1001 .contains(&name.trim().to_ascii_lowercase().as_str())
1002 .then(|| format!("{}: REDACTED", name.trim()))
1003}
1004
1005fn scrub_flag_value(name: &str, value: &str) -> Option<String> {
1009 match name {
1010 "header" => scrub_header_arg(value),
1011 "body" => (!value.starts_with('@')).then(|| "REDACTED".to_string()),
1012 _ if is_secretish_flag(name) => Some("REDACTED".to_string()),
1013 _ => None,
1014 }
1015}
1016
1017fn scrub_argv(argv: &[String]) -> Vec<String> {
1029 scrub_flag_secrets(argv)
1030 .iter()
1031 .map(|arg| redact_url(arg))
1032 .collect()
1033}
1034
1035fn scrub_flag_secrets(argv: &[String]) -> Vec<String> {
1040 let mut out = Vec::with_capacity(argv.len());
1041 let mut i = 0;
1042 while i < argv.len() {
1043 let arg = &argv[i];
1044 i += 1;
1045 let Some(flag_body) = arg.strip_prefix("--") else {
1046 out.push(arg.clone());
1047 continue;
1048 };
1049 if let Some((name, value)) = flag_body.split_once('=') {
1050 match scrub_flag_value(name, value) {
1051 Some(scrubbed) => out.push(format!("--{name}={scrubbed}")),
1052 None => out.push(arg.clone()),
1053 }
1054 } else {
1055 out.push(arg.clone());
1056 let takes_secret_value =
1057 matches!(flag_body, "header" | "body") || is_secretish_flag(flag_body);
1058 if takes_secret_value {
1059 if let Some(value) = argv.get(i) {
1060 i += 1;
1061 out.push(scrub_flag_value(flag_body, value).unwrap_or_else(|| value.clone()));
1062 }
1063 }
1064 }
1065 }
1066 out
1067}
1068
1069const SENSITIVE_QUERY_KEYS: &[&str] = &["sig", "sas", "jwt", "auth"];
1071
1072const SENSITIVE_QUERY_KEY_SUFFIXES: &[&str] = &[
1075 "token",
1076 "secret",
1077 "password",
1078 "passwd",
1079 "signature",
1080 "apikey",
1081 "api_key",
1082 "api-key",
1083];
1084
1085const SENSITIVE_QUERY_KEY_PREFIXES: &[&str] = &["x-amz-", "x-goog-"];
1087
1088fn sensitive_query_key(key: &str) -> bool {
1090 let key = key.to_ascii_lowercase();
1091 SENSITIVE_QUERY_KEYS.contains(&key.as_str())
1092 || SENSITIVE_QUERY_KEY_SUFFIXES
1093 .iter()
1094 .any(|suffix| key.ends_with(suffix))
1095 || SENSITIVE_QUERY_KEY_PREFIXES
1096 .iter()
1097 .any(|prefix| key.starts_with(prefix))
1098}
1099
1100fn redact_pairs(pairs: &str) -> String {
1104 pairs
1105 .split('&')
1106 .map(|segment| match segment.split_once('=') {
1107 Some((raw_key, _)) => {
1108 let sensitive = url::form_urlencoded::parse(raw_key.as_bytes())
1111 .next()
1112 .is_some_and(|(key, _)| sensitive_query_key(&key));
1113 if sensitive {
1114 format!("{raw_key}=REDACTED")
1115 } else {
1116 segment.to_string()
1117 }
1118 }
1119 None => segment.to_string(),
1121 })
1122 .collect::<Vec<_>>()
1123 .join("&")
1124}
1125
1126fn redact_url(url: &str) -> String {
1132 let (rest, fragment) = url
1133 .split_once('#')
1134 .map_or((url, None), |(rest, fragment)| (rest, Some(fragment)));
1135 let (prefix, query) = rest
1136 .split_once('?')
1137 .map_or((rest, None), |(prefix, query)| (prefix, Some(query)));
1138 let mut out = prefix.to_string();
1139 if let Some(query) = query {
1140 out.push('?');
1141 out.push_str(&redact_pairs(query));
1142 }
1143 if let Some(fragment) = fragment {
1144 out.push('#');
1145 out.push_str(&redact_pairs(fragment));
1146 }
1147 out
1148}
1149
1150pub fn new_id() -> String {
1156 let millis = chrono::Utc::now().timestamp_millis().max(0);
1157 let suffix = rand::random::<u64>();
1158 format!("{millis:013}-{suffix:016x}")
1159}
1160
1161fn now_rfc3339_millis() -> String {
1163 chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
1164}
1165
1166fn cwd() -> String {
1168 std::env::current_dir()
1169 .map(|p| p.display().to_string())
1170 .unwrap_or_default()
1171}
1172
1173fn system_user() -> String {
1175 if let Ok(user) = std::env::var("USER") {
1176 if !user.is_empty() {
1177 return user;
1178 }
1179 }
1180 #[cfg(unix)]
1181 {
1182 if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid()) {
1183 return user.name;
1184 }
1185 }
1186 String::new()
1187}
1188
1189fn hostname() -> String {
1191 #[cfg(unix)]
1192 {
1193 if let Ok(name) = nix::unistd::gethostname() {
1194 if let Some(name) = name.to_str() {
1195 if !name.is_empty() {
1196 return name.to_string();
1197 }
1198 }
1199 }
1200 }
1201 std::env::var("HOSTNAME").unwrap_or_default()
1202}
1203
1204const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
1207
1208fn whitelisted_env() -> BTreeMap<String, String> {
1210 std::env::vars()
1211 .filter(|(k, _)| k.starts_with("OMNI_DEV_"))
1212 .map(|(k, v)| {
1213 let secretish = SECRETISH.iter().any(|needle| k.contains(needle));
1214 let value = if secretish { "REDACTED".to_string() } else { v };
1215 (k, value)
1216 })
1217 .collect()
1218}
1219
1220#[cfg(test)]
1221#[allow(clippy::unwrap_used, clippy::expect_used)]
1222mod tests {
1223 use super::*;
1224
1225 #[test]
1226 fn record_round_trips_through_json() {
1227 let mut rec = LogRecord::new(RecordKind::Http, "inv-1".to_string());
1228 rec.service = Some("jira".to_string());
1229 rec.method = Some("GET".to_string());
1230 rec.url = Some("https://example.atlassian.net/rest/api/3/issue/X-1".to_string());
1231 rec.status_code = Some(200);
1232 rec.elapsed_ms = Some(42);
1233
1234 let line = serde_json::to_string(&rec).unwrap();
1235 let back: LogRecord = serde_json::from_str(&line).unwrap();
1236 assert_eq!(back.invocation_id, "inv-1");
1237 assert_eq!(back.kind, RecordKind::Http);
1238 assert_eq!(back.service.as_deref(), Some("jira"));
1239 assert_eq!(back.status_code, Some(200));
1240 }
1241
1242 #[test]
1243 fn reader_tolerates_unknown_fields() {
1244 let line = r#"{"id":"x","invocation_id":"i","kind":"http","method":"GET",
1245 "future_field":{"nested":true},"another":42}"#;
1246 let rec: LogRecord = serde_json::from_str(line).unwrap();
1247 assert_eq!(rec.kind, RecordKind::Http);
1248 assert_eq!(rec.method.as_deref(), Some("GET"));
1249 }
1250
1251 #[test]
1252 fn reader_tolerates_missing_newer_fields() {
1253 let line = r#"{"kind":"invocation","command":["git","view"]}"#;
1255 let rec: LogRecord = serde_json::from_str(line).unwrap();
1256 assert_eq!(rec.kind, RecordKind::Invocation);
1257 assert_eq!(rec.command, vec!["git", "view"]);
1258 assert!(rec.status_code.is_none());
1259 assert!(rec.id.is_empty());
1260 }
1261
1262 #[test]
1263 fn unknown_kind_and_source_do_not_fail() {
1264 let line = r#"{"kind":"telemetry","source":"webhook"}"#;
1265 let rec: LogRecord = serde_json::from_str(line).unwrap();
1266 assert_eq!(rec.kind, RecordKind::Unknown);
1267 assert_eq!(rec.source, Some(Source::Unknown));
1268 }
1269
1270 #[test]
1271 fn optional_fields_are_skipped_when_empty() {
1272 let rec = LogRecord::new(RecordKind::Invocation, "i".to_string());
1273 let line = serde_json::to_string(&rec).unwrap();
1274 assert!(!line.contains("status_code"));
1276 assert!(!line.contains("request_headers"));
1277 assert!(!line.contains("via_daemon"));
1278 assert!(!line.contains("\"env\""));
1279 }
1280
1281 #[test]
1282 fn ids_are_time_sortable() {
1283 let a = new_id();
1284 std::thread::sleep(std::time::Duration::from_millis(2));
1285 let b = new_id();
1286 assert!(a < b, "{a} should sort before {b}");
1287 }
1288
1289 #[test]
1290 fn sensitive_headers_are_redacted() {
1291 let mut headers = BTreeMap::new();
1292 headers.insert("Authorization".to_string(), "Bearer secret".to_string());
1293 headers.insert("X-Api-Key".to_string(), "abc123".to_string());
1294 headers.insert("Content-Type".to_string(), "application/json".to_string());
1295 let out = redact_headers(&headers);
1296 assert_eq!(out["Authorization"], "REDACTED");
1297 assert_eq!(out["X-Api-Key"], "REDACTED");
1298 assert_eq!(out["Content-Type"], "application/json");
1299 }
1300
1301 fn argv(args: &[&str]) -> Vec<String> {
1302 args.iter().copied().map(String::from).collect()
1303 }
1304
1305 #[test]
1306 fn build_gh_record_stamps_kind_source_and_split_command() {
1307 let ctx = RequestLogContext {
1308 invocation_id: "inv-1".to_string(),
1309 source: Source::Daemon,
1310 mcp_tool: None,
1311 };
1312 let rec = build_gh_record(
1313 GhOutcome {
1314 label: "api graphql".to_string(),
1315 argv: argv(&["api", "graphql", "-f", "query=xyz"]),
1316 exit_code: Some(0),
1317 duration: Duration::from_millis(120),
1318 error: None,
1319 },
1320 ctx,
1321 );
1322 assert_eq!(rec.kind, RecordKind::Gh);
1323 assert_eq!(rec.invocation_id, "inv-1");
1324 assert_eq!(rec.source, Some(Source::Daemon));
1325 assert_eq!(rec.command, argv(&["api", "graphql"]));
1327 assert_eq!(
1328 rec.command_line,
1329 argv(&["api", "graphql", "-f", "query=xyz"])
1330 );
1331 assert_eq!(rec.exit_code, Some(0));
1332 assert_eq!(rec.duration_ms, Some(120));
1333 assert!(rec.error.is_none());
1334 }
1335
1336 #[test]
1337 fn build_gh_record_scrubs_secret_bearing_argv() {
1338 let rec = build_gh_record(
1341 GhOutcome {
1342 label: "api graphql".to_string(),
1343 argv: argv(&["api", "--header", "Authorization: Bearer sekret"]),
1344 exit_code: Some(0),
1345 duration: Duration::from_millis(5),
1346 error: None,
1347 },
1348 RequestLogContext::default(),
1349 );
1350 assert_eq!(
1351 rec.command_line,
1352 argv(&["api", "--header", "Authorization: REDACTED"])
1353 );
1354 }
1355
1356 #[test]
1357 fn record_kind_gh_serializes_as_gh_and_round_trips() {
1358 let rec = build_gh_record(
1359 GhOutcome {
1360 label: "pr list".to_string(),
1361 argv: argv(&["pr", "list"]),
1362 exit_code: Some(1),
1363 duration: Duration::from_millis(1),
1364 error: Some("boom".to_string()),
1365 },
1366 RequestLogContext::default(),
1367 );
1368 let line = serde_json::to_string(&rec).unwrap();
1369 assert!(line.contains("\"kind\":\"gh\""), "line was: {line}");
1370 let back: LogRecord = serde_json::from_str(&line).unwrap();
1371 assert_eq!(back.kind, RecordKind::Gh);
1372 assert_eq!(back.command, argv(&["pr", "list"]));
1373 assert_eq!(back.error.as_deref(), Some("boom"));
1374 }
1375
1376 #[test]
1377 fn scrub_argv_redacts_sensitive_header_in_both_forms() {
1378 let out = scrub_argv(&argv(&[
1379 "omni-dev",
1380 "--header",
1381 "Authorization: Bearer sekret",
1382 "--header=Cookie: session=abc",
1383 ]));
1384 assert_eq!(
1385 out,
1386 argv(&[
1387 "omni-dev",
1388 "--header",
1389 "Authorization: REDACTED",
1390 "--header=Cookie: REDACTED",
1391 ])
1392 );
1393 }
1394
1395 #[test]
1396 fn scrub_argv_keeps_non_sensitive_headers() {
1397 let input = argv(&["omni-dev", "--header", "Content-Type: application/json"]);
1398 assert_eq!(scrub_argv(&input), input);
1399 }
1400
1401 #[test]
1402 fn scrub_argv_redacts_colonless_header_wholesale() {
1403 let out = scrub_argv(&argv(&["omni-dev", "--header", "sekret"]));
1404 assert_eq!(out, argv(&["omni-dev", "--header", "REDACTED"]));
1405 }
1406
1407 #[test]
1408 fn scrub_argv_redacts_inline_body_but_keeps_at_file() {
1409 let out = scrub_argv(&argv(&["omni-dev", "--body", r#"{"secret":1}"#]));
1410 assert_eq!(out, argv(&["omni-dev", "--body", "REDACTED"]));
1411
1412 let file_form = argv(&["omni-dev", "--body", "@payload.json"]);
1413 assert_eq!(scrub_argv(&file_form), file_form);
1414
1415 let out = scrub_argv(&argv(&["omni-dev", "--body=sekret"]));
1416 assert_eq!(out, argv(&["omni-dev", "--body=REDACTED"]));
1417 }
1418
1419 #[test]
1420 fn scrub_argv_redacts_secretish_flag_values() {
1421 let out = scrub_argv(&argv(&["omni-dev", "--api-key", "abc", "--auth-token=xyz"]));
1422 assert_eq!(
1423 out,
1424 argv(&["omni-dev", "--api-key", "REDACTED", "--auth-token=REDACTED"])
1425 );
1426 }
1427
1428 #[test]
1429 fn scrub_argv_exempts_path_flags_and_positionals() {
1430 let input = argv(&["omni-dev", "--token-file", "/tmp/t", "PROJ-123"]);
1431 assert_eq!(scrub_argv(&input), input);
1432 }
1433
1434 #[test]
1435 fn scrub_argv_redacts_secret_bearing_url_query_in_both_forms() {
1436 let space = scrub_argv(&argv(&[
1440 "omni-dev",
1441 "browser",
1442 "bridge",
1443 "request",
1444 "--url",
1445 "/api/export?access_token=hunter2&sig=deadbeef&page=3",
1446 ]));
1447 assert_eq!(
1448 *space.last().unwrap(),
1449 "/api/export?access_token=REDACTED&sig=REDACTED&page=3"
1450 );
1451
1452 let eq_form = scrub_argv(&argv(&[
1453 "omni-dev",
1454 "--url=/api/export?access_token=hunter2&page=3",
1455 ]));
1456 assert_eq!(
1457 *eq_form.last().unwrap(),
1458 "--url=/api/export?access_token=REDACTED&page=3"
1459 );
1460
1461 let positional = scrub_argv(&argv(&["omni-dev", "https://h/cb#id_token=xyz"]));
1462 assert_eq!(
1463 *positional.last().unwrap(),
1464 "https://h/cb#id_token=REDACTED"
1465 );
1466 }
1467
1468 #[test]
1469 fn scrub_argv_leaves_benign_argv_byte_identical() {
1470 let input = argv(&[
1471 "omni-dev",
1472 "browser",
1473 "bridge",
1474 "request",
1475 "--control-port",
1476 "19998",
1477 "--url",
1478 "/api/export?page=3&sort=asc",
1479 ]);
1480 assert_eq!(scrub_argv(&input), input);
1481 }
1482
1483 #[test]
1484 fn scrub_argv_handles_trailing_flag_without_value() {
1485 let input = argv(&["omni-dev", "--body"]);
1486 assert_eq!(scrub_argv(&input), input);
1487 }
1488
1489 #[cfg(unix)]
1490 #[test]
1491 fn append_line_creates_file_owner_only() {
1492 use std::os::unix::fs::PermissionsExt;
1493 let dir = tempfile::tempdir().unwrap();
1494 let path = dir.path().join("log.jsonl");
1495 append_line(&path, "{\"kind\":\"http\"}\n").unwrap();
1496 assert_eq!(
1497 std::fs::read_to_string(&path).unwrap(),
1498 "{\"kind\":\"http\"}\n"
1499 );
1500 assert_eq!(
1501 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1502 0o600
1503 );
1504 }
1505
1506 #[cfg(unix)]
1507 #[test]
1508 fn append_line_retightens_preexisting_loose_file() {
1509 use std::os::unix::fs::PermissionsExt;
1510 let dir = tempfile::tempdir().unwrap();
1511 let path = dir.path().join("log.jsonl");
1512 std::fs::write(&path, "old\n").unwrap();
1513 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1514 append_line(&path, "new\n").unwrap();
1515 assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
1516 assert_eq!(
1517 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1518 0o600
1519 );
1520 }
1521
1522 #[test]
1523 fn off_list_secretish_headers_are_redacted() {
1524 let mut headers = BTreeMap::new();
1525 for name in [
1526 "X-Auth-Token",
1527 "x-amz-security-token",
1528 "X-Goog-Api-Key",
1529 "x-csrf-token",
1530 "X-Vendor-Token",
1531 "X-Omni-Bridge",
1532 ] {
1533 headers.insert(name.to_string(), "secret-value".to_string());
1534 }
1535 for name in [
1536 "Content-Type",
1537 "Accept",
1538 "User-Agent",
1539 "x-request-id",
1540 "traceparent",
1541 ] {
1542 headers.insert(name.to_string(), "plain-value".to_string());
1543 }
1544 let out = redact_headers(&headers);
1545 assert_eq!(out["X-Auth-Token"], "REDACTED");
1546 assert_eq!(out["x-amz-security-token"], "REDACTED");
1547 assert_eq!(out["X-Goog-Api-Key"], "REDACTED");
1548 assert_eq!(out["x-csrf-token"], "REDACTED");
1549 assert_eq!(out["X-Vendor-Token"], "REDACTED");
1550 assert_eq!(out["X-Omni-Bridge"], "REDACTED");
1551 assert_eq!(out["Content-Type"], "plain-value");
1552 assert_eq!(out["Accept"], "plain-value");
1553 assert_eq!(out["User-Agent"], "plain-value");
1554 assert_eq!(out["x-request-id"], "plain-value");
1555 assert_eq!(out["traceparent"], "plain-value");
1556 }
1557
1558 #[test]
1559 fn url_without_query_is_unchanged() {
1560 assert_eq!(redact_url("https://h/p"), "https://h/p");
1561 assert_eq!(redact_url("/relative/p"), "/relative/p");
1562 }
1563
1564 #[test]
1565 fn benign_query_is_byte_identical() {
1566 let url = "https://h/p?q=a%20b&page=2&&x=y+z&keyword=k&sort_key=s&token_type=bearer";
1567 assert_eq!(redact_url(url), url);
1568 }
1569
1570 #[test]
1571 fn sensitive_query_values_are_redacted() {
1572 let url = "https://h/p?token=a&access_token=b&client_secret=c&api_key=d&x=1";
1573 assert_eq!(
1574 redact_url(url),
1575 "https://h/p?token=REDACTED&access_token=REDACTED&client_secret=REDACTED\
1576 &api_key=REDACTED&x=1"
1577 );
1578 }
1579
1580 #[test]
1581 fn presigned_s3_query_is_redacted() {
1582 let url = "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=AWS4-HMAC-SHA256\
1583 &X-Amz-Credential=AKIA%2F20260703%2Fus-east-1%2Fs3%2Faws4_request\
1584 &X-Amz-Date=20260703T000000Z&X-Amz-Expires=3600\
1585 &X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef";
1586 assert_eq!(
1587 redact_url(url),
1588 "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=REDACTED\
1589 &X-Amz-Credential=REDACTED&X-Amz-Date=REDACTED&X-Amz-Expires=REDACTED\
1590 &X-Amz-SignedHeaders=REDACTED&X-Amz-Signature=REDACTED"
1591 );
1592 }
1593
1594 #[test]
1595 fn key_matching_is_case_insensitive() {
1596 assert_eq!(
1597 redact_url("/p?TOKEN=x&Api_Key=y&X-Amz-Signature=z"),
1598 "/p?TOKEN=REDACTED&Api_Key=REDACTED&X-Amz-Signature=REDACTED"
1599 );
1600 }
1601
1602 #[test]
1603 fn repeated_sensitive_keys_are_each_redacted() {
1604 assert_eq!(redact_url("/p?sig=a&sig=b"), "/p?sig=REDACTED&sig=REDACTED");
1605 }
1606
1607 #[test]
1608 fn valueless_key_is_left_alone() {
1609 assert_eq!(redact_url("/p?token"), "/p?token");
1610 assert_eq!(redact_url("/p?token="), "/p?token=REDACTED");
1611 }
1612
1613 #[test]
1614 fn relative_url_query_is_redacted() {
1615 assert_eq!(
1616 redact_url("/api/foo?sig=abc&x=y"),
1617 "/api/foo?sig=REDACTED&x=y"
1618 );
1619 }
1620
1621 #[test]
1622 fn fragment_credentials_are_redacted() {
1623 assert_eq!(
1624 redact_url("https://h/cb#access_token=xyz&token_type=bearer"),
1625 "https://h/cb#access_token=REDACTED&token_type=bearer"
1626 );
1627 }
1628
1629 #[test]
1630 fn query_and_fragment_are_scrubbed_independently() {
1631 assert_eq!(
1632 redact_url("/p?sig=a#id_token=b"),
1633 "/p?sig=REDACTED#id_token=REDACTED"
1634 );
1635 }
1636
1637 #[test]
1638 fn question_mark_in_fragment_is_not_parsed_as_query() {
1639 assert_eq!(
1643 redact_url("https://h/p#frag?token=x"),
1644 "https://h/p#frag?token=REDACTED"
1645 );
1646 }
1647
1648 #[test]
1649 fn encoded_sensitive_key_is_decoded_before_matching() {
1650 assert_eq!(
1651 redact_url("/p?access%5Ftoken=v"),
1652 "/p?access%5Ftoken=REDACTED"
1653 );
1654 }
1655
1656 #[test]
1657 fn empty_query_is_unchanged() {
1658 assert_eq!(redact_url("https://h/p?"), "https://h/p?");
1659 assert_eq!(redact_url("https://h/p?#f"), "https://h/p?#f");
1660 }
1661
1662 #[test]
1663 fn env_flag_parses_truthy_values() {
1664 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "1");
1665 assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1666 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "TRUE");
1667 assert!(env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1668 std::env::set_var("OMNI_DEV_TEST_FLAG_ABC", "0");
1669 assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1670 std::env::remove_var("OMNI_DEV_TEST_FLAG_ABC");
1671 assert!(!env_flag("OMNI_DEV_TEST_FLAG_ABC"));
1672 }
1673
1674 #[test]
1675 fn parse_size_handles_units_and_bare_bytes() {
1676 assert_eq!(parse_size("1048576").unwrap(), 1024 * 1024);
1677 assert_eq!(parse_size("512b").unwrap(), 512);
1678 assert_eq!(parse_size("10kb").unwrap(), 10 * 1024);
1679 assert_eq!(parse_size("2K").unwrap(), 2 * 1024);
1680 assert_eq!(parse_size("3mb").unwrap(), 3 * 1024 * 1024);
1681 assert_eq!(parse_size("1gb").unwrap(), 1024 * 1024 * 1024);
1682 assert_eq!(parse_size("1.5mb").unwrap(), (1.5 * 1024.0 * 1024.0) as u64);
1683 assert_eq!(parse_size(" 4mib ").unwrap(), 4 * 1024 * 1024);
1684 }
1685
1686 #[test]
1687 fn parse_size_rejects_garbage() {
1688 assert!(parse_size("").is_err());
1689 assert!(parse_size("mb").is_err());
1690 assert!(parse_size("10tb").is_err());
1691 assert!(parse_size("-5mb").is_err());
1692 }
1693
1694 #[test]
1695 fn sibling_appends_to_final_component() {
1696 let base = Path::new("/tmp/omni/log.jsonl");
1697 assert_eq!(sibling(base, ".1"), Path::new("/tmp/omni/log.jsonl.1"));
1698 assert_eq!(
1699 sibling(base, ".lock"),
1700 Path::new("/tmp/omni/log.jsonl.lock")
1701 );
1702 }
1703
1704 #[test]
1705 fn keep_by_size_keeps_most_recent_that_fit() {
1706 let lines = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"];
1708 let refs: Vec<&str> = lines.to_vec();
1709
1710 assert_eq!(keep_by_size(&refs, 22), &["cccccccccc", "dddddddddd"]);
1712 assert_eq!(keep_by_size(&refs, 1), &["dddddddddd"]);
1714 assert_eq!(keep_by_size(&refs, 10_000), &refs[..]);
1716 assert!(keep_by_size(&[], 100).is_empty());
1718 }
1719
1720 #[test]
1721 fn keep_by_age_is_conservative_on_undateable_lines() {
1722 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1723 .unwrap()
1724 .with_timezone(&Utc);
1725 let old = r#"{"kind":"http","timestamp":"2026-01-01T00:00:00.000Z"}"#;
1726 let new = r#"{"kind":"http","timestamp":"2026-12-01T00:00:00.000Z"}"#;
1727 let undated = r#"{"kind":"http"}"#;
1728 let malformed = "not json at all";
1729
1730 assert!(!keep_by_age(old, Some(cutoff)));
1731 assert!(keep_by_age(new, Some(cutoff)));
1732 assert!(keep_by_age(undated, Some(cutoff)), "undated is kept");
1733 assert!(keep_by_age(malformed, Some(cutoff)), "malformed is kept");
1734 assert!(keep_by_age(old, None), "no filter keeps everything");
1735 }
1736
1737 fn http_line(id: &str, ts: &str) -> String {
1738 format!(r#"{{"id":"{id}","kind":"http","timestamp":"{ts}"}}"#)
1739 }
1740
1741 #[test]
1742 fn prune_by_age_drops_old_records_and_rewrites_atomically() {
1743 use std::os::unix::fs::PermissionsExt;
1744
1745 let dir = tempfile::tempdir().unwrap();
1746 let path = dir.path().join("log.jsonl");
1747 let body = format!(
1748 "{}\n{}\n{}\n",
1749 http_line("1", "2026-01-01T00:00:00.000Z"),
1750 http_line("2", "2026-06-15T00:00:00.000Z"),
1751 http_line("3", "2026-12-31T00:00:00.000Z"),
1752 );
1753 std::fs::write(&path, &body).unwrap();
1754 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1755
1756 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1757 .unwrap()
1758 .with_timezone(&Utc);
1759 let outcome = prune(
1760 &path,
1761 &PruneOptions {
1762 older_than: Some(cutoff),
1763 max_size: None,
1764 dry_run: false,
1765 },
1766 )
1767 .unwrap();
1768
1769 assert_eq!(outcome.removed, 1);
1770 assert_eq!(outcome.kept, 2);
1771 let contents = std::fs::read_to_string(&path).unwrap();
1772 assert!(!contents.contains(r#""id":"1""#));
1773 assert!(contents.contains(r#""id":"2""#));
1774 assert!(contents.contains(r#""id":"3""#));
1775 assert_eq!(
1777 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
1778 0o600
1779 );
1780 }
1781
1782 #[test]
1783 fn prune_dry_run_reports_without_modifying() {
1784 let dir = tempfile::tempdir().unwrap();
1785 let path = dir.path().join("log.jsonl");
1786 let body = format!(
1787 "{}\n{}\n",
1788 http_line("1", "2026-01-01T00:00:00.000Z"),
1789 http_line("2", "2026-12-31T00:00:00.000Z"),
1790 );
1791 std::fs::write(&path, &body).unwrap();
1792
1793 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
1794 .unwrap()
1795 .with_timezone(&Utc);
1796 let outcome = prune(
1797 &path,
1798 &PruneOptions {
1799 older_than: Some(cutoff),
1800 max_size: None,
1801 dry_run: true,
1802 },
1803 )
1804 .unwrap();
1805
1806 assert_eq!(outcome.removed, 1);
1807 assert_eq!(std::fs::read_to_string(&path).unwrap(), body);
1809 }
1810
1811 #[test]
1812 fn prune_by_size_keeps_the_newest_that_fit() {
1813 let dir = tempfile::tempdir().unwrap();
1814 let path = dir.path().join("log.jsonl");
1815 let l1 = http_line("1", "2026-01-01T00:00:00.000Z");
1816 let l2 = http_line("2", "2026-06-15T00:00:00.000Z");
1817 let l3 = http_line("3", "2026-12-31T00:00:00.000Z");
1818 std::fs::write(&path, format!("{l1}\n{l2}\n{l3}\n")).unwrap();
1819
1820 let budget = (l2.len() + 1 + l3.len() + 1) as u64;
1822 let outcome = prune(
1823 &path,
1824 &PruneOptions {
1825 older_than: None,
1826 max_size: Some(budget),
1827 dry_run: false,
1828 },
1829 )
1830 .unwrap();
1831
1832 assert_eq!(outcome.removed, 1);
1833 assert_eq!(outcome.kept, 2);
1834 let contents = std::fs::read_to_string(&path).unwrap();
1835 assert!(!contents.contains(r#""id":"1""#));
1836 assert!(contents.contains(r#""id":"3""#));
1837 }
1838
1839 #[test]
1840 fn prune_missing_file_is_a_noop() {
1841 let dir = tempfile::tempdir().unwrap();
1842 let path = dir.path().join("absent.jsonl");
1843 let outcome = prune(
1844 &path,
1845 &PruneOptions {
1846 older_than: None,
1847 max_size: Some(1),
1848 dry_run: false,
1849 },
1850 )
1851 .unwrap();
1852 assert_eq!(outcome.removed, 0);
1853 assert_eq!(outcome.kept, 0);
1854 assert!(!path.exists());
1855 }
1856
1857 #[cfg(unix)]
1858 #[test]
1859 fn rotation_shifts_numbered_files_and_drops_the_oldest() {
1860 use std::os::unix::fs::PermissionsExt;
1861
1862 let dir = tempfile::tempdir().unwrap();
1863 let path = dir.path().join("log.jsonl");
1864 let cfg = RotationConfig {
1866 max_size: 20,
1867 keep_files: 2,
1868 };
1869
1870 let line = "0123456789012345\n"; for _ in 0..4 {
1872 append_with_rotation(&path, line, &cfg).unwrap();
1873 }
1874
1875 assert!(path.exists());
1878 assert!(sibling(&path, ".1").exists());
1879 assert!(sibling(&path, ".2").exists());
1880 assert!(!sibling(&path, ".3").exists());
1881 assert_eq!(
1883 std::fs::metadata(sibling(&path, ".1"))
1884 .unwrap()
1885 .permissions()
1886 .mode()
1887 & 0o777,
1888 0o600
1889 );
1890 }
1891
1892 #[cfg(unix)]
1893 #[test]
1894 fn rotation_keep_zero_discards_on_overflow() {
1895 let dir = tempfile::tempdir().unwrap();
1896 let path = dir.path().join("log.jsonl");
1897 let cfg = RotationConfig {
1898 max_size: 20,
1899 keep_files: 0,
1900 };
1901 let line = "0123456789012345\n"; append_with_rotation(&path, line, &cfg).unwrap();
1903 append_with_rotation(&path, line, &cfg).unwrap();
1904 assert!(!sibling(&path, ".1").exists());
1906 assert_eq!(std::fs::read_to_string(&path).unwrap(), line);
1907 }
1908
1909 #[test]
1910 fn parse_size_rejects_overflow_to_infinity() {
1911 assert!(parse_size(&"9".repeat(400)).is_err());
1913 }
1914
1915 #[test]
1916 fn prune_surfaces_a_read_error() {
1917 let dir = tempfile::tempdir().unwrap();
1920 let result = prune(
1921 dir.path(),
1922 &PruneOptions {
1923 older_than: None,
1924 max_size: Some(1),
1925 dry_run: false,
1926 },
1927 );
1928 assert!(result.is_err());
1929 }
1930
1931 #[cfg(unix)]
1932 #[test]
1933 fn append_with_rotation_appends_even_when_rotate_fails() {
1934 let dir = tempfile::tempdir().unwrap();
1935 let path = dir.path().join("log.jsonl");
1936 std::fs::write(&path, "0123456789012345\n").unwrap();
1938 std::fs::create_dir(sibling(&path, ".1")).unwrap();
1940 let cfg = RotationConfig {
1941 max_size: 5,
1942 keep_files: 1,
1943 };
1944 append_with_rotation(&path, "new-line\n", &cfg).unwrap();
1946 assert!(
1947 std::fs::read_to_string(&path).unwrap().contains("new-line"),
1948 "the record is appended despite the rotation failure"
1949 );
1950 }
1951
1952 #[test]
1953 fn prune_cleans_up_temp_on_rewrite_failure() {
1954 let dir = tempfile::tempdir().unwrap();
1955 let path = dir.path().join("log.jsonl");
1956 std::fs::write(
1957 &path,
1958 format!(
1959 "{}\n{}\n",
1960 http_line("a", "2999-01-01T00:00:00.000Z"),
1961 http_line("b", "2999-01-01T00:00:00.000Z"),
1962 ),
1963 )
1964 .unwrap();
1965 let tmp = sibling(&path, &format!(".prune.{}.tmp", std::process::id()));
1968 std::fs::create_dir(&tmp).unwrap();
1969
1970 let result = prune(
1971 &path,
1972 &PruneOptions {
1973 older_than: None,
1974 max_size: Some(1),
1975 dry_run: false,
1976 },
1977 );
1978 assert!(result.is_err(), "a failing rewrite surfaces as an error");
1979 let _ = std::fs::remove_dir(&tmp);
1980 }
1981
1982 #[tokio::test]
1983 async fn scope_origin_id_overwrites_id_but_preserves_source() {
1984 let base = RequestLogContext {
1987 invocation_id: "daemon-1".to_string(),
1988 source: Source::Daemon,
1989 mcp_tool: None,
1990 };
1991 CTX.scope(base, async {
1992 scope_origin_id("cli-42".to_string(), async {
1993 let ctx = current_context();
1994 assert_eq!(ctx.invocation_id, "cli-42");
1996 assert_eq!(ctx.source, Source::Daemon);
1998 })
1999 .await;
2000 assert_eq!(current_context().invocation_id, "daemon-1");
2002 })
2003 .await;
2004 }
2005}