Skip to main content

raps_kernel/
logging.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Logging and verbosity control
5//!
6//! Provides global flags for controlling output verbosity and formatting:
7//! - --no-color: Disable ANSI colors
8//! - --quiet: Print only result payload
9//! - --verbose: Show request summaries
10//! - --debug: Include full trace (redacts secrets)
11
12use regex::Regex;
13use std::io::Write;
14use std::sync::Mutex;
15use std::sync::OnceLock;
16use std::sync::atomic::{AtomicBool, Ordering};
17use tracing_appender::non_blocking::WorkerGuard;
18use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt};
19
20/// Global logging state
21static NO_COLOR: AtomicBool = AtomicBool::new(false);
22static QUIET: AtomicBool = AtomicBool::new(false);
23static VERBOSE: AtomicBool = AtomicBool::new(false);
24static DEBUG: AtomicBool = AtomicBool::new(false);
25
26static WORKER_GUARD: Mutex<Option<WorkerGuard>> = Mutex::new(None);
27
28/// Flush background logs by dropping the WorkerGuard
29pub fn flush() {
30    if let Ok(mut guard) = WORKER_GUARD.lock() {
31        let _ = guard.take(); // dropping the guard flushes the async logger
32    }
33}
34
35/// Initialize logging flags and tracing
36pub fn init(no_color: bool, quiet: bool, verbose: bool, debug: bool) {
37    NO_COLOR.store(no_color, Ordering::Relaxed);
38    QUIET.store(quiet, Ordering::Relaxed);
39    VERBOSE.store(verbose, Ordering::Relaxed);
40    DEBUG.store(debug, Ordering::Relaxed);
41
42    // Disable colored output globally if --no-color is set
43    if no_color {
44        colored::control::set_override(false);
45    }
46
47    // Allow RAPS_LOG or RUST_LOG env vars to override CLI flags
48    let console_filter = std::env::var("RAPS_LOG")
49        .or_else(|_| std::env::var("RUST_LOG"))
50        .map(EnvFilter::new)
51        .unwrap_or_else(|_| {
52            if debug {
53                EnvFilter::new("debug")
54            } else if verbose {
55                EnvFilter::new("info")
56            } else if quiet {
57                EnvFilter::new("error")
58            } else {
59                EnvFilter::new("warn")
60            }
61        });
62
63    let stderr_layer = tracing_subscriber::fmt::layer()
64        .with_writer(RedactingMakeWriter::new(std::io::stderr))
65        .with_ansi(!no_color)
66        .with_target(debug)
67        .without_time()
68        .with_filter(console_filter);
69
70    let log_dir = directories::ProjectDirs::from("com", "autodesk", "raps")
71        .map(|dirs| dirs.data_local_dir().join("logs"))
72        .unwrap_or_else(|| {
73            std::env::current_dir()
74                .unwrap_or_else(|_| std::path::PathBuf::from("."))
75                .join(".raps-logs")
76        });
77
78    let _ = crate::security::create_dir_restricted(&log_dir);
79    cleanup_old_logs(&log_dir, 7);
80
81    let file_appender = tracing_appender::rolling::daily(&log_dir, "raps.log");
82    let (non_blocking_appender, guard) = tracing_appender::non_blocking(file_appender);
83    if let Ok(mut lock) = WORKER_GUARD.lock() {
84        *lock = Some(guard);
85    }
86
87    // File log filter: configurable via RAPS_FILE_LOG env var
88    let file_filter = std::env::var("RAPS_FILE_LOG")
89        .map(EnvFilter::new)
90        .unwrap_or_else(|_| EnvFilter::new("raps=debug,info"));
91
92    // File log format: JSON if RAPS_FILE_FORMAT=json, plain text otherwise
93    let use_json = std::env::var("RAPS_FILE_FORMAT")
94        .map(|v| v.eq_ignore_ascii_case("json"))
95        .unwrap_or(false);
96
97    let redacting_appender = RedactingMakeWriter::new(non_blocking_appender);
98
99    let file_layer: Box<dyn Layer<_> + Send + Sync> = if use_json {
100        Box::new(
101            tracing_subscriber::fmt::layer()
102                .json()
103                .with_writer(redacting_appender)
104                .with_current_span(true)
105                .with_filter(file_filter),
106        )
107    } else {
108        Box::new(
109            tracing_subscriber::fmt::layer()
110                .with_writer(redacting_appender)
111                .with_ansi(false)
112                .with_filter(file_filter),
113        )
114    };
115
116    let _ = tracing_subscriber::registry()
117        .with(stderr_layer)
118        .with(file_layer)
119        .try_init();
120}
121
122/// Check if colors should be disabled
123#[allow(dead_code)] // May be used in future
124pub fn no_color() -> bool {
125    NO_COLOR.load(Ordering::Relaxed)
126}
127
128/// Check if quiet mode is enabled
129pub fn quiet() -> bool {
130    QUIET.load(Ordering::Relaxed)
131}
132
133/// Check if verbose mode is enabled
134pub fn verbose() -> bool {
135    VERBOSE.load(Ordering::Relaxed)
136}
137
138/// Check if debug mode is enabled
139pub fn debug() -> bool {
140    DEBUG.load(Ordering::Relaxed)
141}
142
143/// Redact secrets from debug output
144pub fn redact_secrets(text: &str) -> String {
145    fn secret_pattern() -> &'static Regex {
146        static PAT: OnceLock<Regex> = OnceLock::new();
147        PAT.get_or_init(|| {
148            Regex::new(r"(?i)(client[_-]?secret|secret[_-]?key|api[_-]?key)\s*[:=]\s*[^\s]+")
149                .expect("secret_pattern regex is valid")
150        })
151    }
152
153    fn token_pattern() -> &'static Regex {
154        static PAT: OnceLock<Regex> = OnceLock::new();
155        PAT.get_or_init(|| {
156            Regex::new(
157                r#"(?i)(token|access[_-]?token|refresh[_-]?token|bearer)\s*"?\s*[:=]\s*"?\s*([A-Za-z0-9_\-\.]{20,})"#,
158            )
159            .expect("token_pattern regex is valid")
160        })
161    }
162
163    fn auth_header_pattern() -> &'static Regex {
164        static PAT: OnceLock<Regex> = OnceLock::new();
165        PAT.get_or_init(|| {
166            Regex::new(r"(?i)(Authorization:\s*(?:Bearer|Basic))\s+[^\s,;]+")
167                .expect("auth_header_pattern regex is valid")
168        })
169    }
170
171    fn cookie_pattern() -> &'static Regex {
172        static PAT: OnceLock<Regex> = OnceLock::new();
173        PAT.get_or_init(|| {
174            Regex::new(r"(?i)((?:Set-)?Cookie:)\s*[^\r\n]+").expect("cookie_pattern regex is valid")
175        })
176    }
177
178    fn x_api_key_pattern() -> &'static Regex {
179        static PAT: OnceLock<Regex> = OnceLock::new();
180        PAT.get_or_init(|| {
181            Regex::new(r"(?i)(X-API-Key:)\s*[^\s,;]+").expect("x_api_key_pattern regex is valid")
182        })
183    }
184
185    fn url_token_pattern() -> &'static Regex {
186        static PAT: OnceLock<Regex> = OnceLock::new();
187        PAT.get_or_init(|| {
188            Regex::new(r"(?i)([?&](?:access_token|apikey|api_key|token)=)[^&\s]+")
189                .expect("url_token_pattern regex is valid")
190        })
191    }
192
193    let redacted = secret_pattern().replace_all(text, "$1: [REDACTED]");
194    let redacted = token_pattern().replace_all(&redacted, "$1: [REDACTED]");
195    let redacted = auth_header_pattern().replace_all(&redacted, "$1 [REDACTED]");
196    let redacted = cookie_pattern().replace_all(&redacted, "$1 [REDACTED]");
197    let redacted = x_api_key_pattern().replace_all(&redacted, "$1 [REDACTED]");
198    url_token_pattern()
199        .replace_all(&redacted, "${1}[REDACTED]")
200        .into_owned()
201}
202
203/// A writer wrapper that redacts secrets from every line before passing to the inner writer.
204struct RedactingWriter<W: Write> {
205    inner: W,
206}
207
208impl<W: Write> Write for RedactingWriter<W> {
209    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
210        let text = String::from_utf8_lossy(buf);
211        let redacted = redact_secrets(&text);
212        self.inner.write_all(redacted.as_bytes())?;
213        Ok(buf.len())
214    }
215
216    fn flush(&mut self) -> std::io::Result<()> {
217        self.inner.flush()
218    }
219}
220
221/// A `MakeWriter` that wraps another writer with automatic secret redaction.
222struct RedactingMakeWriter<W> {
223    inner: W,
224}
225
226impl<W> RedactingMakeWriter<W> {
227    fn new(inner: W) -> Self {
228        Self { inner }
229    }
230}
231
232impl<'a, W> tracing_subscriber::fmt::MakeWriter<'a> for RedactingMakeWriter<W>
233where
234    W: tracing_subscriber::fmt::MakeWriter<'a>,
235{
236    type Writer = RedactingWriter<W::Writer>;
237
238    fn make_writer(&'a self) -> Self::Writer {
239        RedactingWriter {
240            inner: self.inner.make_writer(),
241        }
242    }
243}
244
245/// Maximum total log size in bytes (50 MB).
246const MAX_LOG_BYTES: u64 = 50 * 1024 * 1024;
247
248/// Remove old log files, keeping at most `max_files` and staying under `MAX_LOG_BYTES`.
249fn cleanup_old_logs(log_dir: &std::path::Path, max_files: usize) {
250    let Ok(entries) = std::fs::read_dir(log_dir) else {
251        return;
252    };
253    let mut files: Vec<_> = entries
254        .flatten()
255        .filter(|e| e.file_name().to_string_lossy().starts_with("raps.log"))
256        .collect();
257    // Most recent first
258    files.sort_by_key(|e| std::cmp::Reverse(e.metadata().and_then(|m| m.modified()).ok()));
259    let mut total_size = 0u64;
260    for (i, file) in files.iter().enumerate() {
261        let size = file.metadata().map(|m| m.len()).unwrap_or(0);
262        total_size += size;
263        if i >= max_files || total_size > MAX_LOG_BYTES {
264            let _ = std::fs::remove_file(file.path());
265        }
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    // Note: Flag tests are not reliable due to global state and parallel test execution.
274    // The init() function modifies global AtomicBool values which can race with other tests.
275    // Testing redact_secrets is more valuable and deterministic.
276
277    // ==================== Redact Secrets Tests ====================
278
279    #[test]
280    fn test_redact_client_secret() {
281        let text = "client_secret: abc123xyz";
282        let redacted = redact_secrets(text);
283        assert!(redacted.contains("[REDACTED]"));
284        assert!(!redacted.contains("abc123xyz"));
285    }
286
287    #[test]
288    fn test_redact_client_secret_underscore() {
289        let text = "client_secret=my_super_secret_value";
290        let redacted = redact_secrets(text);
291        assert!(redacted.contains("[REDACTED]"));
292        assert!(!redacted.contains("my_super_secret_value"));
293    }
294
295    #[test]
296    fn test_redact_api_key() {
297        let text = "api_key: supersecretapikey123";
298        let redacted = redact_secrets(text);
299        assert!(redacted.contains("[REDACTED]"));
300        assert!(!redacted.contains("supersecretapikey123"));
301    }
302
303    #[test]
304    fn test_redact_api_key_dash() {
305        let text = "api-key=myapikey456";
306        let redacted = redact_secrets(text);
307        assert!(redacted.contains("[REDACTED]"));
308        assert!(!redacted.contains("myapikey456"));
309    }
310
311    #[test]
312    fn test_redact_secret_key() {
313        let text = "secret_key: topsecret";
314        let redacted = redact_secrets(text);
315        assert!(redacted.contains("[REDACTED]"));
316        assert!(!redacted.contains("topsecret"));
317    }
318
319    #[test]
320    fn test_redact_access_token() {
321        let text = "access_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
322        let redacted = redact_secrets(text);
323        assert!(redacted.contains("[REDACTED]"));
324        assert!(!redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"));
325    }
326
327    #[test]
328    fn test_redact_refresh_token() {
329        let text = "refresh_token=abcdefghijklmnopqrstuvwxyz";
330        let redacted = redact_secrets(text);
331        assert!(redacted.contains("[REDACTED]"));
332        assert!(!redacted.contains("abcdefghijklmnopqrstuvwxyz"));
333    }
334
335    #[test]
336    fn test_redact_bearer_token() {
337        let text = "bearer: ABCDEFGHIJKLMNOPQRSTUVWXYZ123456";
338        let redacted = redact_secrets(text);
339        assert!(redacted.contains("[REDACTED]"));
340        assert!(!redacted.contains("ABCDEFGHIJKLMNOPQRSTUVWXYZ123456"));
341    }
342
343    #[test]
344    fn test_redact_case_insensitive() {
345        let text1 = "CLIENT_SECRET: secret1";
346        let text2 = "Client_Secret: secret2";
347        let text3 = "client_SECRET: secret3";
348
349        assert!(redact_secrets(text1).contains("[REDACTED]"));
350        assert!(redact_secrets(text2).contains("[REDACTED]"));
351        assert!(redact_secrets(text3).contains("[REDACTED]"));
352    }
353
354    #[test]
355    fn test_redact_preserves_non_secret_text() {
356        let text = "This is a normal message without secrets";
357        let redacted = redact_secrets(text);
358        assert_eq!(text, redacted);
359    }
360
361    #[test]
362    fn test_redact_multiple_secrets() {
363        let text = "client_secret: secret1 api_key: key123";
364        let redacted = redact_secrets(text);
365        assert!(!redacted.contains("secret1"));
366        assert!(!redacted.contains("key123"));
367        assert!(redacted.matches("[REDACTED]").count() >= 2);
368    }
369
370    #[test]
371    fn test_redact_mixed_content() {
372        let text = "Logging in with client_secret: mysecret for user john";
373        let redacted = redact_secrets(text);
374        assert!(redacted.contains("Logging in"));
375        assert!(redacted.contains("for user john"));
376        assert!(!redacted.contains("mysecret"));
377    }
378
379    #[test]
380    fn test_redact_short_token_not_redacted() {
381        // Tokens shorter than 20 chars should not be redacted (not a real token)
382        let text = "token: short";
383        let redacted = redact_secrets(text);
384        assert!(redacted.contains("short"));
385    }
386
387    #[test]
388    fn test_redact_empty_string() {
389        let text = "";
390        let redacted = redact_secrets(text);
391        assert_eq!(redacted, "");
392    }
393
394    #[test]
395    fn test_redact_json_access_token() {
396        let text = r#""access_token":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.abc123""#;
397        let redacted = redact_secrets(text);
398        assert!(!redacted.contains("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"));
399    }
400
401    // ==================== New Redaction Pattern Tests ====================
402
403    #[test]
404    fn test_redact_bearer_header() {
405        let text = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.sig";
406        let redacted = redact_secrets(text);
407        assert!(redacted.contains("[REDACTED]"));
408        assert!(!redacted.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"));
409    }
410
411    #[test]
412    fn test_redact_basic_auth_header() {
413        let text = "Authorization: Basic dXNlcjpwYXNzd29yZA==";
414        let redacted = redact_secrets(text);
415        assert!(redacted.contains("[REDACTED]"));
416        assert!(!redacted.contains("dXNlcjpwYXNzd29yZA=="));
417    }
418
419    #[test]
420    fn test_redact_cookie_header() {
421        let text = "Cookie: session_id=abc123; auth_token=xyz789";
422        let redacted = redact_secrets(text);
423        assert!(redacted.contains("[REDACTED]"));
424        assert!(!redacted.contains("abc123"));
425    }
426
427    #[test]
428    fn test_redact_set_cookie_header() {
429        let text = "Set-Cookie: session=secret_value; Path=/; HttpOnly";
430        let redacted = redact_secrets(text);
431        assert!(redacted.contains("[REDACTED]"));
432        assert!(!redacted.contains("secret_value"));
433    }
434
435    #[test]
436    fn test_redact_x_api_key_header() {
437        let text = "X-API-Key: sk-1234567890abcdef";
438        let redacted = redact_secrets(text);
439        assert!(redacted.contains("[REDACTED]"));
440        assert!(!redacted.contains("sk-1234567890abcdef"));
441    }
442
443    #[test]
444    fn test_redact_url_access_token_param() {
445        let text = "https://api.example.com/data?access_token=secret123&format=json";
446        let redacted = redact_secrets(text);
447        assert!(redacted.contains("[REDACTED]"));
448        assert!(!redacted.contains("secret123"));
449        assert!(redacted.contains("format=json"));
450    }
451
452    #[test]
453    fn test_redact_url_apikey_param() {
454        let text = "https://api.example.com/data?apikey=mykey123&limit=10";
455        let redacted = redact_secrets(text);
456        assert!(redacted.contains("[REDACTED]"));
457        assert!(!redacted.contains("mykey123"));
458    }
459
460    #[test]
461    fn test_redact_non_sensitive_unchanged() {
462        let text = "GET /api/v1/projects HTTP/1.1\nHost: example.com\nAccept: application/json";
463        let redacted = redact_secrets(text);
464        assert_eq!(text, redacted);
465    }
466
467    #[test]
468    fn test_redact_combined_patterns() {
469        let text =
470            "Authorization: Bearer eyJtoken123456789012345 Cookie: sess=val X-API-Key: key123";
471        let redacted = redact_secrets(text);
472        assert!(!redacted.contains("eyJtoken"));
473        assert!(!redacted.contains("sess=val"));
474        assert!(!redacted.contains("key123"));
475    }
476
477    #[test]
478    fn test_redacting_writer() {
479        let mut buf = Vec::new();
480        {
481            let mut writer = super::RedactingWriter { inner: &mut buf };
482            write!(writer, "Authorization: Bearer secret_token_value_here").unwrap();
483        }
484        let output = String::from_utf8(buf).unwrap();
485        assert!(output.contains("[REDACTED]"));
486        assert!(!output.contains("secret_token_value_here"));
487    }
488}