1use std::path::{Path, PathBuf};
30
31use anyhow::{Context, Result};
32use tracing_appender::non_blocking::WorkerGuard;
33use tracing_appender::rolling::{RollingFileAppender, Rotation};
34
35use crate::config::LoggingConfig;
36use crate::log_paths;
37
38const DEFAULT_PREFIX: &str = "ai-memory.log";
41
42pub const DEFAULT_LOG_DIRECTIVE: &str = "ai_memory=info";
47
48pub fn init_file_logging(cfg: &LoggingConfig) -> Result<Option<WorkerGuard>> {
57 if !cfg.enabled.unwrap_or(false) {
58 return Ok(None);
59 }
60 let dir = resolve_log_dir(cfg);
61 log_paths::ensure_dir_secure(&dir)
62 .with_context(|| format!("creating log dir {}", dir.display()))?;
63 let appender = build_appender(&dir, cfg)?;
69 let (writer, guard) = tracing_appender::non_blocking(appender);
70 let level = cfg.level.as_deref().unwrap_or("info");
74 let filter = tracing_subscriber::EnvFilter::try_new(level).unwrap_or_else(|_| {
75 tracing_subscriber::EnvFilter::try_new("info").expect("info is a valid filter")
76 });
77 let structured = cfg.structured.unwrap_or(false);
78 let res = if structured {
79 tracing_subscriber::fmt()
80 .with_env_filter(filter)
81 .with_writer(writer)
82 .json()
83 .try_init()
84 } else {
85 tracing_subscriber::fmt()
86 .with_env_filter(filter)
87 .with_writer(writer)
88 .try_init()
89 };
90 if let Err(e) = res {
91 tracing::debug!("file logging subscriber already initialised: {e}");
97 }
98 Ok(Some(guard))
99}
100
101#[must_use]
111pub fn resolve_log_dir(cfg: &LoggingConfig) -> PathBuf {
112 log_paths::resolve_log_dir(None, cfg.path.as_deref())
113 .map(|r| r.path)
114 .unwrap_or_else(|_| log_paths::platform_default(log_paths::DirKind::Log).path)
115}
116
117pub fn resolve_log_dir_with_override(
124 cli_override: Option<&Path>,
125 cfg: &LoggingConfig,
126) -> Result<log_paths::ResolvedDir> {
127 log_paths::resolve_log_dir(cli_override, cfg.path.as_deref())
128}
129
130pub fn build_appender(dir: &Path, cfg: &LoggingConfig) -> Result<RollingFileAppender> {
134 let rotation = rotation_for(cfg);
135 let max_files = cfg.max_files.unwrap_or(30);
136 let prefix = cfg
137 .filename_prefix
138 .clone()
139 .unwrap_or_else(|| DEFAULT_PREFIX.to_string());
140
141 RollingFileAppender::builder()
142 .filename_prefix(prefix)
143 .rotation(rotation)
144 .max_log_files(max_files)
145 .build(dir)
146 .with_context(|| format!("building rolling appender at {}", dir.display()))
147}
148
149fn rotation_for(cfg: &LoggingConfig) -> Rotation {
150 match cfg.rotation.as_deref().unwrap_or("daily") {
151 "minutely" => Rotation::MINUTELY,
152 "hourly" => Rotation::HOURLY,
153 "never" => Rotation::NEVER,
154 _ => Rotation::DAILY,
155 }
156}
157
158pub const URL_PASSWORD_MASK: &str = "****";
167
168#[must_use]
189pub fn redact_url_password(url: &str) -> String {
190 let Some(scheme_end) = url.find("://") else {
191 return url.to_string();
192 };
193 let authority_start = scheme_end + 3;
194 let rest = &url[authority_start..];
195 let authority_end = rest
197 .find(['/', '?', '#'])
198 .map_or(url.len(), |i| authority_start + i);
199 let authority = &url[authority_start..authority_end];
200 let Some(at_pos) = authority.rfind('@') else {
203 return url.to_string();
204 };
205 let userinfo = &authority[..at_pos];
206 let Some(colon_pos) = userinfo.find(':') else {
208 return url.to_string();
209 };
210 let mut out = String::with_capacity(url.len());
211 out.push_str(&url[..authority_start + colon_pos + 1]);
212 out.push_str(URL_PASSWORD_MASK);
213 out.push_str(&url[authority_start + at_pos..]);
214 out
215}
216
217#[must_use]
224pub fn redact_urls_in_message(msg: &str) -> String {
225 let mut out = String::with_capacity(msg.len());
226 let mut rest = msg;
227 while let Some(sep) = rest.find("://") {
228 let mut scheme_start = sep;
230 while scheme_start > 0 {
231 let c = rest.as_bytes()[scheme_start - 1];
232 if c.is_ascii_alphanumeric() || c == b'+' || c == b'-' || c == b'.' {
233 scheme_start -= 1;
234 } else {
235 break;
236 }
237 }
238 out.push_str(&rest[..scheme_start]);
239 let url_end = rest[sep..]
243 .find(|c: char| {
244 c.is_ascii_whitespace()
245 || matches!(
246 c,
247 '"' | '\'' | '`' | '{' | '}' | '(' | ')' | ',' | ';' | '<' | '>'
248 )
249 })
250 .map_or(rest.len(), |i| sep + i);
251 out.push_str(&redact_url_password(&rest[scheme_start..url_end]));
252 rest = &rest[url_end..];
253 }
254 out.push_str(rest);
255 out
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn rotation_for_default_is_daily() {
264 let cfg = LoggingConfig::default();
265 let r = rotation_for(&cfg);
267 assert!(format!("{r:?}").to_lowercase().contains("daily"));
268 }
269
270 #[test]
271 fn rotation_for_hourly() {
272 let cfg = LoggingConfig {
273 rotation: Some("hourly".to_string()),
274 ..Default::default()
275 };
276 let r = rotation_for(&cfg);
277 assert!(format!("{r:?}").to_lowercase().contains("hourly"));
278 }
279
280 #[test]
281 fn resolve_log_dir_default_under_home() {
282 let cfg = LoggingConfig::default();
283 let p = resolve_log_dir(&cfg);
284 assert!(p.to_string_lossy().contains("ai-memory"));
287 }
288
289 #[test]
290 fn build_appender_creates_file_under_tmp() {
291 let tmp = tempfile::tempdir().unwrap();
292 let cfg = LoggingConfig {
293 enabled: Some(true),
294 path: Some(tmp.path().to_string_lossy().into_owned()),
295 rotation: Some("never".to_string()),
296 ..Default::default()
297 };
298 let _appender = build_appender(tmp.path(), &cfg).unwrap();
299 assert!(tmp.path().is_dir());
302 }
303
304 #[test]
305 fn init_file_logging_returns_none_when_disabled() {
306 let cfg = LoggingConfig {
307 enabled: Some(false),
308 ..Default::default()
309 };
310 let guard = init_file_logging(&cfg).unwrap();
311 assert!(guard.is_none());
312 }
313
314 fn subscriber_lock() -> std::sync::MutexGuard<'static, ()> {
317 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
318 LOCK.get_or_init(|| std::sync::Mutex::new(()))
323 .lock()
324 .unwrap_or_else(|p| p.into_inner())
325 }
326
327 #[test]
328 fn init_file_logging_returns_guard_when_enabled() {
329 let _g = subscriber_lock();
330 let tmp = tempfile::tempdir().unwrap();
331 let cfg = LoggingConfig {
332 enabled: Some(true),
333 path: Some(tmp.path().to_string_lossy().into_owned()),
334 rotation: Some("never".to_string()),
335 level: Some("info".to_string()),
336 structured: Some(false),
337 ..Default::default()
338 };
339 let guard = init_file_logging(&cfg).unwrap();
343 assert!(
344 guard.is_some(),
345 "init_file_logging must return a WorkerGuard when enabled"
346 );
347 assert!(tmp.path().is_dir());
348 drop(guard);
351 }
352
353 #[test]
354 fn init_file_logging_emits_structured_json_when_configured() {
355 let _g = subscriber_lock();
356 let tmp = tempfile::tempdir().unwrap();
357 let cfg = LoggingConfig {
358 enabled: Some(true),
359 path: Some(tmp.path().to_string_lossy().into_owned()),
360 rotation: Some("never".to_string()),
361 level: Some("info".to_string()),
362 structured: Some(true),
363 ..Default::default()
364 };
365 let guard = init_file_logging(&cfg).unwrap();
366 assert!(guard.is_some(), "structured branch must produce a guard");
367 drop(guard);
368 }
369
370 #[test]
371 fn init_file_logging_accepts_invalid_level_falling_back_to_info() {
372 let _g = subscriber_lock();
373 let tmp = tempfile::tempdir().unwrap();
374 let cfg = LoggingConfig {
375 enabled: Some(true),
376 path: Some(tmp.path().to_string_lossy().into_owned()),
377 rotation: Some("never".to_string()),
378 level: Some("@invalid@directive@".to_string()),
383 ..Default::default()
384 };
385 let guard = init_file_logging(&cfg).unwrap();
387 assert!(guard.is_some());
388 }
389
390 #[test]
391 fn init_file_logging_fallback_filter_on_malformed_directive() {
392 let _g = subscriber_lock();
397 let tmp = tempfile::tempdir().unwrap();
398 let cfg = LoggingConfig {
399 enabled: Some(true),
400 path: Some(tmp.path().to_string_lossy().into_owned()),
401 rotation: Some("never".to_string()),
402 level: Some("my_target=not_a_level".to_string()),
405 ..Default::default()
406 };
407 let guard = init_file_logging(&cfg).unwrap();
408 assert!(guard.is_some());
409 }
410
411 #[test]
412 fn rotation_for_minutely() {
413 let cfg = LoggingConfig {
414 rotation: Some("minutely".to_string()),
415 ..Default::default()
416 };
417 let r = rotation_for(&cfg);
418 assert!(format!("{r:?}").to_lowercase().contains("minutely"));
419 }
420
421 #[test]
422 fn rotation_for_never() {
423 let cfg = LoggingConfig {
424 rotation: Some("never".to_string()),
425 ..Default::default()
426 };
427 let r = rotation_for(&cfg);
428 assert!(format!("{r:?}").to_lowercase().contains("never"));
429 }
430
431 #[test]
432 fn rotation_for_unknown_falls_back_to_daily() {
433 let cfg = LoggingConfig {
434 rotation: Some("garbage".to_string()),
435 ..Default::default()
436 };
437 let r = rotation_for(&cfg);
438 assert!(format!("{r:?}").to_lowercase().contains("daily"));
439 }
440
441 #[test]
442 fn build_appender_honours_explicit_filename_prefix() {
443 let tmp = tempfile::tempdir().unwrap();
444 let cfg = LoggingConfig {
445 enabled: Some(true),
446 path: Some(tmp.path().to_string_lossy().into_owned()),
447 rotation: Some("never".to_string()),
448 filename_prefix: Some("custom-prefix".to_string()),
449 ..Default::default()
450 };
451 let _appender = build_appender(tmp.path(), &cfg).unwrap();
453 }
454
455 #[test]
456 fn resolve_log_dir_with_override_uses_cli_layer() {
457 let tmp = tempfile::tempdir().unwrap();
458 let cfg = LoggingConfig::default();
459 let r = resolve_log_dir_with_override(Some(tmp.path()), &cfg).unwrap();
460 assert_eq!(r.path, tmp.path());
461 assert_eq!(r.source, log_paths::PathSource::CliFlag);
462 }
463
464 #[cfg(unix)]
465 #[test]
466 fn resolve_log_dir_with_override_propagates_world_writable_error() {
467 use std::os::unix::fs::PermissionsExt;
468 let tmp = tempfile::tempdir().unwrap();
469 let bad = tmp.path().join("worldwrite");
470 std::fs::create_dir(&bad).unwrap();
471 std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o777)).unwrap();
472 let cfg = LoggingConfig::default();
473 let err = resolve_log_dir_with_override(Some(&bad), &cfg).unwrap_err();
474 let msg = format!("{err}");
475 assert!(msg.contains("world-writable"), "got: {msg}");
476 }
477
478 #[test]
484 fn init_file_logging_second_call_does_not_panic() {
485 let _g = subscriber_lock();
486 let tmp = tempfile::tempdir().unwrap();
487 let cfg = LoggingConfig {
488 enabled: Some(true),
489 path: Some(tmp.path().to_string_lossy().into_owned()),
490 rotation: Some("never".to_string()),
491 ..Default::default()
492 };
493 let _first = init_file_logging(&cfg);
497 let second = init_file_logging(&cfg).expect("second init must not error");
498 assert!(
499 second.is_some(),
500 "second init still returns Some(guard); try_init failure goes to debug! not Err"
501 );
502 }
503
504 #[test]
505 fn init_file_logging_default_enabled_field_is_off() {
506 let cfg = LoggingConfig::default();
509 let guard = init_file_logging(&cfg).expect("disabled returns Ok(None)");
510 assert!(guard.is_none());
511 }
512
513 #[cfg(unix)]
519 #[test]
520 fn init_file_logging_propagates_ensure_dir_secure_failure() {
521 let _g = subscriber_lock();
526 let tmp = tempfile::tempdir().unwrap();
527 let blocker = tmp.path().join("blocker");
528 std::fs::write(&blocker, b"file").unwrap();
529 let cfg = LoggingConfig {
532 enabled: Some(true),
533 path: Some(blocker.join("sub").to_string_lossy().into_owned()),
534 rotation: Some("never".to_string()),
535 ..Default::default()
536 };
537 let res = init_file_logging(&cfg);
538 assert!(
539 res.is_err(),
540 "init_file_logging must propagate create_dir failure"
541 );
542 let err = res.unwrap_err();
543 let msg = format!("{err:#}");
544 assert!(
545 msg.contains("creating log dir") || msg.contains("creating log directory"),
546 "expected wrapped context, got: {msg}"
547 );
548 }
549
550 #[cfg(unix)]
551 #[test]
552 fn resolve_log_dir_falls_back_to_platform_default_when_world_writable() {
553 use std::os::unix::fs::PermissionsExt;
557 let _g = subscriber_lock();
558 let tmp = tempfile::tempdir().unwrap();
559 let bad = tmp.path().join("worldwrite");
560 std::fs::create_dir(&bad).unwrap();
561 std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o777)).unwrap();
562 let cfg = LoggingConfig {
563 path: Some(bad.to_string_lossy().into_owned()),
564 ..Default::default()
565 };
566 let p = resolve_log_dir(&cfg);
567 assert_ne!(p, bad);
570 assert!(p.to_string_lossy().contains("ai-memory"));
571 }
572
573 #[cfg(unix)]
574 #[test]
575 fn build_appender_returns_context_on_unwritable_dir() {
576 let tmp = tempfile::tempdir().unwrap();
580 let not_a_dir = tmp.path().join("not_a_dir_file");
581 std::fs::write(¬_a_dir, b"hello").unwrap();
582 let cfg = LoggingConfig {
583 rotation: Some("never".to_string()),
584 ..Default::default()
585 };
586 let res = build_appender(¬_a_dir, &cfg);
587 if let Err(err) = res {
591 let msg = format!("{err:#}");
592 assert!(
593 msg.contains("building rolling appender"),
594 "expected wrapped context, got: {msg}"
595 );
596 }
597 }
598
599 #[test]
615 fn redact_masks_postgres_password() {
616 let url = "postgres://ai_memory:hunter2@db.internal:5432/ai_memory";
617 let redacted = redact_url_password(url);
618 assert_eq!(
619 redacted,
620 "postgres://ai_memory:****@db.internal:5432/ai_memory"
621 );
622 assert!(!redacted.contains("hunter2"));
623 }
624
625 #[test]
626 fn redact_masks_postgresql_scheme_too() {
627 let url = "postgresql://u:s3cr3t@h:5432/db";
628 let redacted = redact_url_password(url);
629 assert_eq!(redacted, "postgresql://u:****@h:5432/db");
630 }
631
632 #[test]
633 fn redact_without_password_is_unchanged() {
634 assert_eq!(
636 redact_url_password("postgres://user@host:5432/db"),
637 "postgres://user@host:5432/db"
638 );
639 assert_eq!(
641 redact_url_password("postgres://host:5432/db"),
642 "postgres://host:5432/db"
643 );
644 }
645
646 #[test]
647 fn redact_leaves_sqlite_paths_unchanged() {
648 assert_eq!(
649 redact_url_password("sqlite:///var/lib/ai-memory/mem.db"),
650 "sqlite:///var/lib/ai-memory/mem.db"
651 );
652 assert_eq!(
654 redact_url_password("/var/lib/ai-memory/mem.db"),
655 "/var/lib/ai-memory/mem.db"
656 );
657 }
658
659 #[test]
660 fn redact_handles_password_containing_at_and_colon() {
661 let url = "postgres://user:p@:ss@host/db";
665 let redacted = redact_url_password(url);
666 assert_eq!(redacted, "postgres://user:****@host/db");
667 assert!(!redacted.contains("p@:ss"));
668 }
669
670 #[test]
671 fn redact_does_not_touch_password_like_text_in_path_or_query() {
672 let url = "postgres://host/db?options=a:b@c";
674 assert_eq!(redact_url_password(url), url);
675 }
676
677 #[test]
678 fn redact_message_masks_embedded_url() {
679 let msg = "connect failed: invalid url postgres://admin:hunter2@db:5432/mem (timeout)";
680 let clean = redact_urls_in_message(msg);
681 assert!(!clean.contains("hunter2"), "password leaked: {clean}");
682 assert!(clean.contains("postgres://admin:****@db:5432/mem"));
683 assert!(clean.starts_with("connect failed: invalid url "));
684 assert!(clean.ends_with(" (timeout)"));
685 }
686
687 #[test]
688 fn redact_message_handles_multiple_urls() {
689 let msg = "from postgres://a:pw1@h1/db to postgres://b:pw2@h2/db";
690 let clean = redact_urls_in_message(msg);
691 assert!(!clean.contains("pw1") && !clean.contains("pw2"));
692 assert!(clean.contains("postgres://a:****@h1/db"));
693 assert!(clean.contains("postgres://b:****@h2/db"));
694 }
695
696 #[test]
697 fn redact_message_without_urls_is_identity() {
698 let msg = "plain diagnostic with no connection string";
699 assert_eq!(redact_urls_in_message(msg), msg);
700 }
701}