1use std::collections::BTreeMap;
28use std::io::Write;
29use std::path::{Path, PathBuf};
30use std::sync::OnceLock;
31use std::time::{Duration, Instant};
32
33use chrono::{DateTime, SecondsFormat, Utc};
34use serde::{Deserialize, Serialize};
35
36use crate::utils::env::{non_empty_var, truthy_var, EnvSource, SystemEnv};
37
38const LOG_FILE_NAME: &str = "log.jsonl";
40
41const AUDIT_FILE_NAME: &str = "audit.jsonl";
46
47#[cfg(unix)]
51const DEFAULT_KEEP_FILES: u32 = 3;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
56#[serde(rename_all = "lowercase")]
57pub enum RecordKind {
58 #[default]
60 Invocation,
61 Http,
63 Gh,
67 Worktree,
71 DriveMutation,
79 Audit,
88 #[serde(other)]
90 Unknown,
91}
92
93impl RecordKind {
94 #[must_use]
97 pub fn as_str(self) -> &'static str {
98 match self {
99 Self::Invocation => "invocation",
100 Self::Http => "http",
101 Self::Gh => "gh",
102 Self::Worktree => "worktree",
103 Self::DriveMutation => "drivemutation",
104 Self::Audit => "audit",
105 Self::Unknown => "unknown",
106 }
107 }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
113#[serde(rename_all = "lowercase")]
114pub enum Source {
115 #[default]
117 Cli,
118 Mcp,
120 Daemon,
122 #[serde(other)]
124 Unknown,
125}
126
127#[derive(Debug, Clone, Default, Serialize, Deserialize)]
131pub struct LogRecord {
132 #[serde(default)]
135 pub id: String,
136 #[serde(default)]
138 pub invocation_id: String,
139 #[serde(default)]
141 pub kind: RecordKind,
142 #[serde(default)]
144 pub timestamp: String,
145 #[serde(default)]
147 pub hostname: String,
148 #[serde(default)]
150 pub pid: u32,
151 #[serde(default)]
153 pub omni_dev_version: String,
154 #[serde(default)]
156 pub cwd: String,
157 #[serde(default)]
159 pub system_user: String,
160
161 #[serde(default, skip_serializing_if = "Vec::is_empty")]
164 pub command: Vec<String>,
165 #[serde(default, skip_serializing_if = "Vec::is_empty")]
167 pub command_line: Vec<String>,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub exit_code: Option<i32>,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub duration_ms: Option<u64>,
174 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
176 pub env: BTreeMap<String, String>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub source: Option<Source>,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub mcp_tool: Option<String>,
183
184 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub service: Option<String>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub method: Option<String>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub url: Option<String>,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub status_code: Option<u16>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub elapsed_ms: Option<u64>,
200 #[serde(default, skip_serializing_if = "is_false")]
202 pub via_daemon: bool,
203 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub daemon_session_id: Option<String>,
206 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub auth_principal: Option<String>,
210 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
212 pub request_headers: BTreeMap<String, String>,
213 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
215 pub response_headers: BTreeMap<String, String>,
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub request_body: Option<String>,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub response_body: Option<String>,
222 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
224 pub context: BTreeMap<String, String>,
225
226 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub error: Option<String>,
230}
231
232#[allow(clippy::trivially_copy_pass_by_ref)] fn is_false(b: &bool) -> bool {
235 !*b
236}
237
238impl LogRecord {
239 fn new(kind: RecordKind, invocation_id: String) -> Self {
241 Self {
242 id: new_id(),
243 invocation_id,
244 kind,
245 timestamp: now_rfc3339_millis(),
246 hostname: hostname(),
247 pid: std::process::id(),
248 omni_dev_version: crate::VERSION.to_string(),
249 cwd: cwd(),
250 system_user: system_user(),
251 ..Self::default()
252 }
253 }
254}
255
256#[derive(Debug, Clone)]
262pub struct RequestLogContext {
263 pub invocation_id: String,
265 pub source: Source,
267 pub mcp_tool: Option<String>,
269}
270
271impl Default for RequestLogContext {
272 fn default() -> Self {
273 Self {
274 invocation_id: new_id(),
275 source: Source::Cli,
276 mcp_tool: None,
277 }
278 }
279}
280
281impl RequestLogContext {
282 pub fn cli() -> Self {
284 Self {
285 invocation_id: new_id(),
286 source: Source::Cli,
287 mcp_tool: None,
288 }
289 }
290
291 pub fn mcp(tool: impl Into<String>) -> Self {
293 Self {
294 invocation_id: new_id(),
295 source: Source::Mcp,
296 mcp_tool: Some(tool.into()),
297 }
298 }
299}
300
301static GLOBAL: OnceLock<RequestLogContext> = OnceLock::new();
302
303tokio::task_local! {
304 pub static CTX: RequestLogContext;
306}
307
308pub fn set_global(ctx: RequestLogContext) {
311 let _ = GLOBAL.set(ctx);
312}
313
314pub fn current_context() -> RequestLogContext {
317 if let Ok(ctx) = CTX.try_with(RequestLogContext::clone) {
318 return ctx;
319 }
320 if let Some(ctx) = GLOBAL.get() {
321 return ctx.clone();
322 }
323 RequestLogContext::default()
324}
325
326pub async fn scope_origin_id<F, T>(origin_id: String, fut: F) -> T
336where
337 F: std::future::Future<Output = T>,
338{
339 let mut ctx = current_context();
340 ctx.invocation_id = origin_id;
341 CTX.scope(ctx, fut).await
342}
343
344pub fn disabled() -> bool {
346 disabled_with(&SystemEnv)
347}
348
349fn disabled_with(env: &impl EnvSource) -> bool {
351 truthy_var(env, "OMNI_DEV_LOG_DISABLE")
352}
353
354pub fn bodies_enabled() -> bool {
356 bodies_enabled_with(&SystemEnv)
357}
358
359fn bodies_enabled_with(env: &impl EnvSource) -> bool {
361 truthy_var(env, "OMNI_DEV_LOG_BODIES")
362}
363
364pub fn headers_enabled() -> bool {
366 truthy_var(&SystemEnv, "OMNI_DEV_LOG_HEADERS")
367}
368
369pub(crate) fn omni_dev_state_subpath(component: &str) -> Option<PathBuf> {
376 let base = dirs::state_dir().or_else(dirs::data_dir)?;
377 Some(base.join("omni-dev").join(component))
378}
379
380pub fn log_file_path() -> Option<PathBuf> {
383 log_file_path_with(&SystemEnv)
384}
385
386pub(crate) fn log_file_path_with(env: &impl EnvSource) -> Option<PathBuf> {
402 let path = non_empty_var(env, "OMNI_DEV_LOG_FILE")
403 .map(PathBuf::from)
404 .or_else(|| omni_dev_state_subpath(LOG_FILE_NAME))?;
405 if resolves_to_audit_file_with(&path, env) {
406 tracing::warn!(
407 "request_log: OMNI_DEV_LOG_FILE resolves to the audit log ({}); refusing to use it \
408 as the request log path — set OMNI_DEV_LOG_FILE and/or OMNI_DEV_AUDIT_LOG_FILE to \
409 distinct paths",
410 path.display()
411 );
412 return None;
413 }
414 Some(path)
415}
416
417pub fn audit_file_path() -> Option<PathBuf> {
435 test_audit_file_override().or_else(|| audit_file_path_with(&SystemEnv))
436}
437
438pub(crate) fn audit_file_path_with(env: &impl EnvSource) -> Option<PathBuf> {
446 non_empty_var(env, "OMNI_DEV_AUDIT_LOG_FILE")
447 .map(PathBuf::from)
448 .or_else(default_audit_file_path)
449}
450
451fn lexically_normalize(path: &Path) -> PathBuf {
461 use std::path::Component;
462
463 let mut out = PathBuf::new();
464 for component in path.components() {
465 match component {
466 Component::CurDir => {}
467 Component::ParentDir => match out.components().next_back() {
468 Some(Component::Normal(_)) => {
469 out.pop();
470 }
471 _ => out.push(".."),
472 },
473 other => out.push(other.as_os_str()),
474 }
475 }
476 out
477}
478
479const MAX_HAND_FOLLOWED_SYMLINK_HOPS: u32 = 8;
484
485fn normalize(path: &Path) -> PathBuf {
503 normalize_with_budget(&lexically_normalize(path), MAX_HAND_FOLLOWED_SYMLINK_HOPS)
504}
505
506fn normalize_with_budget(path: &Path, hops: u32) -> PathBuf {
514 if let Ok(canon) = std::fs::canonicalize(path) {
515 return canon;
516 }
517 if hops > 0 {
518 if let Ok(meta) = std::fs::symlink_metadata(path) {
519 if meta.file_type().is_symlink() {
520 if let Ok(target) = std::fs::read_link(path) {
521 let resolved = if target.is_absolute() {
522 target
523 } else {
524 path.parent()
525 .filter(|p| !p.as_os_str().is_empty())
526 .unwrap_or_else(|| Path::new("."))
527 .join(target)
528 };
529 return normalize_with_budget(&lexically_normalize(&resolved), hops - 1);
530 }
531 }
532 }
533 }
534 let Some(file_name) = path.file_name() else {
535 return path.to_path_buf();
536 };
537 let parent = path
538 .parent()
539 .filter(|p| !p.as_os_str().is_empty())
540 .unwrap_or_else(|| Path::new("."));
541 match std::fs::canonicalize(parent) {
542 Ok(canon_parent) => canon_parent.join(file_name),
543 Err(_) => path.to_path_buf(),
544 }
545}
546
547fn same_file(a: &Path, b: &Path) -> bool {
560 if let Ok(same) = same_file::is_same_file(a, b) {
561 return same;
562 }
563 normalize(a) == normalize(b)
564}
565
566fn resolves_to_audit_file(path: &Path) -> bool {
580 audit_file_path().is_some_and(|audit| same_file(path, &audit))
581}
582
583fn resolves_to_audit_file_with(path: &Path, env: &impl EnvSource) -> bool {
593 audit_file_path_with(env).is_some_and(|audit| same_file(path, &audit))
594}
595
596#[cfg(not(test))]
598fn test_audit_file_override() -> Option<PathBuf> {
599 None
600}
601
602#[cfg(test)]
623fn test_audit_file_override() -> Option<PathBuf> {
624 TEST_AUDIT_ROUTE
625 .with(|slot| slot.borrow().clone())
626 .or_else(default_audit_file_path)
627}
628
629#[cfg(test)]
630thread_local! {
631 pub(crate) static TEST_AUDIT_ROUTE: std::cell::RefCell<Option<PathBuf>> =
632 const { std::cell::RefCell::new(None) };
633}
634
635#[cfg(not(test))]
637fn default_audit_file_path() -> Option<PathBuf> {
638 omni_dev_state_subpath(AUDIT_FILE_NAME)
639}
640
641#[cfg(test)]
660#[allow(clippy::expect_used)]
661fn default_audit_file_path() -> Option<PathBuf> {
662 use std::sync::OnceLock;
663 static PATH: OnceLock<PathBuf> = OnceLock::new();
664 Some(
665 PATH.get_or_init(|| {
666 let dir =
667 tempfile::tempdir().expect("failed to create a scratch dir for test audit logs");
668 let path = dir.path().join(AUDIT_FILE_NAME);
669 std::mem::forget(dir);
670 path
671 })
672 .clone(),
673 )
674}
675
676pub fn record(entry: &LogRecord) {
679 if disabled() {
680 return;
681 }
682 if let Err(e) = try_record(entry) {
683 tracing::debug!("request_log: failed to append record: {e}");
684 }
685}
686
687fn try_record(entry: &LogRecord) -> anyhow::Result<()> {
694 use anyhow::{ensure, Context};
695
696 ensure!(
697 entry.kind != RecordKind::Audit,
698 "an audit-kind record must go through record_audit(), not record()"
699 );
700 let path = log_file_path().context("could not resolve the log file path")?;
701 append_record_to(&path, entry, append_line)
702}
703
704pub fn record_audit(entry: &LogRecord) -> anyhow::Result<()> {
737 use anyhow::Context;
738
739 let path = audit_file_path().context("could not resolve the audit log file path")?;
740 record_audit_to(path, entry)
741}
742
743#[cfg(test)]
751fn record_audit_with(env: &impl EnvSource, entry: &LogRecord) -> anyhow::Result<()> {
752 use anyhow::Context;
753
754 let path = audit_file_path_with(env).context("could not resolve the audit log file path")?;
755 record_audit_to(path, entry)
756}
757
758fn record_audit_to(path: PathBuf, entry: &LogRecord) -> anyhow::Result<()> {
761 use anyhow::ensure;
762
763 ensure!(
764 entry.kind == RecordKind::Audit,
765 "record_audit() requires a RecordKind::Audit entry, got {:?}",
766 entry.kind
767 );
768 append_record_to(&path, entry, append_line_synced)
769}
770
771fn append_record_to(
779 path: &Path,
780 entry: &LogRecord,
781 append: fn(&Path, &str) -> anyhow::Result<()>,
782) -> anyhow::Result<()> {
783 use anyhow::Context;
784
785 if let Some(parent) = path.parent() {
789 if !parent.as_os_str().is_empty() && !parent.exists() {
790 crate::daemon::paths::ensure_dir_0700(parent)?;
791 }
792 }
793 let mut line = serde_json::to_string(entry).context("failed to serialize record")?;
794 line.push('\n');
795 append(path, &line)?;
796 Ok(())
797}
798
799#[cfg(unix)]
811fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
812 if let Some(cfg) = rotation_config() {
815 return append_with_rotation(path, line, &cfg);
816 }
817 append_line_no_rotation(path, line)
818}
819
820#[cfg(unix)]
826fn append_line_no_rotation(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
827 append_line_unrotated(path, line, false)
828}
829
830fn append_line_synced(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
846 append_line_unrotated(path, line, true)
847}
848
849#[cfg(unix)]
850fn append_line_unrotated(path: &std::path::Path, line: &str, sync: bool) -> anyhow::Result<()> {
851 use std::os::unix::fs::OpenOptionsExt;
852
853 let file = std::fs::OpenOptions::new()
854 .append(true)
855 .create(true)
856 .mode(0o600)
857 .open(path)?;
858 crate::daemon::paths::ensure_handle_0600(&file)?;
859
860 let sync_parent = sync && file.metadata()?.len() == 0;
886
887 if bodies_enabled() {
888 match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive) {
889 Ok(mut guard) => {
890 guard.write_all(line.as_bytes())?;
891 if sync {
892 guard.sync_data()?;
893 }
894 }
895 Err((mut file, _)) => {
896 file.write_all(line.as_bytes())?;
897 if sync {
898 file.sync_data()?;
899 }
900 }
901 }
902 } else {
903 let mut file = file;
904 file.write_all(line.as_bytes())?;
905 if sync {
906 file.sync_data()?;
907 }
908 }
909
910 if sync_parent {
911 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
912 std::fs::File::open(parent)?.sync_all()?;
913 }
914 }
915 Ok(())
916}
917
918#[cfg(not(unix))]
924fn append_line(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
925 append_line_no_rotation(path, line)
926}
927
928#[cfg(not(unix))]
930fn append_line_no_rotation(path: &std::path::Path, line: &str) -> anyhow::Result<()> {
931 append_line_unrotated(path, line, false)
932}
933
934#[cfg(not(unix))]
938fn append_line_unrotated(path: &std::path::Path, line: &str, sync: bool) -> anyhow::Result<()> {
939 let mut file = std::fs::OpenOptions::new()
940 .append(true)
941 .create(true)
942 .open(path)?;
943 file.write_all(line.as_bytes())?;
944 if sync {
945 file.sync_data()?;
946 }
947 Ok(())
948}
949
950fn sibling(path: &Path, suffix: &str) -> PathBuf {
964 let mut name = path.as_os_str().to_owned();
965 name.push(suffix);
966 PathBuf::from(name)
967}
968
969pub(crate) fn parse_size(s: &str) -> anyhow::Result<u64> {
973 use anyhow::Context as _;
974
975 let lower = s.trim().to_ascii_lowercase();
976 if lower.is_empty() {
977 anyhow::bail!("empty size (expected e.g. 10mb, 512kb, 1048576)");
978 }
979 let split = lower
980 .find(|c: char| !c.is_ascii_digit() && c != '.')
981 .unwrap_or(lower.len());
982 let (num, unit) = lower.split_at(split);
983 let value: f64 = num
984 .parse()
985 .with_context(|| format!("invalid size number: {s}"))?;
986 if !value.is_finite() || value < 0.0 {
987 anyhow::bail!("invalid size: {s}");
988 }
989 let mult: u64 = match unit.trim() {
990 "" | "b" => 1,
991 "k" | "kb" | "kib" => 1024,
992 "m" | "mb" | "mib" => 1024 * 1024,
993 "g" | "gb" | "gib" => 1024 * 1024 * 1024,
994 other => anyhow::bail!("invalid size unit: {other} (use b, kb, mb, or gb)"),
995 };
996 Ok((value * mult as f64) as u64)
997}
998
999#[cfg(unix)]
1003struct RotationConfig {
1004 max_size: u64,
1006 keep_files: u32,
1008}
1009
1010#[cfg(unix)]
1014fn rotation_config() -> Option<RotationConfig> {
1015 let raw = std::env::var("OMNI_DEV_LOG_MAX_SIZE").ok()?;
1016 if raw.trim().is_empty() {
1017 return None;
1018 }
1019 let max_size = match parse_size(&raw) {
1020 Ok(0) => return None,
1021 Ok(n) => n,
1022 Err(e) => {
1023 tracing::debug!("request_log: ignoring invalid OMNI_DEV_LOG_MAX_SIZE: {e}");
1024 return None;
1025 }
1026 };
1027 let keep_files = std::env::var("OMNI_DEV_LOG_KEEP_FILES")
1028 .ok()
1029 .and_then(|v| v.trim().parse::<u32>().ok())
1030 .unwrap_or(DEFAULT_KEEP_FILES);
1031 Some(RotationConfig {
1032 max_size,
1033 keep_files,
1034 })
1035}
1036
1037#[cfg(unix)]
1041fn rotate(path: &Path, keep_files: u32) -> anyhow::Result<()> {
1042 if keep_files == 0 {
1043 let _ = std::fs::remove_file(path);
1046 return Ok(());
1047 }
1048 let _ = std::fs::remove_file(sibling(path, &format!(".{keep_files}")));
1050 for i in (1..keep_files).rev() {
1051 let from = sibling(path, &format!(".{i}"));
1052 if from.exists() {
1053 std::fs::rename(&from, sibling(path, &format!(".{}", i + 1)))?;
1054 }
1055 }
1056 std::fs::rename(path, sibling(path, ".1"))?;
1057 Ok(())
1058}
1059
1060#[cfg(unix)]
1073fn append_with_rotation(path: &Path, line: &str, cfg: &RotationConfig) -> anyhow::Result<()> {
1074 use std::os::unix::fs::OpenOptionsExt;
1075
1076 if resolves_to_audit_file(path) {
1077 return append_line_no_rotation(path, line);
1078 }
1079
1080 let lock_path = sibling(path, ".lock");
1081 let lock_file = std::fs::OpenOptions::new()
1082 .create(true)
1083 .write(true)
1084 .truncate(false)
1085 .mode(0o600)
1086 .open(&lock_path)?;
1087 crate::daemon::paths::ensure_handle_0600(&lock_file)?;
1088 let _guard = nix::fcntl::Flock::lock(lock_file, nix::fcntl::FlockArg::LockExclusive).ok();
1091
1092 let current = std::fs::metadata(path).map_or(0, |m| m.len());
1093 if current > 0 && current.saturating_add(line.len() as u64) > cfg.max_size {
1094 if let Err(e) = rotate(path, cfg.keep_files) {
1095 tracing::debug!("request_log: rotation failed, appending without rotating: {e}");
1096 }
1097 }
1098
1099 let mut file = std::fs::OpenOptions::new()
1100 .append(true)
1101 .create(true)
1102 .mode(0o600)
1103 .open(path)?;
1104 crate::daemon::paths::ensure_handle_0600(&file)?;
1105 file.write_all(line.as_bytes())?;
1106 Ok(())
1107}
1108
1109pub struct PruneOptions {
1111 pub older_than: Option<DateTime<Utc>>,
1115 pub max_size: Option<u64>,
1118 pub dry_run: bool,
1120}
1121
1122pub struct PruneOutcome {
1124 pub removed: usize,
1126 pub kept: usize,
1128 pub bytes_before: u64,
1130 pub bytes_after: u64,
1132}
1133
1134pub fn prune(path: &Path, opts: &PruneOptions) -> anyhow::Result<PruneOutcome> {
1151 use anyhow::Context as _;
1152
1153 anyhow::ensure!(
1154 !resolves_to_audit_file(path),
1155 "refusing to prune {}: it resolves to the audit log, which is exempt from pruning by \
1156 design (ADR-0080 §11); read it with `omni-dev log --audit` instead",
1157 path.display()
1158 );
1159
1160 let data = match std::fs::read(path) {
1161 Ok(data) => data,
1162 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1163 return Ok(PruneOutcome {
1164 removed: 0,
1165 kept: 0,
1166 bytes_before: 0,
1167 bytes_after: 0,
1168 });
1169 }
1170 Err(e) => return Err(e).context("failed to read the log file"),
1171 };
1172 let bytes_before = data.len() as u64;
1173 let text = String::from_utf8_lossy(&data);
1174
1175 let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
1178 let aged: Vec<&str> = all
1179 .iter()
1180 .copied()
1181 .filter(|line| keep_by_age(line, opts.older_than))
1182 .collect();
1183
1184 let kept: &[&str] = match opts.max_size {
1185 None => &aged,
1186 Some(max) => keep_by_size(&aged, max),
1187 };
1188
1189 let bytes_after: u64 = kept.iter().map(|l| l.len() as u64 + 1).sum();
1190 let outcome = PruneOutcome {
1191 removed: all.len() - kept.len(),
1192 kept: kept.len(),
1193 bytes_before,
1194 bytes_after,
1195 };
1196
1197 if !opts.dry_run && outcome.removed > 0 {
1198 rewrite_atomically(path, kept)?;
1199 }
1200 Ok(outcome)
1201}
1202
1203fn keep_by_age(line: &str, older_than: Option<DateTime<Utc>>) -> bool {
1206 let Some(cutoff) = older_than else {
1207 return true;
1208 };
1209 match serde_json::from_str::<LogRecord>(line) {
1210 Ok(rec) => match DateTime::parse_from_rfc3339(&rec.timestamp) {
1211 Ok(ts) => ts.with_timezone(&Utc) >= cutoff,
1212 Err(_) => true,
1213 },
1214 Err(_) => true,
1215 }
1216}
1217
1218fn keep_by_size<'a>(lines: &'a [&'a str], max: u64) -> &'a [&'a str] {
1221 let mut acc = 0u64;
1222 let mut start = lines.len();
1223 for (i, line) in lines.iter().enumerate().rev() {
1224 acc += line.len() as u64 + 1;
1225 if acc > max {
1226 break;
1227 }
1228 start = i;
1229 }
1230 if start == lines.len() && !lines.is_empty() {
1231 start = lines.len() - 1; }
1233 &lines[start..]
1234}
1235
1236fn rewrite_atomically(path: &Path, lines: &[&str]) -> anyhow::Result<()> {
1239 let tmp = sibling(path, &format!(".prune.{}.tmp", std::process::id()));
1240 let result = (|| -> anyhow::Result<()> {
1241 let mut options = std::fs::OpenOptions::new();
1242 options.create(true).write(true).truncate(true);
1243 #[cfg(unix)]
1244 {
1245 use std::os::unix::fs::OpenOptionsExt;
1246 options.mode(0o600);
1247 }
1248 let mut file = options.open(&tmp)?;
1249 #[cfg(unix)]
1250 crate::daemon::paths::ensure_handle_0600(&file)?;
1251 for line in lines {
1252 file.write_all(line.as_bytes())?;
1253 file.write_all(b"\n")?;
1254 }
1255 file.flush()?;
1256 std::fs::rename(&tmp, path)?;
1257 Ok(())
1258 })();
1259 if result.is_err() {
1260 let _ = std::fs::remove_file(&tmp);
1261 }
1262 result
1263}
1264
1265#[derive(Debug, Clone)]
1267pub struct InvocationOutcome {
1268 pub command: Vec<String>,
1270 pub command_line: Vec<String>,
1272 pub exit_code: i32,
1274 pub error: Option<String>,
1276 pub duration: Duration,
1278}
1279
1280pub fn record_invocation(outcome: InvocationOutcome) {
1282 let ctx = current_context();
1283 let mut rec = LogRecord::new(RecordKind::Invocation, ctx.invocation_id);
1284 rec.source = Some(ctx.source);
1285 rec.mcp_tool = ctx.mcp_tool;
1286 rec.command = outcome.command;
1287 rec.command_line = scrub_argv(&outcome.command_line);
1288 rec.exit_code = Some(outcome.exit_code);
1289 rec.error = outcome.error;
1290 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
1291 rec.env = whitelisted_env();
1292 record(&rec);
1293}
1294
1295#[derive(Debug, Clone)]
1298pub struct GhOutcome {
1299 pub label: String,
1302 pub argv: Vec<String>,
1304 pub exit_code: Option<i32>,
1306 pub duration: Duration,
1308 pub error: Option<String>,
1310}
1311
1312pub fn record_gh(outcome: GhOutcome) {
1317 record(&build_gh_record(outcome, current_context()));
1318}
1319
1320fn build_gh_record(outcome: GhOutcome, ctx: RequestLogContext) -> LogRecord {
1324 let mut rec = LogRecord::new(RecordKind::Gh, ctx.invocation_id);
1325 rec.source = Some(ctx.source);
1326 rec.mcp_tool = ctx.mcp_tool;
1327 rec.command = outcome
1328 .label
1329 .split(' ')
1330 .filter(|s| !s.is_empty())
1331 .map(str::to_string)
1332 .collect();
1333 rec.command_line = scrub_argv(&outcome.argv);
1337 rec.exit_code = outcome.exit_code;
1338 rec.error = outcome.error;
1339 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
1340 rec
1341}
1342
1343#[derive(Debug, Clone)]
1346pub struct WorktreeOutcome {
1347 pub verb: String,
1351 pub argv: Vec<String>,
1354 pub exit_code: Option<i32>,
1356 pub duration: Duration,
1358 pub error: Option<String>,
1360 pub context: BTreeMap<String, String>,
1363}
1364
1365pub fn record_worktree(outcome: WorktreeOutcome) {
1371 record(&build_worktree_record(outcome, current_context()));
1372}
1373
1374fn build_worktree_record(outcome: WorktreeOutcome, ctx: RequestLogContext) -> LogRecord {
1379 let mut rec = LogRecord::new(RecordKind::Worktree, ctx.invocation_id);
1380 rec.source = Some(ctx.source);
1381 rec.mcp_tool = ctx.mcp_tool;
1382 rec.service = Some("worktree".to_string());
1384 rec.command = vec!["git".to_string(), "worktree".to_string(), outcome.verb];
1385 rec.command_line = scrub_argv(&outcome.argv);
1386 rec.exit_code = outcome.exit_code;
1387 rec.error = outcome.error;
1388 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
1389 rec.context = outcome.context;
1390 rec
1391}
1392
1393#[derive(Debug, Clone, Default)]
1400pub struct DriveMutationOutcome {
1401 pub operation: &'static str,
1404 pub file_id: String,
1406 pub file_name: String,
1408 pub status: String,
1412 pub added_principals: Vec<String>,
1416 pub removed_principals: Vec<String>,
1419 pub crosses_drive_boundary: bool,
1421 pub resolved_folder_id: Option<String>,
1430 pub decided_by_folder_id: Option<String>,
1434 pub decided_by_depth: Option<usize>,
1436 pub decided_by_file_id: Option<String>,
1446 pub range: Option<String>,
1449 pub updated_range: Option<String>,
1453 pub updated_rows: Option<i64>,
1455 pub updated_columns: Option<i64>,
1457 pub updated_cells: Option<i64>,
1460 pub sheet_id: Option<i64>,
1465 pub sheet_title: Option<String>,
1468 pub sheet_new_title: Option<String>,
1473 pub dimension_range: Option<String>,
1478 pub move_to: Option<i64>,
1483 pub grid_range: Option<String>,
1489 pub occurrences_changed: Option<i64>,
1496 pub inserted_chars: Option<i64>,
1498 pub required_revision_id: Option<String>,
1506 pub fields_changed: Option<String>,
1514 pub discarded_cells: Vec<String>,
1519 pub overwritten_cells: Vec<String>,
1527 pub overwritten_cells_upper_bound: bool,
1542 pub validation_type: Option<String>,
1547 pub protected_range_id: Option<i64>,
1554 pub protection_editors_added: Vec<String>,
1558 pub protection_editors_removed: Vec<String>,
1561 pub filter_view_id: Option<i64>,
1570 pub named_range_id: Option<String>,
1576 pub referencing_formula_locations: Vec<String>,
1582 pub embedded_object_id: Option<i64>,
1591 pub banded_range_id: Option<i64>,
1597 pub error: Option<String>,
1599 pub duration: Duration,
1601}
1602
1603pub fn record_drive_mutation(outcome: DriveMutationOutcome) {
1622 record(&build_drive_mutation_record(outcome, current_context()));
1623}
1624
1625fn build_drive_mutation_record(outcome: DriveMutationOutcome, ctx: RequestLogContext) -> LogRecord {
1629 let mut rec = LogRecord::new(RecordKind::DriveMutation, ctx.invocation_id);
1630 rec.source = Some(ctx.source);
1631 rec.mcp_tool = ctx.mcp_tool;
1632 rec.service = Some("drive".to_string());
1633 rec.command = vec!["drive".to_string(), outcome.operation.to_string()];
1634 rec.error = outcome.error;
1635 rec.duration_ms = Some(outcome.duration.as_millis() as u64);
1636
1637 let mut context = BTreeMap::new();
1638 context.insert("file_id".to_string(), outcome.file_id);
1639 context.insert("file_name".to_string(), outcome.file_name);
1640 context.insert("status".to_string(), outcome.status);
1641 if !outcome.added_principals.is_empty() {
1642 context.insert(
1643 "added_principals".to_string(),
1644 outcome.added_principals.join(","),
1645 );
1646 }
1647 if !outcome.removed_principals.is_empty() {
1648 context.insert(
1649 "removed_principals".to_string(),
1650 outcome.removed_principals.join(","),
1651 );
1652 }
1653 if outcome.crosses_drive_boundary {
1654 context.insert("crosses_drive_boundary".to_string(), "true".to_string());
1655 }
1656 if let Some(resolved_folder_id) = outcome.resolved_folder_id {
1657 context.insert("resolved_folder_id".to_string(), resolved_folder_id);
1658 }
1659 if let Some(decided_by_folder_id) = outcome.decided_by_folder_id {
1660 context.insert("decided_by_folder_id".to_string(), decided_by_folder_id);
1661 }
1662 if let Some(decided_by_depth) = outcome.decided_by_depth {
1663 context.insert("decided_by_depth".to_string(), decided_by_depth.to_string());
1664 }
1665 if let Some(decided_by_file_id) = outcome.decided_by_file_id {
1666 context.insert("decided_by_file_id".to_string(), decided_by_file_id);
1667 }
1668 if let Some(range) = outcome.range {
1669 context.insert("range".to_string(), range);
1670 }
1671 if let Some(updated_range) = outcome.updated_range {
1672 context.insert("updated_range".to_string(), updated_range);
1673 }
1674 if let Some(rows) = outcome.updated_rows {
1675 context.insert("updated_rows".to_string(), rows.to_string());
1676 }
1677 if let Some(cols) = outcome.updated_columns {
1678 context.insert("updated_columns".to_string(), cols.to_string());
1679 }
1680 if let Some(cells) = outcome.updated_cells {
1681 context.insert("updated_cells".to_string(), cells.to_string());
1682 }
1683 if let Some(sheet_id) = outcome.sheet_id {
1684 context.insert("sheet_id".to_string(), sheet_id.to_string());
1685 }
1686 if let Some(sheet_title) = outcome.sheet_title {
1687 context.insert("sheet_title".to_string(), sheet_title);
1688 }
1689 if let Some(sheet_new_title) = outcome.sheet_new_title {
1690 context.insert("sheet_new_title".to_string(), sheet_new_title);
1691 }
1692 if let Some(dimension_range) = outcome.dimension_range {
1693 context.insert("dimension_range".to_string(), dimension_range);
1694 }
1695 if let Some(move_to) = outcome.move_to {
1696 context.insert("move_to".to_string(), move_to.to_string());
1697 }
1698 if let Some(grid_range) = outcome.grid_range {
1699 context.insert("grid_range".to_string(), grid_range);
1700 }
1701 if let Some(occurrences) = outcome.occurrences_changed {
1702 context.insert("occurrences_changed".to_string(), occurrences.to_string());
1703 }
1704 if let Some(chars) = outcome.inserted_chars {
1705 context.insert("inserted_chars".to_string(), chars.to_string());
1706 }
1707 if let Some(revision) = outcome.required_revision_id {
1708 context.insert("required_revision_id".to_string(), revision);
1709 }
1710 if let Some(fields_changed) = outcome.fields_changed {
1711 context.insert("fields_changed".to_string(), fields_changed);
1712 }
1713 if !outcome.discarded_cells.is_empty() {
1714 context.insert(
1715 "discarded_cells".to_string(),
1716 outcome.discarded_cells.join("; "),
1717 );
1718 }
1719 if !outcome.overwritten_cells.is_empty() {
1720 context.insert(
1721 "overwritten_cells".to_string(),
1722 outcome.overwritten_cells.join(", "),
1723 );
1724 if outcome.overwritten_cells_upper_bound {
1727 context.insert(
1728 "overwritten_cells_are_upper_bound".to_string(),
1729 "true".to_string(),
1730 );
1731 }
1732 }
1733 if let Some(validation_type) = outcome.validation_type {
1734 context.insert("validation_type".to_string(), validation_type);
1735 }
1736 if let Some(protected_range_id) = outcome.protected_range_id {
1737 context.insert(
1738 "protected_range_id".to_string(),
1739 protected_range_id.to_string(),
1740 );
1741 }
1742 if !outcome.protection_editors_added.is_empty() {
1743 context.insert(
1744 "protection_editors_added".to_string(),
1745 outcome.protection_editors_added.join(","),
1746 );
1747 }
1748 if !outcome.protection_editors_removed.is_empty() {
1749 context.insert(
1750 "protection_editors_removed".to_string(),
1751 outcome.protection_editors_removed.join(","),
1752 );
1753 }
1754 if let Some(filter_view_id) = outcome.filter_view_id {
1755 context.insert("filter_view_id".to_string(), filter_view_id.to_string());
1756 }
1757 if let Some(named_range_id) = outcome.named_range_id {
1758 context.insert("named_range_id".to_string(), named_range_id);
1759 }
1760 if !outcome.referencing_formula_locations.is_empty() {
1761 context.insert(
1762 "referencing_formula_count".to_string(),
1763 outcome.referencing_formula_locations.len().to_string(),
1764 );
1765 context.insert(
1766 "referencing_formula_locations".to_string(),
1767 outcome.referencing_formula_locations.join("; "),
1768 );
1769 }
1770 if let Some(embedded_object_id) = outcome.embedded_object_id {
1771 context.insert(
1772 "embedded_object_id".to_string(),
1773 embedded_object_id.to_string(),
1774 );
1775 }
1776 if let Some(banded_range_id) = outcome.banded_range_id {
1777 context.insert("banded_range_id".to_string(), banded_range_id.to_string());
1778 }
1779 rec.context = context;
1780 rec
1781}
1782
1783#[derive(Debug, Clone, Default)]
1792pub struct AuditOutcome {
1793 pub command: Vec<String>,
1797 pub integration: &'static str,
1800 pub file_id: String,
1802 pub lease_id: Option<String>,
1806 pub verdict: String,
1817 pub version_before: Option<String>,
1819 pub version_after: Option<String>,
1823 pub modified_time_before: Option<String>,
1825 pub modified_time_after: Option<String>,
1827 pub backup_location: Option<String>,
1830 pub backup_sha256: Option<String>,
1834 pub backup_size: Option<u64>,
1836 pub auth_policy: Option<String>,
1839 pub restored_from_lease_id: Option<String>,
1845 pub superseded_lease_id: Option<String>,
1858 pub error: Option<String>,
1862}
1863
1864pub fn record_audit_event(outcome: AuditOutcome) -> anyhow::Result<()> {
1873 record_audit(&build_audit_record(outcome, current_context()))
1874}
1875
1876#[cfg(test)]
1879fn record_audit_event_with(env: &impl EnvSource, outcome: AuditOutcome) -> anyhow::Result<()> {
1880 record_audit_with(env, &build_audit_record(outcome, current_context()))
1881}
1882
1883fn build_audit_record(outcome: AuditOutcome, ctx: RequestLogContext) -> LogRecord {
1887 let mut rec = LogRecord::new(RecordKind::Audit, ctx.invocation_id);
1888 rec.source = Some(ctx.source);
1889 rec.mcp_tool = ctx.mcp_tool;
1890 rec.service = Some("drive".to_string());
1891 rec.command = outcome.command;
1892 rec.error = outcome.error;
1893
1894 let mut context = BTreeMap::new();
1895 context.insert("integration".to_string(), outcome.integration.to_string());
1896 context.insert("file_id".to_string(), outcome.file_id);
1897 if let Some(lease_id) = outcome.lease_id {
1898 context.insert("lease_id".to_string(), lease_id);
1899 }
1900 context.insert("verdict".to_string(), outcome.verdict);
1901 if let Some(version) = outcome.version_before {
1902 context.insert("version_before".to_string(), version);
1903 }
1904 if let Some(version) = outcome.version_after {
1905 context.insert("version_after".to_string(), version);
1906 }
1907 if let Some(modified_time) = outcome.modified_time_before {
1908 context.insert("modified_time_before".to_string(), modified_time);
1909 }
1910 if let Some(modified_time) = outcome.modified_time_after {
1911 context.insert("modified_time_after".to_string(), modified_time);
1912 }
1913 if let Some(location) = outcome.backup_location {
1914 context.insert("backup_location".to_string(), location);
1915 }
1916 if let Some(sha256) = outcome.backup_sha256 {
1917 context.insert("backup_sha256".to_string(), sha256);
1918 }
1919 if let Some(size) = outcome.backup_size {
1920 context.insert("backup_size".to_string(), size.to_string());
1921 }
1922 if let Some(auth_policy) = outcome.auth_policy {
1923 context.insert("auth_policy".to_string(), auth_policy);
1924 }
1925 if let Some(restored_from) = outcome.restored_from_lease_id {
1926 context.insert("restored_from_lease_id".to_string(), restored_from);
1927 }
1928 if let Some(superseded) = outcome.superseded_lease_id {
1929 context.insert("superseded_lease_id".to_string(), superseded);
1930 }
1931 rec.context = context;
1932 rec
1933}
1934
1935#[derive(Debug, Clone, Default)]
1938pub struct HttpExtra {
1939 pub via_daemon: bool,
1941 pub daemon_session_id: Option<String>,
1943 pub auth_principal: Option<String>,
1945 pub request_headers: BTreeMap<String, String>,
1947 pub response_headers: BTreeMap<String, String>,
1949 pub request_body: Option<String>,
1951 pub response_body: Option<String>,
1953 pub context: BTreeMap<String, String>,
1955}
1956
1957pub fn record_http(
1959 service: &str,
1960 method: &str,
1961 url: &str,
1962 started: Instant,
1963 status: Option<u16>,
1964 error: Option<&str>,
1965) {
1966 record_http_with(
1967 service,
1968 method,
1969 url,
1970 started,
1971 status,
1972 error,
1973 HttpExtra::default(),
1974 );
1975}
1976
1977pub fn record_http_result(
1983 service: &str,
1984 method: &str,
1985 url: &str,
1986 started: Instant,
1987 result: &reqwest::Result<reqwest::Response>,
1988) {
1989 match result {
1990 Ok(response) => {
1991 record_http(
1992 service,
1993 method,
1994 url,
1995 started,
1996 Some(response.status().as_u16()),
1997 None,
1998 );
1999 }
2000 Err(error) => {
2001 record_http(
2002 service,
2003 method,
2004 url,
2005 started,
2006 None,
2007 Some(&error.to_string()),
2008 );
2009 }
2010 }
2011}
2012
2013#[allow(clippy::too_many_arguments)]
2020pub fn record_http_with(
2021 service: &str,
2022 method: &str,
2023 url: &str,
2024 started: Instant,
2025 status: Option<u16>,
2026 error: Option<&str>,
2027 extra: HttpExtra,
2028) {
2029 if disabled() {
2030 return;
2031 }
2032 let ctx = current_context();
2033 let mut rec = LogRecord::new(RecordKind::Http, ctx.invocation_id);
2034 rec.source = Some(ctx.source);
2035 rec.mcp_tool = ctx.mcp_tool;
2036 rec.service = Some(service.to_string());
2037 rec.method = Some(method.to_string());
2038 rec.url = Some(redact_url(url));
2039 rec.status_code = status;
2040 rec.elapsed_ms = Some(started.elapsed().as_millis() as u64);
2041 rec.error = error.map(str::to_string);
2042 rec.via_daemon = extra.via_daemon;
2043 rec.daemon_session_id = extra.daemon_session_id;
2044 rec.auth_principal = extra.auth_principal;
2045 rec.context = extra.context;
2046 if headers_enabled() {
2047 rec.request_headers = redact_headers(&extra.request_headers);
2048 rec.response_headers = redact_headers(&extra.response_headers);
2049 }
2050 if bodies_enabled() {
2051 rec.request_body = extra.request_body;
2052 rec.response_body = extra.response_body;
2053 }
2054 record(&rec);
2055}
2056
2057const SENSITIVE_HEADERS: &[&str] = &[
2059 "authorization",
2060 "proxy-authorization",
2061 "cookie",
2062 "set-cookie",
2063 "x-api-key",
2064 "api-key",
2065 "dd-api-key",
2066 "dd-application-key",
2067 "x-datadog-api-key",
2068 "x-datadog-application-key",
2069 "x-omni-bridge",
2070 "x-omni-bridge-target",
2071];
2072
2073const SENSITIVE_HEADER_MARKERS: &[&str] = &[
2077 "auth",
2078 "token",
2079 "secret",
2080 "key",
2081 "cookie",
2082 "password",
2083 "session",
2084 "signature",
2085 "credential",
2086];
2087
2088pub fn redact_headers(headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
2093 headers
2094 .iter()
2095 .map(|(name, value)| {
2096 let lower = name.to_ascii_lowercase();
2097 let redacted = SENSITIVE_HEADERS.contains(&lower.as_str())
2098 || SENSITIVE_HEADER_MARKERS
2099 .iter()
2100 .any(|marker| lower.contains(marker));
2101 (
2102 name.clone(),
2103 if redacted {
2104 "REDACTED".to_string()
2105 } else {
2106 value.clone()
2107 },
2108 )
2109 })
2110 .collect()
2111}
2112
2113const SECRETISH_FLAG_WORDS: &[&str] = &["token", "secret", "password", "passwd", "key"];
2117
2118fn is_secretish_flag(name: &str) -> bool {
2122 let segments: Vec<String> = name
2123 .split(['-', '_'])
2124 .map(str::to_ascii_lowercase)
2125 .collect();
2126 let takes_path = matches!(segments.last().map(String::as_str), Some("file" | "path"));
2127 !takes_path
2128 && segments
2129 .iter()
2130 .any(|segment| SECRETISH_FLAG_WORDS.contains(&segment.as_str()))
2131}
2132
2133fn scrub_header_arg(value: &str) -> Option<String> {
2137 let Some((name, _)) = value.split_once(':') else {
2138 return Some("REDACTED".to_string());
2139 };
2140 SENSITIVE_HEADERS
2141 .contains(&name.trim().to_ascii_lowercase().as_str())
2142 .then(|| format!("{}: REDACTED", name.trim()))
2143}
2144
2145fn scrub_flag_value(name: &str, value: &str) -> Option<String> {
2149 match name {
2150 "header" => scrub_header_arg(value),
2151 "body" => (!value.starts_with('@')).then(|| "REDACTED".to_string()),
2152 "find" | "replacement" => Some("REDACTED".to_string()),
2153 _ if is_secretish_flag(name) => Some("REDACTED".to_string()),
2154 _ => None,
2155 }
2156}
2157
2158fn scrub_argv(argv: &[String]) -> Vec<String> {
2170 scrub_flag_secrets(argv)
2171 .iter()
2172 .map(|arg| redact_url(arg))
2173 .collect()
2174}
2175
2176fn scrub_flag_secrets(argv: &[String]) -> Vec<String> {
2181 let mut out = Vec::with_capacity(argv.len());
2182 let mut i = 0;
2183 while i < argv.len() {
2184 let arg = &argv[i];
2185 i += 1;
2186 let Some(flag_body) = arg.strip_prefix("--") else {
2187 out.push(arg.clone());
2188 continue;
2189 };
2190 if let Some((name, value)) = flag_body.split_once('=') {
2191 match scrub_flag_value(name, value) {
2192 Some(scrubbed) => out.push(format!("--{name}={scrubbed}")),
2193 None => out.push(arg.clone()),
2194 }
2195 } else {
2196 out.push(arg.clone());
2197 let takes_secret_value =
2198 matches!(flag_body, "header" | "body" | "find" | "replacement")
2199 || is_secretish_flag(flag_body);
2200 if takes_secret_value {
2201 if let Some(value) = argv.get(i) {
2202 i += 1;
2203 out.push(scrub_flag_value(flag_body, value).unwrap_or_else(|| value.clone()));
2204 }
2205 }
2206 }
2207 }
2208 out
2209}
2210
2211const SENSITIVE_QUERY_KEYS: &[&str] = &["sig", "sas", "jwt", "auth"];
2213
2214const SENSITIVE_QUERY_KEY_SUFFIXES: &[&str] = &[
2217 "token",
2218 "secret",
2219 "password",
2220 "passwd",
2221 "signature",
2222 "apikey",
2223 "api_key",
2224 "api-key",
2225];
2226
2227const SENSITIVE_QUERY_KEY_PREFIXES: &[&str] = &["x-amz-", "x-goog-"];
2229
2230fn sensitive_query_key(key: &str) -> bool {
2232 let key = key.to_ascii_lowercase();
2233 SENSITIVE_QUERY_KEYS.contains(&key.as_str())
2234 || SENSITIVE_QUERY_KEY_SUFFIXES
2235 .iter()
2236 .any(|suffix| key.ends_with(suffix))
2237 || SENSITIVE_QUERY_KEY_PREFIXES
2238 .iter()
2239 .any(|prefix| key.starts_with(prefix))
2240}
2241
2242fn redact_pairs(pairs: &str) -> String {
2246 pairs
2247 .split('&')
2248 .map(|segment| match segment.split_once('=') {
2249 Some((raw_key, _)) => {
2250 let sensitive = url::form_urlencoded::parse(raw_key.as_bytes())
2253 .next()
2254 .is_some_and(|(key, _)| sensitive_query_key(&key));
2255 if sensitive {
2256 format!("{raw_key}=REDACTED")
2257 } else {
2258 segment.to_string()
2259 }
2260 }
2261 None => segment.to_string(),
2263 })
2264 .collect::<Vec<_>>()
2265 .join("&")
2266}
2267
2268fn redact_url(url: &str) -> String {
2274 let (rest, fragment) = url
2275 .split_once('#')
2276 .map_or((url, None), |(rest, fragment)| (rest, Some(fragment)));
2277 let (prefix, query) = rest
2278 .split_once('?')
2279 .map_or((rest, None), |(prefix, query)| (prefix, Some(query)));
2280 let mut out = prefix.to_string();
2281 if let Some(query) = query {
2282 out.push('?');
2283 out.push_str(&redact_pairs(query));
2284 }
2285 if let Some(fragment) = fragment {
2286 out.push('#');
2287 out.push_str(&redact_pairs(fragment));
2288 }
2289 out
2290}
2291
2292pub fn new_id() -> String {
2298 let millis = chrono::Utc::now().timestamp_millis().max(0);
2299 let suffix = rand::random::<u64>();
2300 format!("{millis:013}-{suffix:016x}")
2301}
2302
2303fn now_rfc3339_millis() -> String {
2305 chrono::Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)
2306}
2307
2308fn cwd() -> String {
2310 std::env::current_dir()
2311 .map(|p| p.display().to_string())
2312 .unwrap_or_default()
2313}
2314
2315fn system_user() -> String {
2317 if let Ok(user) = std::env::var("USER") {
2318 if !user.is_empty() {
2319 return user;
2320 }
2321 }
2322 #[cfg(unix)]
2323 {
2324 if let Ok(Some(user)) = nix::unistd::User::from_uid(nix::unistd::geteuid()) {
2325 return user.name;
2326 }
2327 }
2328 String::new()
2329}
2330
2331fn hostname() -> String {
2333 #[cfg(unix)]
2334 {
2335 if let Ok(name) = nix::unistd::gethostname() {
2336 if let Some(name) = name.to_str() {
2337 if !name.is_empty() {
2338 return name.to_string();
2339 }
2340 }
2341 }
2342 }
2343 std::env::var("HOSTNAME").unwrap_or_default()
2344}
2345
2346const SECRETISH: &[&str] = &["TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"];
2349
2350fn whitelisted_env() -> BTreeMap<String, String> {
2352 std::env::vars()
2353 .filter(|(k, _)| k.starts_with("OMNI_DEV_"))
2354 .map(|(k, v)| {
2355 let secretish = SECRETISH.iter().any(|needle| k.contains(needle));
2356 let value = if secretish { "REDACTED".to_string() } else { v };
2357 (k, value)
2358 })
2359 .collect()
2360}
2361
2362#[cfg(test)]
2363#[allow(clippy::unwrap_used, clippy::expect_used)]
2364mod tests {
2365 use super::*;
2366 use crate::test_support::env::MapEnv;
2367
2368 #[test]
2369 fn record_round_trips_through_json() {
2370 let mut rec = LogRecord::new(RecordKind::Http, "inv-1".to_string());
2371 rec.service = Some("jira".to_string());
2372 rec.method = Some("GET".to_string());
2373 rec.url = Some("https://example.atlassian.net/rest/api/3/issue/X-1".to_string());
2374 rec.status_code = Some(200);
2375 rec.elapsed_ms = Some(42);
2376
2377 let line = serde_json::to_string(&rec).unwrap();
2378 let back: LogRecord = serde_json::from_str(&line).unwrap();
2379 assert_eq!(back.invocation_id, "inv-1");
2380 assert_eq!(back.kind, RecordKind::Http);
2381 assert_eq!(back.service.as_deref(), Some("jira"));
2382 assert_eq!(back.status_code, Some(200));
2383 }
2384
2385 #[test]
2386 fn reader_tolerates_unknown_fields() {
2387 let line = r#"{"id":"x","invocation_id":"i","kind":"http","method":"GET",
2388 "future_field":{"nested":true},"another":42}"#;
2389 let rec: LogRecord = serde_json::from_str(line).unwrap();
2390 assert_eq!(rec.kind, RecordKind::Http);
2391 assert_eq!(rec.method.as_deref(), Some("GET"));
2392 }
2393
2394 #[test]
2395 fn reader_tolerates_missing_newer_fields() {
2396 let line = r#"{"kind":"invocation","command":["git","view"]}"#;
2398 let rec: LogRecord = serde_json::from_str(line).unwrap();
2399 assert_eq!(rec.kind, RecordKind::Invocation);
2400 assert_eq!(rec.command, vec!["git", "view"]);
2401 assert!(rec.status_code.is_none());
2402 assert!(rec.id.is_empty());
2403 }
2404
2405 #[test]
2406 fn unknown_kind_and_source_do_not_fail() {
2407 let line = r#"{"kind":"telemetry","source":"webhook"}"#;
2408 let rec: LogRecord = serde_json::from_str(line).unwrap();
2409 assert_eq!(rec.kind, RecordKind::Unknown);
2410 assert_eq!(rec.source, Some(Source::Unknown));
2411 }
2412
2413 #[test]
2414 fn optional_fields_are_skipped_when_empty() {
2415 let rec = LogRecord::new(RecordKind::Invocation, "i".to_string());
2416 let line = serde_json::to_string(&rec).unwrap();
2417 assert!(!line.contains("status_code"));
2419 assert!(!line.contains("request_headers"));
2420 assert!(!line.contains("via_daemon"));
2421 assert!(!line.contains("\"env\""));
2422 }
2423
2424 #[test]
2425 fn ids_are_time_sortable() {
2426 let a = new_id();
2427 std::thread::sleep(std::time::Duration::from_millis(2));
2428 let b = new_id();
2429 assert!(a < b, "{a} should sort before {b}");
2430 }
2431
2432 #[test]
2433 fn sensitive_headers_are_redacted() {
2434 let mut headers = BTreeMap::new();
2435 headers.insert("Authorization".to_string(), "Bearer secret".to_string());
2436 headers.insert("X-Api-Key".to_string(), "abc123".to_string());
2437 headers.insert("Content-Type".to_string(), "application/json".to_string());
2438 let out = redact_headers(&headers);
2439 assert_eq!(out["Authorization"], "REDACTED");
2440 assert_eq!(out["X-Api-Key"], "REDACTED");
2441 assert_eq!(out["Content-Type"], "application/json");
2442 }
2443
2444 fn argv(args: &[&str]) -> Vec<String> {
2445 args.iter().copied().map(String::from).collect()
2446 }
2447
2448 #[test]
2449 fn build_gh_record_stamps_kind_source_and_split_command() {
2450 let ctx = RequestLogContext {
2451 invocation_id: "inv-1".to_string(),
2452 source: Source::Daemon,
2453 mcp_tool: None,
2454 };
2455 let rec = build_gh_record(
2456 GhOutcome {
2457 label: "api graphql".to_string(),
2458 argv: argv(&["api", "graphql", "-f", "query=xyz"]),
2459 exit_code: Some(0),
2460 duration: Duration::from_millis(120),
2461 error: None,
2462 },
2463 ctx,
2464 );
2465 assert_eq!(rec.kind, RecordKind::Gh);
2466 assert_eq!(rec.invocation_id, "inv-1");
2467 assert_eq!(rec.source, Some(Source::Daemon));
2468 assert_eq!(rec.command, argv(&["api", "graphql"]));
2470 assert_eq!(
2471 rec.command_line,
2472 argv(&["api", "graphql", "-f", "query=xyz"])
2473 );
2474 assert_eq!(rec.exit_code, Some(0));
2475 assert_eq!(rec.duration_ms, Some(120));
2476 assert!(rec.error.is_none());
2477 }
2478
2479 #[test]
2480 fn build_gh_record_scrubs_secret_bearing_argv() {
2481 let rec = build_gh_record(
2484 GhOutcome {
2485 label: "api graphql".to_string(),
2486 argv: argv(&["api", "--header", "Authorization: Bearer sekret"]),
2487 exit_code: Some(0),
2488 duration: Duration::from_millis(5),
2489 error: None,
2490 },
2491 RequestLogContext::default(),
2492 );
2493 assert_eq!(
2494 rec.command_line,
2495 argv(&["api", "--header", "Authorization: REDACTED"])
2496 );
2497 }
2498
2499 #[test]
2500 fn build_worktree_record_stamps_kind_service_command_and_context() {
2501 let ctx = RequestLogContext {
2502 invocation_id: "inv-2".to_string(),
2503 source: Source::Mcp,
2504 mcp_tool: Some("some_tool".to_string()),
2505 };
2506 let mut context = BTreeMap::new();
2507 context.insert("path".to_string(), "/tmp/wt".to_string());
2508 context.insert("branch".to_string(), "demo-wt".to_string());
2509 context.insert("had_uncommitted".to_string(), "true".to_string());
2510 let rec = build_worktree_record(
2511 WorktreeOutcome {
2512 verb: "remove".to_string(),
2513 argv: argv(&["worktree", "remove", "--force", "/tmp/wt"]),
2514 exit_code: Some(0),
2515 duration: Duration::from_millis(42),
2516 error: None,
2517 context,
2518 },
2519 ctx,
2520 );
2521 assert_eq!(rec.kind, RecordKind::Worktree);
2522 assert_eq!(rec.invocation_id, "inv-2");
2523 assert_eq!(rec.source, Some(Source::Mcp));
2524 assert_eq!(rec.mcp_tool.as_deref(), Some("some_tool"));
2525 assert_eq!(rec.service.as_deref(), Some("worktree"));
2526 assert_eq!(rec.command, argv(&["git", "worktree", "remove"]));
2527 assert_eq!(
2528 rec.command_line,
2529 argv(&["worktree", "remove", "--force", "/tmp/wt"])
2530 );
2531 assert_eq!(rec.exit_code, Some(0));
2532 assert_eq!(rec.duration_ms, Some(42));
2533 assert_eq!(
2534 rec.context.get("branch").map(String::as_str),
2535 Some("demo-wt")
2536 );
2537 assert_eq!(
2538 rec.context.get("had_uncommitted").map(String::as_str),
2539 Some("true")
2540 );
2541 }
2542
2543 #[test]
2544 fn record_kind_worktree_serializes_as_worktree_and_round_trips() {
2545 let rec = build_worktree_record(
2546 WorktreeOutcome {
2547 verb: "add".to_string(),
2548 argv: argv(&["worktree", "add", "wt"]),
2549 exit_code: Some(1),
2550 duration: Duration::from_millis(1),
2551 error: Some("boom".to_string()),
2552 context: BTreeMap::new(),
2553 },
2554 RequestLogContext::default(),
2555 );
2556 let line = serde_json::to_string(&rec).unwrap();
2557 assert!(line.contains("\"kind\":\"worktree\""), "line was: {line}");
2558 assert!(
2559 line.contains("\"service\":\"worktree\""),
2560 "line was: {line}"
2561 );
2562 assert_eq!(RecordKind::Worktree.as_str(), "worktree");
2564 let back: LogRecord = serde_json::from_str(&line).unwrap();
2565 assert_eq!(back.kind, RecordKind::Worktree);
2566 assert_eq!(back.command, argv(&["git", "worktree", "add"]));
2567 assert_eq!(back.error.as_deref(), Some("boom"));
2568 }
2569
2570 #[test]
2571 fn build_drive_mutation_record_stamps_kind_service_command_and_context() {
2572 let ctx = RequestLogContext {
2573 invocation_id: "inv-3".to_string(),
2574 source: Source::Mcp,
2575 mcp_tool: Some("drive_file_move".to_string()),
2576 };
2577 let rec = build_drive_mutation_record(
2578 DriveMutationOutcome {
2579 operation: "move",
2580 file_id: "f1".to_string(),
2581 file_name: "report.pdf".to_string(),
2582 status: "blocked".to_string(),
2583 added_principals: vec!["alice@example.com".to_string()],
2584 removed_principals: vec![],
2585 crosses_drive_boundary: true,
2586 resolved_folder_id: Some("dest1".to_string()),
2587 decided_by_folder_id: Some("dest1".to_string()),
2588 decided_by_depth: Some(0),
2589 error: None,
2590 duration: Duration::from_millis(17),
2591 ..Default::default()
2592 },
2593 ctx,
2594 );
2595 assert_eq!(rec.kind, RecordKind::DriveMutation);
2596 assert_eq!(rec.invocation_id, "inv-3");
2597 assert_eq!(rec.source, Some(Source::Mcp));
2598 assert_eq!(rec.mcp_tool.as_deref(), Some("drive_file_move"));
2599 assert_eq!(rec.service.as_deref(), Some("drive"));
2600 assert_eq!(rec.command, vec!["drive".to_string(), "move".to_string()]);
2601 assert_eq!(rec.duration_ms, Some(17));
2602 assert_eq!(rec.context.get("file_id").map(String::as_str), Some("f1"));
2603 assert_eq!(
2604 rec.context.get("file_name").map(String::as_str),
2605 Some("report.pdf")
2606 );
2607 assert_eq!(
2608 rec.context.get("status").map(String::as_str),
2609 Some("blocked")
2610 );
2611 assert_eq!(
2612 rec.context.get("added_principals").map(String::as_str),
2613 Some("alice@example.com")
2614 );
2615 assert_eq!(rec.context.get("removed_principals"), None);
2616 assert_eq!(
2617 rec.context
2618 .get("crosses_drive_boundary")
2619 .map(String::as_str),
2620 Some("true")
2621 );
2622 assert_eq!(
2623 rec.context.get("resolved_folder_id").map(String::as_str),
2624 Some("dest1")
2625 );
2626 assert_eq!(
2627 rec.context.get("decided_by_folder_id").map(String::as_str),
2628 Some("dest1")
2629 );
2630 assert_eq!(
2631 rec.context.get("decided_by_depth").map(String::as_str),
2632 Some("0")
2633 );
2634 }
2635
2636 #[test]
2637 fn build_drive_mutation_record_omits_empty_principal_lists_and_false_boundary() {
2638 let rec = build_drive_mutation_record(
2639 DriveMutationOutcome {
2640 operation: "rename",
2641 file_id: "f2".to_string(),
2642 file_name: "old.txt".to_string(),
2643 status: "moved".to_string(),
2644 added_principals: vec![],
2645 removed_principals: vec![],
2646 crosses_drive_boundary: false,
2647 resolved_folder_id: None,
2648 decided_by_folder_id: None,
2649 decided_by_depth: None,
2650 error: None,
2651 duration: Duration::from_millis(5),
2652 ..Default::default()
2653 },
2654 RequestLogContext::default(),
2655 );
2656 assert_eq!(rec.context.get("added_principals"), None);
2657 assert_eq!(rec.context.get("removed_principals"), None);
2658 assert_eq!(rec.context.get("crosses_drive_boundary"), None);
2659 assert_eq!(rec.context.get("resolved_folder_id"), None);
2660 assert_eq!(rec.context.get("decided_by_folder_id"), None);
2661 assert_eq!(rec.context.get("decided_by_depth"), None);
2662 }
2663
2664 #[test]
2665 fn build_drive_mutation_record_includes_the_structural_context_keys() {
2666 let rec = build_drive_mutation_record(
2667 DriveMutationOutcome {
2668 operation: "sheets-insert-rows",
2669 file_id: "s1".to_string(),
2670 file_name: "Budget".to_string(),
2671 status: "changed".to_string(),
2672 sheet_id: Some(118_293),
2673 sheet_title: Some("Q2".to_string()),
2674 dimension_range: Some("ROWS 5:7".to_string()),
2675 duration: Duration::from_millis(1),
2676 ..Default::default()
2677 },
2678 RequestLogContext::default(),
2679 );
2680 assert_eq!(rec.command, vec!["drive", "sheets-insert-rows"]);
2681 assert_eq!(
2682 rec.context.get("sheet_id").map(String::as_str),
2683 Some("118293")
2684 );
2685 assert_eq!(
2686 rec.context.get("sheet_title").map(String::as_str),
2687 Some("Q2")
2688 );
2689 assert_eq!(
2690 rec.context.get("dimension_range").map(String::as_str),
2691 Some("ROWS 5:7")
2692 );
2693 assert_eq!(rec.context.get("range"), None);
2696 assert_eq!(rec.context.get("updated_cells"), None);
2697 assert_eq!(rec.context.get("sheet_new_title"), None);
2699 assert_eq!(rec.context.get("grid_range"), None);
2701 assert_eq!(rec.context.get("move_to"), None);
2703 }
2704
2705 #[test]
2706 fn build_drive_mutation_record_includes_the_move_to_key_for_a_move() {
2707 let rec = build_drive_mutation_record(
2712 DriveMutationOutcome {
2713 operation: "sheets-move-rows",
2714 file_id: "s1".to_string(),
2715 file_name: "Budget".to_string(),
2716 status: "changed".to_string(),
2717 sheet_id: Some(118_293),
2718 sheet_title: Some("Q2".to_string()),
2719 dimension_range: Some("ROWS 2:3".to_string()),
2720 move_to: Some(6),
2721 duration: Duration::from_millis(1),
2722 ..Default::default()
2723 },
2724 RequestLogContext::default(),
2725 );
2726 assert_eq!(rec.command, vec!["drive", "sheets-move-rows"]);
2727 assert_eq!(
2728 rec.context.get("dimension_range").map(String::as_str),
2729 Some("ROWS 2:3")
2730 );
2731 assert_eq!(rec.context.get("move_to").map(String::as_str), Some("6"));
2732 assert_eq!(rec.context.get("grid_range"), None);
2733 }
2734
2735 #[test]
2736 fn build_drive_mutation_record_includes_the_grid_range_key_for_a_delete_range() {
2737 let rec = build_drive_mutation_record(
2742 DriveMutationOutcome {
2743 operation: "sheets-delete-range",
2744 file_id: "s1".to_string(),
2745 file_name: "Budget".to_string(),
2746 status: "changed".to_string(),
2747 sheet_id: Some(118_293),
2748 sheet_title: Some("Q2".to_string()),
2749 grid_range: Some("rows 2-4, columns 2-3".to_string()),
2750 duration: Duration::from_millis(1),
2751 ..Default::default()
2752 },
2753 RequestLogContext::default(),
2754 );
2755 assert_eq!(
2756 rec.context.get("grid_range").map(String::as_str),
2757 Some("rows 2-4, columns 2-3")
2758 );
2759 assert_eq!(rec.context.get("dimension_range"), None);
2760 }
2761
2762 #[test]
2763 fn build_drive_mutation_record_includes_the_formatting_validation_and_protection_keys() {
2764 let rec = build_drive_mutation_record(
2769 DriveMutationOutcome {
2770 operation: "sheets-merge-cells",
2771 file_id: "s1".to_string(),
2772 file_name: "Budget".to_string(),
2773 status: "changed".to_string(),
2774 fields_changed: Some("merged A1:B2".to_string()),
2775 discarded_cells: vec!["B1: gone".to_string()],
2776 validation_type: Some("ONE_OF_LIST".to_string()),
2777 protected_range_id: Some(42),
2778 protection_editors_added: vec!["alice@example.com".to_string()],
2779 protection_editors_removed: vec!["bob@example.com".to_string()],
2780 duration: Duration::from_millis(1),
2781 ..Default::default()
2782 },
2783 RequestLogContext::default(),
2784 );
2785 assert_eq!(
2786 rec.context.get("fields_changed").map(String::as_str),
2787 Some("merged A1:B2")
2788 );
2789 assert_eq!(
2790 rec.context.get("discarded_cells").map(String::as_str),
2791 Some("B1: gone")
2792 );
2793 assert_eq!(
2794 rec.context.get("validation_type").map(String::as_str),
2795 Some("ONE_OF_LIST")
2796 );
2797 assert_eq!(
2798 rec.context.get("protected_range_id").map(String::as_str),
2799 Some("42")
2800 );
2801 assert_eq!(
2802 rec.context
2803 .get("protection_editors_added")
2804 .map(String::as_str),
2805 Some("alice@example.com")
2806 );
2807 assert_eq!(
2808 rec.context
2809 .get("protection_editors_removed")
2810 .map(String::as_str),
2811 Some("bob@example.com")
2812 );
2813 }
2814
2815 #[test]
2816 fn build_drive_mutation_record_omits_the_formatting_validation_and_protection_keys_when_unset()
2817 {
2818 let rec = build_drive_mutation_record(
2819 DriveMutationOutcome {
2820 operation: "sheets-write",
2821 file_id: "s1".to_string(),
2822 file_name: "Budget".to_string(),
2823 status: "written".to_string(),
2824 duration: Duration::from_millis(1),
2825 ..Default::default()
2826 },
2827 RequestLogContext::default(),
2828 );
2829 assert_eq!(rec.context.get("fields_changed"), None);
2830 assert_eq!(rec.context.get("discarded_cells"), None);
2831 assert_eq!(rec.context.get("validation_type"), None);
2832 assert_eq!(rec.context.get("protected_range_id"), None);
2833 assert_eq!(rec.context.get("protection_editors_added"), None);
2834 assert_eq!(rec.context.get("protection_editors_removed"), None);
2835 }
2836
2837 #[test]
2838 fn build_drive_mutation_record_records_both_titles_for_a_rename() {
2839 let rec = build_drive_mutation_record(
2842 DriveMutationOutcome {
2843 operation: "sheets-rename-sheet",
2844 file_id: "s1".to_string(),
2845 file_name: "Budget".to_string(),
2846 status: "changed".to_string(),
2847 sheet_id: Some(118_293),
2848 sheet_title: Some("Q2".to_string()),
2849 sheet_new_title: Some("Q2 (final)".to_string()),
2850 duration: Duration::from_millis(1),
2851 ..Default::default()
2852 },
2853 RequestLogContext::default(),
2854 );
2855 assert_eq!(
2856 rec.context.get("sheet_title").map(String::as_str),
2857 Some("Q2")
2858 );
2859 assert_eq!(
2860 rec.context.get("sheet_new_title").map(String::as_str),
2861 Some("Q2 (final)")
2862 );
2863 assert_eq!(rec.context.get("dimension_range"), None);
2865 }
2866
2867 #[test]
2868 fn build_drive_mutation_record_omits_structural_keys_for_a_cell_write() {
2869 let rec = build_drive_mutation_record(
2870 DriveMutationOutcome {
2871 operation: "sheets-write",
2872 file_id: "s1".to_string(),
2873 file_name: "Budget".to_string(),
2874 status: "written".to_string(),
2875 range: Some("A1:B2".to_string()),
2876 duration: Duration::from_millis(1),
2877 ..Default::default()
2878 },
2879 RequestLogContext::default(),
2880 );
2881 assert_eq!(rec.context.get("sheet_id"), None);
2882 assert_eq!(rec.context.get("sheet_title"), None);
2883 assert_eq!(rec.context.get("sheet_new_title"), None);
2884 assert_eq!(rec.context.get("dimension_range"), None);
2885 assert_eq!(rec.context.get("grid_range"), None);
2886 }
2887
2888 #[test]
2889 fn build_drive_mutation_record_includes_range_and_every_sheets_count() {
2890 let rec = build_drive_mutation_record(
2891 DriveMutationOutcome {
2892 operation: "sheets-write",
2893 file_id: "s1".to_string(),
2894 file_name: "Budget".to_string(),
2895 status: "written".to_string(),
2896 removed_principals: vec!["bob@example.com".to_string()],
2897 range: Some("A1:B2".to_string()),
2898 updated_range: Some("'Q1'!A1:B2".to_string()),
2899 updated_rows: Some(3),
2900 updated_columns: Some(2),
2901 updated_cells: Some(6),
2902 duration: Duration::from_millis(1),
2903 ..Default::default()
2904 },
2905 RequestLogContext::default(),
2906 );
2907 assert_eq!(
2908 rec.context.get("removed_principals").map(String::as_str),
2909 Some("bob@example.com")
2910 );
2911 assert_eq!(rec.context.get("range").map(String::as_str), Some("A1:B2"));
2912 assert_eq!(
2913 rec.context.get("updated_range").map(String::as_str),
2914 Some("'Q1'!A1:B2")
2915 );
2916 assert_eq!(
2917 rec.context.get("updated_rows").map(String::as_str),
2918 Some("3")
2919 );
2920 assert_eq!(
2921 rec.context.get("updated_columns").map(String::as_str),
2922 Some("2")
2923 );
2924 assert_eq!(
2925 rec.context.get("updated_cells").map(String::as_str),
2926 Some("6")
2927 );
2928 }
2929
2930 #[test]
2931 fn auto_fill_record_includes_only_overwritten_addresses() {
2932 let rec = build_drive_mutation_record(
2933 DriveMutationOutcome {
2934 operation: "sheets-auto-fill",
2935 file_id: "sheet-1".into(),
2936 file_name: "Budget".into(),
2937 status: "changed".into(),
2938 range: Some("'Q1'!A4:A5".into()),
2939 overwritten_cells: vec!["A4".into(), "A5".into()],
2940 duration: Duration::from_millis(1),
2941 ..Default::default()
2942 },
2943 RequestLogContext::default(),
2944 );
2945 assert_eq!(
2946 rec.context.get("overwritten_cells").map(String::as_str),
2947 Some("A4, A5")
2948 );
2949 assert_eq!(
2950 rec.context.get("range").map(String::as_str),
2951 Some("'Q1'!A4:A5")
2952 );
2953 assert_eq!(rec.context.get("updated_cells"), None);
2954 assert_eq!(rec.context.get("overwritten_cells_are_upper_bound"), None);
2957 }
2958
2959 #[test]
2964 fn auto_fill_range_form_marks_its_overwritten_cells_as_an_upper_bound() {
2965 let rec = build_drive_mutation_record(
2966 DriveMutationOutcome {
2967 operation: "sheets-auto-fill",
2968 file_id: "sheet-1".into(),
2969 file_name: "Budget".into(),
2970 status: "changed".into(),
2971 range: Some("'Q1'!A1:A10".into()),
2972 overwritten_cells: vec!["A1".into(), "A2".into()],
2973 overwritten_cells_upper_bound: true,
2974 duration: Duration::from_millis(1),
2975 ..Default::default()
2976 },
2977 RequestLogContext::default(),
2978 );
2979 assert_eq!(
2980 rec.context
2981 .get("overwritten_cells_are_upper_bound")
2982 .map(String::as_str),
2983 Some("true")
2984 );
2985 }
2986
2987 #[test]
2992 fn text_to_columns_record_always_marks_its_overwritten_cells_as_an_upper_bound() {
2993 let rec = build_drive_mutation_record(
2994 DriveMutationOutcome {
2995 operation: "sheets-text-to-columns",
2996 file_id: "sheet-1".into(),
2997 file_name: "Budget".into(),
2998 status: "changed".into(),
2999 range: Some("'Q1'!A2:A4".into()),
3000 fields_changed: Some(
3001 "split 'Q1'!A2:A4 on comma into up to 2 column(s), spill 'Q1'!B2:B4".into(),
3002 ),
3003 overwritten_cells: vec!["B3".into()],
3004 overwritten_cells_upper_bound: true,
3005 duration: Duration::from_millis(1),
3006 ..Default::default()
3007 },
3008 RequestLogContext::default(),
3009 );
3010 assert_eq!(
3011 rec.context.get("overwritten_cells").map(String::as_str),
3012 Some("B3")
3013 );
3014 assert_eq!(
3015 rec.context
3016 .get("overwritten_cells_are_upper_bound")
3017 .map(String::as_str),
3018 Some("true")
3019 );
3020 assert!(!rec.context.get("fields_changed").unwrap().contains("a,b"));
3023 }
3024
3025 #[test]
3026 fn record_kind_drive_mutation_serializes_as_drivemutation_and_round_trips() {
3027 let rec = build_drive_mutation_record(
3028 DriveMutationOutcome {
3029 operation: "rename",
3030 file_id: "f1".to_string(),
3031 file_name: "a.txt".to_string(),
3032 status: "failed".to_string(),
3033 added_principals: vec![],
3034 removed_principals: vec![],
3035 crosses_drive_boundary: false,
3036 resolved_folder_id: None,
3037 decided_by_folder_id: None,
3038 decided_by_depth: None,
3039 error: Some("boom".to_string()),
3040 duration: Duration::from_millis(1),
3041 ..Default::default()
3042 },
3043 RequestLogContext::default(),
3044 );
3045 let line = serde_json::to_string(&rec).unwrap();
3046 assert!(
3047 line.contains("\"kind\":\"drivemutation\""),
3048 "line was: {line}"
3049 );
3050 assert_eq!(RecordKind::DriveMutation.as_str(), "drivemutation");
3051 let back: LogRecord = serde_json::from_str(&line).unwrap();
3052 assert_eq!(back.kind, RecordKind::DriveMutation);
3053 assert_eq!(
3054 back.command,
3055 vec!["drive".to_string(), "rename".to_string()]
3056 );
3057 assert_eq!(back.error.as_deref(), Some("boom"));
3058 }
3059
3060 #[test]
3064 fn build_drive_mutation_record_round_trips_for_create_operation() {
3065 let rec = build_drive_mutation_record(
3066 DriveMutationOutcome {
3067 operation: "create",
3068 file_id: "f1".to_string(),
3069 file_name: "New File".to_string(),
3070 status: "created".to_string(),
3071 added_principals: vec![],
3072 removed_principals: vec![],
3073 crosses_drive_boundary: false,
3074 resolved_folder_id: Some("parent1".to_string()),
3075 decided_by_folder_id: None,
3076 decided_by_depth: None,
3077 error: None,
3078 duration: Duration::from_millis(1),
3079 ..Default::default()
3080 },
3081 RequestLogContext::default(),
3082 );
3083 assert_eq!(rec.command, vec!["drive".to_string(), "create".to_string()]);
3084 assert_eq!(
3085 rec.context.get("resolved_folder_id").map(String::as_str),
3086 Some("parent1")
3087 );
3088 assert_eq!(rec.context.get("decided_by_folder_id"), None);
3089 let line = serde_json::to_string(&rec).unwrap();
3090 let back: LogRecord = serde_json::from_str(&line).unwrap();
3091 assert_eq!(back.kind, RecordKind::DriveMutation);
3092 }
3093
3094 #[test]
3095 fn record_kind_gh_serializes_as_gh_and_round_trips() {
3096 let rec = build_gh_record(
3097 GhOutcome {
3098 label: "pr list".to_string(),
3099 argv: argv(&["pr", "list"]),
3100 exit_code: Some(1),
3101 duration: Duration::from_millis(1),
3102 error: Some("boom".to_string()),
3103 },
3104 RequestLogContext::default(),
3105 );
3106 let line = serde_json::to_string(&rec).unwrap();
3107 assert!(line.contains("\"kind\":\"gh\""), "line was: {line}");
3108 let back: LogRecord = serde_json::from_str(&line).unwrap();
3109 assert_eq!(back.kind, RecordKind::Gh);
3110 assert_eq!(back.command, argv(&["pr", "list"]));
3111 assert_eq!(back.error.as_deref(), Some("boom"));
3112 }
3113
3114 #[test]
3115 fn build_audit_record_stamps_kind_service_command_and_context() {
3116 let ctx = RequestLogContext {
3117 invocation_id: "inv-4".to_string(),
3118 source: Source::Cli,
3119 mcp_tool: None,
3120 };
3121 let rec = build_audit_record(
3122 AuditOutcome {
3123 command: vec!["drive".to_string(), "lease-acquire".to_string()],
3124 integration: "drive",
3125 file_id: "f1".to_string(),
3126 lease_id: Some("lease-1".to_string()),
3127 verdict: "acquired".to_string(),
3128 version_after: Some("42".to_string()),
3129 modified_time_after: Some("2026-09-12T00:00:00Z".to_string()),
3130 backup_location: Some("/tmp/backup".to_string()),
3131 backup_sha256: Some("deadbeef".to_string()),
3132 backup_size: Some(5),
3133 auth_policy: Some("device-owner".to_string()),
3134 ..Default::default()
3135 },
3136 ctx,
3137 );
3138 assert_eq!(rec.kind, RecordKind::Audit);
3139 assert_eq!(rec.invocation_id, "inv-4");
3140 assert_eq!(rec.source, Some(Source::Cli));
3141 assert_eq!(rec.service.as_deref(), Some("drive"));
3142 assert_eq!(
3143 rec.command,
3144 vec!["drive".to_string(), "lease-acquire".to_string()]
3145 );
3146 assert_eq!(
3147 rec.context.get("integration").map(String::as_str),
3148 Some("drive")
3149 );
3150 assert_eq!(rec.context.get("file_id").map(String::as_str), Some("f1"));
3151 assert_eq!(
3152 rec.context.get("lease_id").map(String::as_str),
3153 Some("lease-1")
3154 );
3155 assert_eq!(
3156 rec.context.get("verdict").map(String::as_str),
3157 Some("acquired")
3158 );
3159 assert_eq!(
3160 rec.context.get("version_after").map(String::as_str),
3161 Some("42")
3162 );
3163 assert_eq!(
3164 rec.context.get("modified_time_after").map(String::as_str),
3165 Some("2026-09-12T00:00:00Z")
3166 );
3167 assert_eq!(
3168 rec.context.get("backup_location").map(String::as_str),
3169 Some("/tmp/backup")
3170 );
3171 assert_eq!(
3172 rec.context.get("backup_sha256").map(String::as_str),
3173 Some("deadbeef")
3174 );
3175 assert_eq!(
3176 rec.context.get("backup_size").map(String::as_str),
3177 Some("5")
3178 );
3179 assert_eq!(
3180 rec.context.get("auth_policy").map(String::as_str),
3181 Some("device-owner")
3182 );
3183 }
3184
3185 #[test]
3186 fn build_audit_record_omits_absent_optional_context_keys() {
3187 let rec = build_audit_record(
3188 AuditOutcome {
3189 command: vec!["drive".to_string(), "lease-acquire".to_string()],
3190 integration: "drive",
3191 file_id: "f1".to_string(),
3192 verdict: "denied".to_string(),
3193 error: Some("no".to_string()),
3194 ..Default::default()
3195 },
3196 RequestLogContext::default(),
3197 );
3198 assert_eq!(rec.error.as_deref(), Some("no"));
3199 assert_eq!(rec.context.get("lease_id"), None);
3200 assert_eq!(rec.context.get("version_before"), None);
3201 assert_eq!(rec.context.get("version_after"), None);
3202 assert_eq!(rec.context.get("backup_location"), None);
3203 assert_eq!(rec.context.get("backup_sha256"), None);
3204 assert_eq!(rec.context.get("backup_size"), None);
3205 assert_eq!(rec.context.get("auth_policy"), None);
3206 }
3207
3208 #[test]
3209 fn record_audit_event_writes_to_the_audit_sink() {
3210 let dir = tempfile::tempdir().unwrap();
3211 let path = dir.path().join("audit.jsonl");
3212 let env = MapEnv::new().with("OMNI_DEV_AUDIT_LOG_FILE", path.to_str().unwrap());
3213
3214 record_audit_event_with(
3215 &env,
3216 AuditOutcome {
3217 command: vec!["drive".to_string(), "lease-acquire".to_string()],
3218 integration: "drive",
3219 file_id: "f1".to_string(),
3220 verdict: "acquired".to_string(),
3221 ..Default::default()
3222 },
3223 )
3224 .unwrap();
3225
3226 let contents = std::fs::read_to_string(&path).unwrap();
3227 let back: LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
3228 assert_eq!(back.kind, RecordKind::Audit);
3229 assert_eq!(
3230 back.context.get("verdict").map(String::as_str),
3231 Some("acquired")
3232 );
3233 }
3234
3235 #[test]
3236 fn record_kind_audit_serializes_as_audit_and_round_trips() {
3237 let rec = LogRecord {
3238 kind: RecordKind::Audit,
3239 id: new_id(),
3240 invocation_id: new_id(),
3241 ..LogRecord::default()
3242 };
3243 let line = serde_json::to_string(&rec).unwrap();
3244 assert!(line.contains("\"kind\":\"audit\""), "line was: {line}");
3245 assert_eq!(RecordKind::Audit.as_str(), "audit");
3246 let back: LogRecord = serde_json::from_str(&line).unwrap();
3247 assert_eq!(back.kind, RecordKind::Audit);
3248 }
3249
3250 #[test]
3251 fn audit_file_path_honors_env_override() {
3252 let env = MapEnv::new().with("OMNI_DEV_AUDIT_LOG_FILE", "/tmp/omni-dev-test-audit.jsonl");
3253 assert_eq!(
3254 audit_file_path_with(&env),
3255 Some(PathBuf::from("/tmp/omni-dev-test-audit.jsonl"))
3256 );
3257 }
3258
3259 #[test]
3260 fn audit_file_path_never_resolves_to_the_real_machine_default_when_unset() {
3261 let real_default = dirs::state_dir()
3272 .or_else(dirs::data_dir)
3273 .unwrap()
3274 .join("omni-dev")
3275 .join(AUDIT_FILE_NAME);
3276
3277 let unset = MapEnv::new();
3278 let unrouted = audit_file_path_with(&unset).unwrap();
3279 assert_eq!(
3280 unrouted,
3281 audit_file_path_with(&unset).unwrap(),
3282 "the fallback path must be stable across calls"
3283 );
3284 assert_ne!(
3285 unrouted, real_default,
3286 "must never fall back to the real machine's default audit log path in a test build"
3287 );
3288
3289 assert_eq!(
3290 audit_file_path().unwrap(),
3291 unrouted,
3292 "the ambient entry point with no thread-local route lands on the same scratch file"
3293 );
3294 }
3295
3296 #[test]
3297 fn record_audit_writes_a_line_that_round_trips() {
3298 let dir = tempfile::tempdir().unwrap();
3299 let path = dir.path().join("audit.jsonl");
3300 let env = MapEnv::new().with("OMNI_DEV_AUDIT_LOG_FILE", path.to_str().unwrap());
3301
3302 let rec = LogRecord {
3303 kind: RecordKind::Audit,
3304 id: new_id(),
3305 invocation_id: new_id(),
3306 ..LogRecord::default()
3307 };
3308 record_audit_with(&env, &rec).unwrap();
3309
3310 let contents = std::fs::read_to_string(&path).unwrap();
3311 let back: LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
3312 assert_eq!(back.kind, RecordKind::Audit);
3313 assert_eq!(back.id, rec.id);
3314 }
3315
3316 #[test]
3317 fn record_audit_locks_and_syncs_the_handle_when_bodies_are_enabled() {
3318 let dir = tempfile::tempdir().unwrap();
3323 let path = dir.path().join("audit.jsonl");
3324 let env = MapEnv::new()
3325 .with("OMNI_DEV_LOG_BODIES", "1")
3326 .with("OMNI_DEV_AUDIT_LOG_FILE", path.to_str().unwrap());
3327 assert!(bodies_enabled_with(&env));
3328
3329 let rec = LogRecord {
3330 kind: RecordKind::Audit,
3331 id: new_id(),
3332 invocation_id: new_id(),
3333 ..LogRecord::default()
3334 };
3335 record_audit_with(&env, &rec).unwrap();
3336
3337 let contents = std::fs::read_to_string(&path).unwrap();
3338 let back: LogRecord = serde_json::from_str(contents.trim_end()).unwrap();
3339 assert_eq!(back.id, rec.id);
3340 }
3341
3342 #[test]
3343 fn record_audit_fails_closed_when_the_write_errors() {
3344 let dir = tempfile::tempdir().unwrap();
3349 let rec = LogRecord {
3350 kind: RecordKind::Audit,
3351 id: new_id(),
3352 invocation_id: new_id(),
3353 ..LogRecord::default()
3354 };
3355 let err = append_record_to(dir.path(), &rec, append_line_no_rotation).unwrap_err();
3356 assert!(!err.to_string().is_empty());
3357 }
3358
3359 #[test]
3360 fn record_audit_is_exempt_from_omni_dev_log_disable() {
3361 let dir = tempfile::tempdir().unwrap();
3362 let path = dir.path().join("audit.jsonl");
3363 let env = MapEnv::new()
3364 .with("OMNI_DEV_LOG_DISABLE", "1")
3365 .with("OMNI_DEV_AUDIT_LOG_FILE", path.to_str().unwrap());
3366 assert!(disabled_with(&env));
3367
3368 let rec = LogRecord {
3369 kind: RecordKind::Audit,
3370 id: new_id(),
3371 invocation_id: new_id(),
3372 ..LogRecord::default()
3373 };
3374 record_audit_with(&env, &rec).unwrap();
3375
3376 assert!(path.exists());
3377 }
3378
3379 #[test]
3380 fn the_audit_write_path_never_gates_on_log_disable() {
3381 let source = include_str!("request_log.rs");
3388 let start = source
3389 .find("pub fn record_audit(entry: &LogRecord) -> anyhow::Result<()> {")
3390 .expect("record_audit signature not found");
3391 let end = source[start..]
3392 .find("/// Serializes `entry` and appends it to `path` via `append`,")
3393 .expect("append_record_to doc comment not found")
3394 + start;
3395 let audit_write_path = &source[start..end];
3396 assert!(
3397 !audit_write_path.contains("disabled"),
3398 "the fail-closed audit sink must never gate on OMNI_DEV_LOG_DISABLE:\n{audit_write_path}"
3399 );
3400 }
3401
3402 #[test]
3403 fn try_record_refuses_an_audit_kind_entry() {
3404 let rec = LogRecord {
3405 kind: RecordKind::Audit,
3406 id: new_id(),
3407 invocation_id: new_id(),
3408 ..LogRecord::default()
3409 };
3410 let err = try_record(&rec).unwrap_err();
3411 assert!(err.to_string().contains("record_audit()"), "{err}");
3412 }
3413
3414 #[test]
3415 fn record_audit_refuses_a_non_audit_kind_entry() {
3416 let dir = tempfile::tempdir().unwrap();
3417 let env = MapEnv::new().with(
3418 "OMNI_DEV_AUDIT_LOG_FILE",
3419 dir.path().join("audit.jsonl").to_str().unwrap(),
3420 );
3421
3422 let rec = LogRecord {
3423 kind: RecordKind::Invocation,
3424 id: new_id(),
3425 invocation_id: new_id(),
3426 ..LogRecord::default()
3427 };
3428 let err = record_audit_with(&env, &rec).unwrap_err();
3429
3430 assert!(err.to_string().contains("RecordKind::Audit"), "{err}");
3431 }
3432
3433 #[test]
3434 fn log_file_path_with_refuses_when_it_resolves_to_the_audit_file() {
3435 let dir = tempfile::tempdir().unwrap();
3436 let shared = dir.path().join("shared.jsonl");
3437 let shared_str = shared.to_str().unwrap();
3438 let env = MapEnv::new()
3439 .with("OMNI_DEV_LOG_FILE", shared_str)
3440 .with("OMNI_DEV_AUDIT_LOG_FILE", shared_str);
3441
3442 assert!(log_file_path_with(&env).is_none());
3443 }
3444
3445 #[test]
3446 fn log_file_path_with_refuses_a_dot_dot_spelling_of_the_audit_file() {
3447 let dir = tempfile::tempdir().unwrap();
3450 let audit = dir.path().join("audit.jsonl");
3451 std::fs::write(&audit, "{}\n").unwrap();
3452 let dotted = dir.path().join("sub/../audit.jsonl");
3453 let env = MapEnv::new()
3454 .with("OMNI_DEV_LOG_FILE", dotted.to_str().unwrap())
3455 .with("OMNI_DEV_AUDIT_LOG_FILE", audit.to_str().unwrap());
3456
3457 assert!(log_file_path_with(&env).is_none());
3458 }
3459
3460 #[test]
3461 fn record_audit_with_succeeds_when_the_log_file_env_aliases_the_audit_path() {
3462 let dir = tempfile::tempdir().unwrap();
3468 let shared = dir.path().join("shared.jsonl");
3469 let shared_str = shared.to_str().unwrap();
3470 let env = MapEnv::new()
3471 .with("OMNI_DEV_LOG_FILE", shared_str)
3472 .with("OMNI_DEV_AUDIT_LOG_FILE", shared_str);
3473
3474 let rec = LogRecord {
3475 kind: RecordKind::Audit,
3476 id: new_id(),
3477 invocation_id: new_id(),
3478 ..LogRecord::default()
3479 };
3480 record_audit_with(&env, &rec).unwrap();
3481
3482 assert!(shared.exists());
3483 assert!(log_file_path_with(&env).is_none());
3484 }
3485
3486 #[test]
3487 fn scrub_argv_redacts_sensitive_header_in_both_forms() {
3488 let out = scrub_argv(&argv(&[
3489 "omni-dev",
3490 "--header",
3491 "Authorization: Bearer sekret",
3492 "--header=Cookie: session=abc",
3493 ]));
3494 assert_eq!(
3495 out,
3496 argv(&[
3497 "omni-dev",
3498 "--header",
3499 "Authorization: REDACTED",
3500 "--header=Cookie: REDACTED",
3501 ])
3502 );
3503 }
3504
3505 #[test]
3506 fn scrub_argv_keeps_non_sensitive_headers() {
3507 let input = argv(&["omni-dev", "--header", "Content-Type: application/json"]);
3508 assert_eq!(scrub_argv(&input), input);
3509 }
3510
3511 #[test]
3512 fn scrub_argv_redacts_colonless_header_wholesale() {
3513 let out = scrub_argv(&argv(&["omni-dev", "--header", "sekret"]));
3514 assert_eq!(out, argv(&["omni-dev", "--header", "REDACTED"]));
3515 }
3516
3517 #[test]
3518 fn scrub_argv_redacts_inline_body_but_keeps_at_file() {
3519 let out = scrub_argv(&argv(&["omni-dev", "--body", r#"{"secret":1}"#]));
3520 assert_eq!(out, argv(&["omni-dev", "--body", "REDACTED"]));
3521
3522 let file_form = argv(&["omni-dev", "--body", "@payload.json"]);
3523 assert_eq!(scrub_argv(&file_form), file_form);
3524
3525 let out = scrub_argv(&argv(&["omni-dev", "--body=sekret"]));
3526 assert_eq!(out, argv(&["omni-dev", "--body=REDACTED"]));
3527 }
3528
3529 #[test]
3530 fn scrub_argv_redacts_find_replace_content_in_both_flag_forms() {
3531 let out = scrub_argv(&argv(&[
3532 "omni-dev",
3533 "--find",
3534 "customer@example.com",
3535 "--replacement=redacted",
3536 ]));
3537 assert_eq!(
3538 out,
3539 argv(&["omni-dev", "--find", "REDACTED", "--replacement=REDACTED",])
3540 );
3541 }
3542
3543 #[test]
3544 fn scrub_argv_redacts_secretish_flag_values() {
3545 let out = scrub_argv(&argv(&["omni-dev", "--api-key", "abc", "--auth-token=xyz"]));
3546 assert_eq!(
3547 out,
3548 argv(&["omni-dev", "--api-key", "REDACTED", "--auth-token=REDACTED"])
3549 );
3550 }
3551
3552 #[test]
3553 fn scrub_argv_exempts_path_flags_and_positionals() {
3554 let input = argv(&["omni-dev", "--token-file", "/tmp/t", "PROJ-123"]);
3555 assert_eq!(scrub_argv(&input), input);
3556 }
3557
3558 #[test]
3559 fn scrub_argv_redacts_secret_bearing_url_query_in_both_forms() {
3560 let space = scrub_argv(&argv(&[
3564 "omni-dev",
3565 "browser",
3566 "bridge",
3567 "request",
3568 "--url",
3569 "/api/export?access_token=hunter2&sig=deadbeef&page=3",
3570 ]));
3571 assert_eq!(
3572 *space.last().unwrap(),
3573 "/api/export?access_token=REDACTED&sig=REDACTED&page=3"
3574 );
3575
3576 let eq_form = scrub_argv(&argv(&[
3577 "omni-dev",
3578 "--url=/api/export?access_token=hunter2&page=3",
3579 ]));
3580 assert_eq!(
3581 *eq_form.last().unwrap(),
3582 "--url=/api/export?access_token=REDACTED&page=3"
3583 );
3584
3585 let positional = scrub_argv(&argv(&["omni-dev", "https://h/cb#id_token=xyz"]));
3586 assert_eq!(
3587 *positional.last().unwrap(),
3588 "https://h/cb#id_token=REDACTED"
3589 );
3590 }
3591
3592 #[test]
3593 fn scrub_argv_leaves_benign_argv_byte_identical() {
3594 let input = argv(&[
3595 "omni-dev",
3596 "browser",
3597 "bridge",
3598 "request",
3599 "--control-port",
3600 "19998",
3601 "--url",
3602 "/api/export?page=3&sort=asc",
3603 ]);
3604 assert_eq!(scrub_argv(&input), input);
3605 }
3606
3607 #[test]
3608 fn scrub_argv_handles_trailing_flag_without_value() {
3609 let input = argv(&["omni-dev", "--body"]);
3610 assert_eq!(scrub_argv(&input), input);
3611 }
3612
3613 #[cfg(unix)]
3614 #[test]
3615 fn append_line_creates_file_owner_only() {
3616 use std::os::unix::fs::PermissionsExt;
3617 let dir = tempfile::tempdir().unwrap();
3618 let path = dir.path().join("log.jsonl");
3619 append_line(&path, "{\"kind\":\"http\"}\n").unwrap();
3620 assert_eq!(
3621 std::fs::read_to_string(&path).unwrap(),
3622 "{\"kind\":\"http\"}\n"
3623 );
3624 assert_eq!(
3625 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
3626 0o600
3627 );
3628 }
3629
3630 #[cfg(unix)]
3631 #[test]
3632 fn append_line_retightens_preexisting_loose_file() {
3633 use std::os::unix::fs::PermissionsExt;
3634 let dir = tempfile::tempdir().unwrap();
3635 let path = dir.path().join("log.jsonl");
3636 std::fs::write(&path, "old\n").unwrap();
3637 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3638 append_line(&path, "new\n").unwrap();
3639 assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
3640 assert_eq!(
3641 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
3642 0o600
3643 );
3644 }
3645
3646 #[cfg(unix)]
3652 #[test]
3653 fn append_line_synced_appends_in_order_across_the_creating_and_later_writes() {
3654 use std::os::unix::fs::PermissionsExt;
3655 let dir = tempfile::tempdir().unwrap();
3656 let path = dir.path().join("audit.jsonl");
3657
3658 append_line_synced(&path, "first\n").unwrap();
3659 append_line_synced(&path, "second\n").unwrap();
3660 append_line_synced(&path, "third\n").unwrap();
3661
3662 assert_eq!(
3663 std::fs::read_to_string(&path).unwrap(),
3664 "first\nsecond\nthird\n"
3665 );
3666 assert_eq!(
3667 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
3668 0o600
3669 );
3670 }
3671
3672 #[cfg(unix)]
3676 #[test]
3677 fn append_line_synced_retightens_preexisting_loose_file() {
3678 use std::os::unix::fs::PermissionsExt;
3679 let dir = tempfile::tempdir().unwrap();
3680 let path = dir.path().join("audit.jsonl");
3681 std::fs::write(&path, "old\n").unwrap();
3682 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3683
3684 append_line_synced(&path, "new\n").unwrap();
3685
3686 assert_eq!(std::fs::read_to_string(&path).unwrap(), "old\nnew\n");
3687 assert_eq!(
3688 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
3689 0o600
3690 );
3691 }
3692
3693 #[test]
3698 fn append_line_synced_errors_when_the_target_is_a_directory() {
3699 let dir = tempfile::tempdir().unwrap();
3700 let path = dir.path().join("audit.jsonl");
3701 std::fs::create_dir(&path).unwrap();
3702
3703 assert!(append_line_synced(&path, "line\n").is_err());
3704 }
3705
3706 #[test]
3710 fn append_line_synced_appends_to_an_existing_empty_file() {
3711 let dir = tempfile::tempdir().unwrap();
3712 let path = dir.path().join("audit.jsonl");
3713 std::fs::write(&path, "").unwrap();
3714
3715 append_line_synced(&path, "line\n").unwrap();
3716
3717 assert_eq!(std::fs::read_to_string(&path).unwrap(), "line\n");
3718 }
3719
3720 #[test]
3721 fn off_list_secretish_headers_are_redacted() {
3722 let mut headers = BTreeMap::new();
3723 for name in [
3724 "X-Auth-Token",
3725 "x-amz-security-token",
3726 "X-Goog-Api-Key",
3727 "x-csrf-token",
3728 "X-Vendor-Token",
3729 "X-Omni-Bridge",
3730 ] {
3731 headers.insert(name.to_string(), "secret-value".to_string());
3732 }
3733 for name in [
3734 "Content-Type",
3735 "Accept",
3736 "User-Agent",
3737 "x-request-id",
3738 "traceparent",
3739 ] {
3740 headers.insert(name.to_string(), "plain-value".to_string());
3741 }
3742 let out = redact_headers(&headers);
3743 assert_eq!(out["X-Auth-Token"], "REDACTED");
3744 assert_eq!(out["x-amz-security-token"], "REDACTED");
3745 assert_eq!(out["X-Goog-Api-Key"], "REDACTED");
3746 assert_eq!(out["x-csrf-token"], "REDACTED");
3747 assert_eq!(out["X-Vendor-Token"], "REDACTED");
3748 assert_eq!(out["X-Omni-Bridge"], "REDACTED");
3749 assert_eq!(out["Content-Type"], "plain-value");
3750 assert_eq!(out["Accept"], "plain-value");
3751 assert_eq!(out["User-Agent"], "plain-value");
3752 assert_eq!(out["x-request-id"], "plain-value");
3753 assert_eq!(out["traceparent"], "plain-value");
3754 }
3755
3756 #[test]
3757 fn url_without_query_is_unchanged() {
3758 assert_eq!(redact_url("https://h/p"), "https://h/p");
3759 assert_eq!(redact_url("/relative/p"), "/relative/p");
3760 }
3761
3762 #[test]
3763 fn benign_query_is_byte_identical() {
3764 let url = "https://h/p?q=a%20b&page=2&&x=y+z&keyword=k&sort_key=s&token_type=bearer";
3765 assert_eq!(redact_url(url), url);
3766 }
3767
3768 #[test]
3769 fn sensitive_query_values_are_redacted() {
3770 let url = "https://h/p?token=a&access_token=b&client_secret=c&api_key=d&x=1";
3771 assert_eq!(
3772 redact_url(url),
3773 "https://h/p?token=REDACTED&access_token=REDACTED&client_secret=REDACTED\
3774 &api_key=REDACTED&x=1"
3775 );
3776 }
3777
3778 #[test]
3779 fn presigned_s3_query_is_redacted() {
3780 let url = "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=AWS4-HMAC-SHA256\
3781 &X-Amz-Credential=AKIA%2F20260703%2Fus-east-1%2Fs3%2Faws4_request\
3782 &X-Amz-Date=20260703T000000Z&X-Amz-Expires=3600\
3783 &X-Amz-SignedHeaders=host&X-Amz-Signature=deadbeef";
3784 assert_eq!(
3785 redact_url(url),
3786 "https://bucket.s3.amazonaws.com/key?X-Amz-Algorithm=REDACTED\
3787 &X-Amz-Credential=REDACTED&X-Amz-Date=REDACTED&X-Amz-Expires=REDACTED\
3788 &X-Amz-SignedHeaders=REDACTED&X-Amz-Signature=REDACTED"
3789 );
3790 }
3791
3792 #[test]
3793 fn key_matching_is_case_insensitive() {
3794 assert_eq!(
3795 redact_url("/p?TOKEN=x&Api_Key=y&X-Amz-Signature=z"),
3796 "/p?TOKEN=REDACTED&Api_Key=REDACTED&X-Amz-Signature=REDACTED"
3797 );
3798 }
3799
3800 #[test]
3801 fn repeated_sensitive_keys_are_each_redacted() {
3802 assert_eq!(redact_url("/p?sig=a&sig=b"), "/p?sig=REDACTED&sig=REDACTED");
3803 }
3804
3805 #[test]
3806 fn valueless_key_is_left_alone() {
3807 assert_eq!(redact_url("/p?token"), "/p?token");
3808 assert_eq!(redact_url("/p?token="), "/p?token=REDACTED");
3809 }
3810
3811 #[test]
3812 fn relative_url_query_is_redacted() {
3813 assert_eq!(
3814 redact_url("/api/foo?sig=abc&x=y"),
3815 "/api/foo?sig=REDACTED&x=y"
3816 );
3817 }
3818
3819 #[test]
3820 fn fragment_credentials_are_redacted() {
3821 assert_eq!(
3822 redact_url("https://h/cb#access_token=xyz&token_type=bearer"),
3823 "https://h/cb#access_token=REDACTED&token_type=bearer"
3824 );
3825 }
3826
3827 #[test]
3828 fn query_and_fragment_are_scrubbed_independently() {
3829 assert_eq!(
3830 redact_url("/p?sig=a#id_token=b"),
3831 "/p?sig=REDACTED#id_token=REDACTED"
3832 );
3833 }
3834
3835 #[test]
3836 fn question_mark_in_fragment_is_not_parsed_as_query() {
3837 assert_eq!(
3841 redact_url("https://h/p#frag?token=x"),
3842 "https://h/p#frag?token=REDACTED"
3843 );
3844 }
3845
3846 #[test]
3847 fn encoded_sensitive_key_is_decoded_before_matching() {
3848 assert_eq!(
3849 redact_url("/p?access%5Ftoken=v"),
3850 "/p?access%5Ftoken=REDACTED"
3851 );
3852 }
3853
3854 #[test]
3855 fn empty_query_is_unchanged() {
3856 assert_eq!(redact_url("https://h/p?"), "https://h/p?");
3857 assert_eq!(redact_url("https://h/p?#f"), "https://h/p?#f");
3858 }
3859
3860 #[test]
3861 fn parse_size_handles_units_and_bare_bytes() {
3862 assert_eq!(parse_size("1048576").unwrap(), 1024 * 1024);
3863 assert_eq!(parse_size("512b").unwrap(), 512);
3864 assert_eq!(parse_size("10kb").unwrap(), 10 * 1024);
3865 assert_eq!(parse_size("2K").unwrap(), 2 * 1024);
3866 assert_eq!(parse_size("3mb").unwrap(), 3 * 1024 * 1024);
3867 assert_eq!(parse_size("1gb").unwrap(), 1024 * 1024 * 1024);
3868 assert_eq!(parse_size("1.5mb").unwrap(), (1.5 * 1024.0 * 1024.0) as u64);
3869 assert_eq!(parse_size(" 4mib ").unwrap(), 4 * 1024 * 1024);
3870 }
3871
3872 #[test]
3873 fn parse_size_rejects_garbage() {
3874 assert!(parse_size("").is_err());
3875 assert!(parse_size("mb").is_err());
3876 assert!(parse_size("10tb").is_err());
3877 assert!(parse_size("-5mb").is_err());
3878 }
3879
3880 #[test]
3881 fn sibling_appends_to_final_component() {
3882 let base = Path::new("/tmp/omni/log.jsonl");
3883 assert_eq!(sibling(base, ".1"), Path::new("/tmp/omni/log.jsonl.1"));
3884 assert_eq!(
3885 sibling(base, ".lock"),
3886 Path::new("/tmp/omni/log.jsonl.lock")
3887 );
3888 }
3889
3890 #[test]
3891 fn keep_by_size_keeps_most_recent_that_fit() {
3892 let lines = ["aaaaaaaaaa", "bbbbbbbbbb", "cccccccccc", "dddddddddd"];
3894 let refs: Vec<&str> = lines.to_vec();
3895
3896 assert_eq!(keep_by_size(&refs, 22), &["cccccccccc", "dddddddddd"]);
3898 assert_eq!(keep_by_size(&refs, 1), &["dddddddddd"]);
3900 assert_eq!(keep_by_size(&refs, 10_000), &refs[..]);
3902 assert!(keep_by_size(&[], 100).is_empty());
3904 }
3905
3906 #[test]
3907 fn keep_by_age_is_conservative_on_undateable_lines() {
3908 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
3909 .unwrap()
3910 .with_timezone(&Utc);
3911 let old = r#"{"kind":"http","timestamp":"2026-01-01T00:00:00.000Z"}"#;
3912 let new = r#"{"kind":"http","timestamp":"2026-12-01T00:00:00.000Z"}"#;
3913 let undated = r#"{"kind":"http"}"#;
3914 let malformed = "not json at all";
3915
3916 assert!(!keep_by_age(old, Some(cutoff)));
3917 assert!(keep_by_age(new, Some(cutoff)));
3918 assert!(keep_by_age(undated, Some(cutoff)), "undated is kept");
3919 assert!(keep_by_age(malformed, Some(cutoff)), "malformed is kept");
3920 assert!(keep_by_age(old, None), "no filter keeps everything");
3921 }
3922
3923 fn http_line(id: &str, ts: &str) -> String {
3924 format!(r#"{{"id":"{id}","kind":"http","timestamp":"{ts}"}}"#)
3925 }
3926
3927 #[test]
3928 fn prune_by_age_drops_old_records_and_rewrites_atomically() {
3929 use std::os::unix::fs::PermissionsExt;
3930
3931 let dir = tempfile::tempdir().unwrap();
3932 let path = dir.path().join("log.jsonl");
3933 let body = format!(
3934 "{}\n{}\n{}\n",
3935 http_line("1", "2026-01-01T00:00:00.000Z"),
3936 http_line("2", "2026-06-15T00:00:00.000Z"),
3937 http_line("3", "2026-12-31T00:00:00.000Z"),
3938 );
3939 std::fs::write(&path, &body).unwrap();
3940 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3941
3942 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
3943 .unwrap()
3944 .with_timezone(&Utc);
3945 let outcome = prune(
3946 &path,
3947 &PruneOptions {
3948 older_than: Some(cutoff),
3949 max_size: None,
3950 dry_run: false,
3951 },
3952 )
3953 .unwrap();
3954
3955 assert_eq!(outcome.removed, 1);
3956 assert_eq!(outcome.kept, 2);
3957 let contents = std::fs::read_to_string(&path).unwrap();
3958 assert!(!contents.contains(r#""id":"1""#));
3959 assert!(contents.contains(r#""id":"2""#));
3960 assert!(contents.contains(r#""id":"3""#));
3961 assert_eq!(
3963 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
3964 0o600
3965 );
3966 }
3967
3968 #[test]
3969 fn prune_dry_run_reports_without_modifying() {
3970 let dir = tempfile::tempdir().unwrap();
3971 let path = dir.path().join("log.jsonl");
3972 let body = format!(
3973 "{}\n{}\n",
3974 http_line("1", "2026-01-01T00:00:00.000Z"),
3975 http_line("2", "2026-12-31T00:00:00.000Z"),
3976 );
3977 std::fs::write(&path, &body).unwrap();
3978
3979 let cutoff = DateTime::parse_from_rfc3339("2026-06-01T00:00:00.000Z")
3980 .unwrap()
3981 .with_timezone(&Utc);
3982 let outcome = prune(
3983 &path,
3984 &PruneOptions {
3985 older_than: Some(cutoff),
3986 max_size: None,
3987 dry_run: true,
3988 },
3989 )
3990 .unwrap();
3991
3992 assert_eq!(outcome.removed, 1);
3993 assert_eq!(std::fs::read_to_string(&path).unwrap(), body);
3995 }
3996
3997 #[test]
3998 fn prune_by_size_keeps_the_newest_that_fit() {
3999 let dir = tempfile::tempdir().unwrap();
4000 let path = dir.path().join("log.jsonl");
4001 let l1 = http_line("1", "2026-01-01T00:00:00.000Z");
4002 let l2 = http_line("2", "2026-06-15T00:00:00.000Z");
4003 let l3 = http_line("3", "2026-12-31T00:00:00.000Z");
4004 std::fs::write(&path, format!("{l1}\n{l2}\n{l3}\n")).unwrap();
4005
4006 let budget = (l2.len() + 1 + l3.len() + 1) as u64;
4008 let outcome = prune(
4009 &path,
4010 &PruneOptions {
4011 older_than: None,
4012 max_size: Some(budget),
4013 dry_run: false,
4014 },
4015 )
4016 .unwrap();
4017
4018 assert_eq!(outcome.removed, 1);
4019 assert_eq!(outcome.kept, 2);
4020 let contents = std::fs::read_to_string(&path).unwrap();
4021 assert!(!contents.contains(r#""id":"1""#));
4022 assert!(contents.contains(r#""id":"3""#));
4023 }
4024
4025 #[test]
4026 fn prune_missing_file_is_a_noop() {
4027 let dir = tempfile::tempdir().unwrap();
4028 let path = dir.path().join("absent.jsonl");
4029 let outcome = prune(
4030 &path,
4031 &PruneOptions {
4032 older_than: None,
4033 max_size: Some(1),
4034 dry_run: false,
4035 },
4036 )
4037 .unwrap();
4038 assert_eq!(outcome.removed, 0);
4039 assert_eq!(outcome.kept, 0);
4040 assert!(!path.exists());
4041 }
4042
4043 #[cfg(unix)]
4044 #[test]
4045 fn rotation_shifts_numbered_files_and_drops_the_oldest() {
4046 use std::os::unix::fs::PermissionsExt;
4047
4048 let dir = tempfile::tempdir().unwrap();
4049 let path = dir.path().join("log.jsonl");
4050 let cfg = RotationConfig {
4052 max_size: 20,
4053 keep_files: 2,
4054 };
4055
4056 let line = "0123456789012345\n"; for _ in 0..4 {
4058 append_with_rotation(&path, line, &cfg).unwrap();
4059 }
4060
4061 assert!(path.exists());
4064 assert!(sibling(&path, ".1").exists());
4065 assert!(sibling(&path, ".2").exists());
4066 assert!(!sibling(&path, ".3").exists());
4067 assert_eq!(
4069 std::fs::metadata(sibling(&path, ".1"))
4070 .unwrap()
4071 .permissions()
4072 .mode()
4073 & 0o777,
4074 0o600
4075 );
4076 }
4077
4078 #[cfg(unix)]
4079 #[test]
4080 fn rotation_keep_zero_discards_on_overflow() {
4081 let dir = tempfile::tempdir().unwrap();
4082 let path = dir.path().join("log.jsonl");
4083 let cfg = RotationConfig {
4084 max_size: 20,
4085 keep_files: 0,
4086 };
4087 let line = "0123456789012345\n"; append_with_rotation(&path, line, &cfg).unwrap();
4089 append_with_rotation(&path, line, &cfg).unwrap();
4090 assert!(!sibling(&path, ".1").exists());
4092 assert_eq!(std::fs::read_to_string(&path).unwrap(), line);
4093 }
4094
4095 #[test]
4096 fn parse_size_rejects_overflow_to_infinity() {
4097 assert!(parse_size(&"9".repeat(400)).is_err());
4099 }
4100
4101 #[test]
4104 fn same_file_matches_identical_paths() {
4105 let dir = tempfile::tempdir().unwrap();
4106 let path = dir.path().join("audit.jsonl");
4107 assert!(same_file(&path, &path));
4108 }
4109
4110 #[test]
4111 fn same_file_matches_a_dot_dot_spelling_routed_through_a_nonexistent_directory() {
4112 let dir = tempfile::tempdir().unwrap();
4117 let audit = dir.path().join("audit.jsonl");
4118 std::fs::write(&audit, "{}\n").unwrap();
4119 let dotted = dir.path().join("sub/../audit.jsonl");
4120 assert!(same_file(&audit, &dotted));
4121 }
4122
4123 #[test]
4124 fn same_file_matches_a_hard_link() {
4125 let dir = tempfile::tempdir().unwrap();
4126 let original = dir.path().join("audit.jsonl");
4127 std::fs::write(&original, "{}\n").unwrap();
4128 let linked = dir.path().join("linked.jsonl");
4129 std::fs::hard_link(&original, &linked).unwrap();
4130 assert!(same_file(&original, &linked));
4131 }
4132
4133 #[cfg(unix)]
4134 #[test]
4135 fn same_file_matches_a_symlink_to_an_existing_file() {
4136 let dir = tempfile::tempdir().unwrap();
4137 let original = dir.path().join("audit.jsonl");
4138 std::fs::write(&original, "{}\n").unwrap();
4139 let link = dir.path().join("link.jsonl");
4140 std::os::unix::fs::symlink(&original, &link).unwrap();
4141 assert!(same_file(&original, &link));
4142 }
4143
4144 #[cfg(unix)]
4145 #[test]
4146 fn same_file_matches_a_symlink_to_a_not_yet_existing_target() {
4147 let dir = tempfile::tempdir().unwrap();
4153 let target = dir.path().join("audit.jsonl"); let link = dir.path().join("link.jsonl");
4155 std::os::unix::fs::symlink(&target, &link).unwrap();
4156 assert!(same_file(&target, &link));
4157 }
4158
4159 #[test]
4160 fn same_file_rejects_two_distinct_existing_files() {
4161 let dir = tempfile::tempdir().unwrap();
4162 let a = dir.path().join("a.jsonl");
4163 let b = dir.path().join("b.jsonl");
4164 std::fs::write(&a, "a").unwrap();
4165 std::fs::write(&b, "b").unwrap();
4166 assert!(!same_file(&a, &b));
4167 }
4168
4169 #[test]
4170 fn same_file_rejects_two_distinct_paths_that_both_do_not_exist() {
4171 let dir = tempfile::tempdir().unwrap();
4172 let a = dir.path().join("a.jsonl");
4173 let b = dir.path().join("b.jsonl");
4174 assert!(!same_file(&a, &b));
4175 }
4176
4177 #[test]
4178 fn lexically_normalize_collapses_dot_dot_without_touching_disk() {
4179 assert_eq!(
4180 lexically_normalize(Path::new("a/sub/../b")),
4181 PathBuf::from("a/b")
4182 );
4183 assert_eq!(
4184 lexically_normalize(Path::new("./a/./b")),
4185 PathBuf::from("a/b")
4186 );
4187 assert_eq!(
4190 lexically_normalize(Path::new("../a")),
4191 PathBuf::from("../a")
4192 );
4193 }
4194
4195 #[test]
4196 fn normalize_returns_the_lexical_form_unchanged_when_there_is_nothing_left_to_canonicalize() {
4197 assert_eq!(normalize(Path::new("")), PathBuf::new());
4201 }
4202
4203 #[test]
4204 fn prune_surfaces_a_read_error() {
4205 let dir = tempfile::tempdir().unwrap();
4208 let result = prune(
4209 dir.path(),
4210 &PruneOptions {
4211 older_than: None,
4212 max_size: Some(1),
4213 dry_run: false,
4214 },
4215 );
4216 assert!(result.is_err());
4217 }
4218
4219 #[test]
4220 fn prune_refuses_the_audit_file_by_direct_path() {
4221 let dir = tempfile::tempdir().unwrap();
4222 let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
4223 let audit_path = dir.path().join("audit.jsonl");
4224 std::fs::write(&audit_path, "{}\n").unwrap();
4225
4226 let err = prune(
4227 &audit_path,
4228 &PruneOptions {
4229 older_than: None,
4230 max_size: Some(1),
4231 dry_run: false,
4232 },
4233 )
4234 .err()
4235 .unwrap();
4236
4237 assert!(err.to_string().contains("audit log"), "{err}");
4238 assert_eq!(std::fs::read_to_string(&audit_path).unwrap(), "{}\n");
4239 }
4240
4241 #[test]
4242 fn prune_refuses_a_dot_dot_spelling_of_the_audit_file() {
4243 let dir = tempfile::tempdir().unwrap();
4244 let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
4245 let audit_path = dir.path().join("audit.jsonl");
4246 std::fs::write(&audit_path, "{}\n").unwrap();
4247 let dotted = dir.path().join("sub/../audit.jsonl");
4248
4249 let err = prune(
4250 &dotted,
4251 &PruneOptions {
4252 older_than: None,
4253 max_size: Some(1),
4254 dry_run: false,
4255 },
4256 )
4257 .err()
4258 .unwrap();
4259
4260 assert!(err.to_string().contains("audit log"), "{err}");
4261 assert_eq!(std::fs::read_to_string(&audit_path).unwrap(), "{}\n");
4262 }
4263
4264 #[test]
4265 fn prune_refuses_the_audit_file_even_under_dry_run() {
4266 let dir = tempfile::tempdir().unwrap();
4270 let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
4271 let audit_path = dir.path().join("audit.jsonl");
4272 std::fs::write(&audit_path, "{}\n").unwrap();
4273
4274 let err = prune(
4275 &audit_path,
4276 &PruneOptions {
4277 older_than: None,
4278 max_size: Some(1),
4279 dry_run: true,
4280 },
4281 )
4282 .err()
4283 .unwrap();
4284
4285 assert!(err.to_string().contains("audit log"), "{err}");
4286 }
4287
4288 #[cfg(unix)]
4289 #[test]
4290 fn append_with_rotation_refuses_to_rotate_the_audit_file() {
4291 let dir = tempfile::tempdir().unwrap();
4292 let _audit = crate::test_support::AuditLogGuard::redirect(dir.path());
4293 let audit_path = dir.path().join("audit.jsonl");
4294 std::fs::write(&audit_path, "0123456789012345\n").unwrap();
4296 let cfg = RotationConfig {
4297 max_size: 5,
4298 keep_files: 1,
4299 };
4300
4301 append_with_rotation(&audit_path, "new-line\n", &cfg).unwrap();
4302
4303 assert!(
4304 !sibling(&audit_path, ".1").exists(),
4305 "the audit file must never be rotated"
4306 );
4307 assert!(
4308 !sibling(&audit_path, ".lock").exists(),
4309 "a refused rotation must not create a lock file next to the audit file"
4310 );
4311 assert!(
4312 std::fs::read_to_string(&audit_path)
4313 .unwrap()
4314 .contains("new-line"),
4315 "the line is still appended (best effort)"
4316 );
4317 }
4318
4319 #[cfg(unix)]
4320 #[test]
4321 fn append_with_rotation_appends_even_when_rotate_fails() {
4322 let dir = tempfile::tempdir().unwrap();
4323 let path = dir.path().join("log.jsonl");
4324 std::fs::write(&path, "0123456789012345\n").unwrap();
4326 std::fs::create_dir(sibling(&path, ".1")).unwrap();
4328 let cfg = RotationConfig {
4329 max_size: 5,
4330 keep_files: 1,
4331 };
4332 append_with_rotation(&path, "new-line\n", &cfg).unwrap();
4334 assert!(
4335 std::fs::read_to_string(&path).unwrap().contains("new-line"),
4336 "the record is appended despite the rotation failure"
4337 );
4338 }
4339
4340 #[test]
4341 fn prune_cleans_up_temp_on_rewrite_failure() {
4342 let dir = tempfile::tempdir().unwrap();
4343 let path = dir.path().join("log.jsonl");
4344 std::fs::write(
4345 &path,
4346 format!(
4347 "{}\n{}\n",
4348 http_line("a", "2999-01-01T00:00:00.000Z"),
4349 http_line("b", "2999-01-01T00:00:00.000Z"),
4350 ),
4351 )
4352 .unwrap();
4353 let tmp = sibling(&path, &format!(".prune.{}.tmp", std::process::id()));
4356 std::fs::create_dir(&tmp).unwrap();
4357
4358 let result = prune(
4359 &path,
4360 &PruneOptions {
4361 older_than: None,
4362 max_size: Some(1),
4363 dry_run: false,
4364 },
4365 );
4366 assert!(result.is_err(), "a failing rewrite surfaces as an error");
4367 let _ = std::fs::remove_dir(&tmp);
4368 }
4369
4370 #[tokio::test]
4371 async fn scope_origin_id_overwrites_id_but_preserves_source() {
4372 let base = RequestLogContext {
4375 invocation_id: "daemon-1".to_string(),
4376 source: Source::Daemon,
4377 mcp_tool: None,
4378 };
4379 CTX.scope(base, async {
4380 scope_origin_id("cli-42".to_string(), async {
4381 let ctx = current_context();
4382 assert_eq!(ctx.invocation_id, "cli-42");
4384 assert_eq!(ctx.source, Source::Daemon);
4386 })
4387 .await;
4388 assert_eq!(current_context().invocation_id, "daemon-1");
4390 })
4391 .await;
4392 }
4393}