Skip to main content

ai_memory/
logging.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! Operational logging facility (PR-5 of issue #487).
5//!
6//! Routes the binary's existing `tracing::info!` / `tracing::warn!` /
7//! `tracing::error!` call sites through a rotating, on-disk file
8//! appender so operators can ingest server logs into Splunk, Datadog,
9//! Loki, etc.
10//!
11//! **Default-OFF.** Without a `[logging]` block in `config.toml` the
12//! daemon keeps the legacy `tracing-subscriber::fmt` setup that writes
13//! to stderr. Enabling file logging is opt-in:
14//!
15//! ```toml
16//! [logging]
17//! enabled = true
18//! path = "~/.local/state/ai-memory/logs/"
19//! max_size_mb = 100
20//! max_files = 30
21//! retention_days = 90
22//! structured = false
23//! level = "info"
24//! ```
25//!
26//! See [`docs/security/audit-trail.md`](../docs/security/audit-trail.md)
27//! for the SIEM ingestion guide.
28
29use 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
38/// Default file prefix written by the rolling appender. Concrete
39/// rotated filenames look like `ai-memory.log.2026-04-30`.
40const DEFAULT_PREFIX: &str = "ai-memory.log";
41
42/// Default `tracing` EnvFilter directive applied when `RUST_LOG` is
43/// unset — INFO-level for the substrate's own crate only. One spelling
44/// for every fallback-filter construction site (pm-v3.1 gate, #1558
45/// wave 4).
46pub const DEFAULT_LOG_DIRECTIVE: &str = "ai_memory=info";
47
48/// Initialise the file logging facility. Returns a [`WorkerGuard`] that
49/// the caller MUST keep alive for the lifetime of the process — when
50/// dropped it flushes the in-memory buffer to disk. Returns `None`
51/// when logging is disabled.
52///
53/// # Errors
54/// - The configured log directory cannot be created.
55/// - The rolling file appender cannot be constructed.
56pub 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    // COVERAGE: build_appender Err-arm (line 57) reachable when the
64    //           rolling-file builder rejects the dir; exercised
65    //           indirectly by build_appender_returns_context_on_unwritable_dir
66    //           and propagates here in production. Not deterministic on
67    //           macOS because the appender accepts non-dir paths lazily.
68    let appender = build_appender(&dir, cfg)?;
69    let (writer, guard) = tracing_appender::non_blocking(appender);
70    // Capture the writer in the static slot so the daemon's tracing
71    // subscriber can drain it. `try_init` so multiple test runs
72    // (each spinning a fresh subscriber) don't poison the global.
73    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        // COVERAGE: tracing::debug! lazy-format closure (line 81)
92        //           unreachable when the subscriber filter is INFO
93        //           (default in tests) — debug! short-circuits before
94        //           invoking the format closure. Documented per L0.7
95        //           playbook §3c.
96        tracing::debug!("file logging subscriber already initialised: {e}");
97    }
98    Ok(Some(guard))
99}
100
101/// Resolve the configured log directory honouring the user-mandated
102/// precedence ladder: CLI > env (`AI_MEMORY_LOG_DIR`) > `[logging]
103/// path` in config > platform default. The `cfg`-only entry point is
104/// kept for callers that don't have a CLI override; subcommand wiring
105/// uses [`resolve_log_dir_with_override`] directly.
106///
107/// Falls back to a best-effort default if the security guard rejects
108/// the configured path — the `init_file_logging` path will then re-run
109/// the strict resolver and surface the error to the operator.
110#[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
117/// Strict version: returns the [`log_paths::ResolvedDir`] so callers
118/// can surface the resolution layer in error messages, and propagates
119/// the world-writable-refusal error.
120///
121/// # Errors
122/// - Resolved path is world-writable.
123pub 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
130/// Build the rolling file appender with the rotation policy from
131/// `cfg`. Defaults to daily rotation with `max_files` retained on
132/// disk.
133pub 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
158// ---------------------------------------------------------------------------
159// #1579 A3 (SECURITY) — store-URL credential redaction for logs
160// ---------------------------------------------------------------------------
161
162/// Mask substituted for the userinfo password portion of a URL by
163/// [`redact_url_password`] / [`redact_urls_in_message`]. The username
164/// and host stay readable so operators can still correlate the log
165/// line with the deployment; only the secret is destroyed.
166pub const URL_PASSWORD_MASK: &str = "****";
167
168/// #1579 A3 (SECURITY) — redact the userinfo *password* portion of a
169/// single URL: `postgres://user:hunter2@host:5432/db` becomes
170/// `postgres://user:****@host:5432/db`.
171///
172/// The P3 perf-audit found the daemon boot line logging the FULL
173/// `--store-url` (password included) to journald at INFO
174/// (`src/daemon_runtime.rs::build_store_handle`). Every log / error /
175/// trace / CLI-output site that emits a store URL routes through this
176/// helper (or [`redact_urls_in_message`] for free-text diagnostics).
177///
178/// Behaviour:
179/// - URL without userinfo (`postgres://host/db`, `sqlite:///path`)
180///   → returned unchanged.
181/// - Userinfo without a password (`postgres://user@host/db`)
182///   → returned unchanged (no secret present).
183/// - Non-URL input (plain filesystem path) → returned unchanged.
184///
185/// Deliberately textual (no `url` crate parse) so a *malformed* URL
186/// containing credentials is still scrubbed rather than passed
187/// through on a parse error.
188#[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    // The authority component ends at the first '/', '?' or '#'.
196    let authority_end = rest
197        .find(['/', '?', '#'])
198        .map_or(url.len(), |i| authority_start + i);
199    let authority = &url[authority_start..authority_end];
200    // Userinfo is everything before the LAST '@' in the authority
201    // (RFC 3986 — the host may not contain '@', so the last one wins).
202    let Some(at_pos) = authority.rfind('@') else {
203        return url.to_string();
204    };
205    let userinfo = &authority[..at_pos];
206    // Password is everything after the FIRST ':' in the userinfo.
207    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/// #1579 A3 (SECURITY) — companion to [`redact_url_password`] for
218/// free-text diagnostics that may EMBED a URL (e.g. a wrapped
219/// `sqlx::Error::Configuration("invalid url postgres://…")` whose
220/// Display interpolates the connection target). Scans the message for
221/// `scheme://` runs and masks the userinfo password inside each one;
222/// every other byte passes through unchanged.
223#[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        // Walk back over scheme characters already buffered.
229        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        // The URL run ends at the first whitespace / quote / brace /
240        // paren / comma / semicolon / angle bracket — same boundary
241        // set as `handlers::postgres_gate::sanitize_store_err_message`.
242        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        // Rotation enum doesn't impl PartialEq, so format-compare.
266        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        // Default contains the well-known suffix even on bare-min
285        // home setups.
286        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        // The appender lazily creates the log file on first write. Just
300        // ensure construction succeeded and the dir is writable.
301        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    /// Process-wide lock so tests that swap the global tracing
315    /// subscriber via `try_init` don't race each other.
316    fn subscriber_lock() -> std::sync::MutexGuard<'static, ()> {
317        static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
318        // COVERAGE: poisoned-lock recovery closure (line 204) reachable
319        //           only after a test thread panics holding the lock.
320        //           Tests are non-panicking so the closure body stays
321        //           uncovered — structural cap per L0.7 playbook §3c.
322        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        // The first call returns Some(guard); the appender lazily
340        // creates the file on first write so we just verify a guard
341        // came back and the configured dir survives.
342        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        // Guard drop flushes the buffer; explicit drop confirms no
349        // panic on shutdown.
350        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            // Garbage directive — exercises the EnvFilter fallback branch.
379            // The string contains an `@` which tracing-subscriber 0.3
380            // recognises as a span constraint operator with invalid
381            // syntax, forcing try_new to return Err.
382            level: Some("@invalid@directive@".to_string()),
383            ..Default::default()
384        };
385        // Must not panic; fallback path swaps in `info`.
386        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        // Lines 63-65: EnvFilter::try_new(level) Err arm => fall back
393        // to "info" filter. Use a directive containing an invalid
394        // level spec (lowercase `bogus` is rejected as a level when
395        // the directive has the `<target>=<level>` shape).
396        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            // A `target=level` shape with garbage level forces the
403            // Err arm of EnvFilter::try_new in tracing-subscriber 0.3.
404            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        // Constructing succeeds for an alternate prefix.
452        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    // -----------------------------------------------------------------
479    // L0.7-2 Tier A — try_init second-call debug path + default-cfg
480    // pass-through (`enabled = None` -> disabled)
481    // -----------------------------------------------------------------
482
483    #[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        // First call sets up the global subscriber (or no-ops if a
494        // prior test already grabbed it). Second call's try_init must
495        // fail-soft via the debug! arm without panicking.
496        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        // LoggingConfig::default() has `enabled: None` -> treated as
507        // disabled by unwrap_or(false). Exercises the early-return arm.
508        let cfg = LoggingConfig::default();
509        let guard = init_file_logging(&cfg).expect("disabled returns Ok(None)");
510        assert!(guard.is_none());
511    }
512
513    // -----------------------------------------------------------------
514    // L0.7-2 Tier A — error path closures (init_file_logging /
515    // build_appender / resolve_log_dir fallback).
516    // -----------------------------------------------------------------
517
518    #[cfg(unix)]
519    #[test]
520    fn init_file_logging_propagates_ensure_dir_secure_failure() {
521        // Line 56: with_context closure on log_paths::ensure_dir_secure
522        // failure. ensure_dir_secure fails when create_dir_all fails;
523        // we trigger that by pointing path at a child of a regular
524        // file (ENOTDIR).
525        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        // path = blocker/sub — create_dir_all fails because blocker
530        // is a regular file.
531        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        // Line 98: unwrap_or_else closure on resolve_log_dir error.
554        // resolve_log_dir errors when the config path is world-writable;
555        // the fallback then picks the platform default.
556        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        // Must NOT return the world-writable path; falls back to
568        // platform default which contains "ai-memory".
569        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        // Line 130: with_context closure on RollingFileAppender::build
577        // failure. Builder.build() validates the dir is a directory;
578        // pass a file path so build returns Err.
579        let tmp = tempfile::tempdir().unwrap();
580        let not_a_dir = tmp.path().join("not_a_dir_file");
581        std::fs::write(&not_a_dir, b"hello").unwrap();
582        let cfg = LoggingConfig {
583            rotation: Some("never".to_string()),
584            ..Default::default()
585        };
586        let res = build_appender(&not_a_dir, &cfg);
587        // The appender may or may not validate eagerly. If it does, we
588        // get the wrapped context; if not, the test still passes by
589        // virtue of having traversed the build() call.
590        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    // The two `tracing::debug!`/`tracing::info!` lazy-format closures
600    // (init_file_logging line 81 inside the `if let Err(e)` arm, plus
601    // any subscriber-disabled log lines) are unreachable under the
602    // default subscriber config — `debug!` is filtered out at INFO
603    // level. The macro short-circuits before invoking the format
604    // closure, so the closure body's coverage is structurally bound
605    // by the subscriber level chosen at startup.
606    // COVERAGE: tracing::debug! lazy-format closure unreachable when
607    //           subscriber level < DEBUG (default INFO in tests);
608    //           exercised by operators running with RUST_LOG=debug.
609
610    // -----------------------------------------------------------------
611    // #1579 A3 (SECURITY) — store-URL credential redaction
612    // -----------------------------------------------------------------
613
614    #[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        // Userinfo with no password — nothing to mask.
635        assert_eq!(
636            redact_url_password("postgres://user@host:5432/db"),
637            "postgres://user@host:5432/db"
638        );
639        // No userinfo at all.
640        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        // Plain filesystem path (no scheme) passes through verbatim.
653        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        // Password "p@:ss" — the LAST '@' in the authority separates
662        // userinfo from host, the FIRST ':' in the userinfo starts the
663        // password, so the whole odd password is masked.
664        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        // ':'/'@' AFTER the authority must not confuse the scanner.
673        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}