//! JSON-lines application log (port of `logging/logger.js` + `logService.js`). //! Field names that look like secrets are redacted recursively; callers must still //! never pass secrets inside free-text messages. use crate::core::error::CoreResult; use once_cell::sync::Lazy; use parking_lot::Mutex; use regex::Regex; use serde::Serialize; use serde_json::{json, Map, Value}; use std::fs::{self, OpenOptions}; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; static SENSITIVE_KEYS: Lazy = Lazy::new(|| Regex::new(r"(?i)pass(word|phrase)?|private.?key|secret|token|credential").unwrap()); pub const DEFAULT_TAIL: u64 = 256 * 1024; pub const MAX_TAIL: u64 = 1024 * 1024; pub fn sanitize(value: &Value, depth: usize) -> Value { if depth > 5 { return Value::String("[depth-limit]".into()); } match value { Value::Array(items) => Value::Array(items.iter().take(50).map(|v| sanitize(v, depth + 1)).collect()), Value::Object(map) => { let mut out = Map::new(); for (key, entry) in map { if SENSITIVE_KEYS.is_match(key) { out.insert(key.clone(), Value::String("[redacted]".into())); } else { out.insert(key.clone(), sanitize(entry, depth + 1)); } } Value::Object(out) } other => other.clone(), } } pub struct Logger { path: PathBuf, lock: Mutex<()>, } impl Logger { pub fn new(root: &Path) -> Self { let dir = root.join("logs"); let _ = fs::create_dir_all(&dir); Self { path: dir.join("core.log"), lock: Mutex::new(()) } } pub fn path(&self) -> &Path { &self.path } pub fn write(&self, level: &str, message: &str, data: Value) { let record = json!({ "timestamp": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), "level": level, "message": message, "data": sanitize(&if data.is_null() { json!({}) } else { data }, 0), }); let line = format!("{record}\n"); let _guard = self.lock.lock(); let mut options = OpenOptions::new(); options.create(true).append(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } if let Ok(mut file) = options.open(&self.path) { let _ = file.write_all(line.as_bytes()); } if cfg!(debug_assertions) { eprintln!("[{}] {} {}", level.to_uppercase(), message, record["data"]); } } pub fn info(&self, message: &str, data: Value) { self.write("info", message, data) } pub fn warn(&self, message: &str, data: Value) { self.write("warn", message, data) } pub fn error(&self, message: &str, data: Value) { self.write("error", message, data) } } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct LogTail { pub text: String, pub truncated: bool, pub size: u64, pub read_at: String, } pub fn normalize_limit(value: Option<&Value>) -> u64 { match value.and_then(Value::as_f64) { Some(n) if n.fract() == 0.0 && n > 0.0 && n <= 9_007_199_254_740_991.0 => (n as u64).min(MAX_TAIL), _ => DEFAULT_TAIL, } } pub fn read_tail(path: &Path, max_bytes: u64) -> CoreResult { let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); let meta = match fs::metadata(path) { Ok(meta) => meta, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { return Ok(LogTail { text: String::new(), truncated: false, size: 0, read_at: now }) } Err(e) => return Err(e.into()), }; if !meta.is_file() { return Err(crate::core::error::CoreError::plain("Application log is not a file.")); } let size = meta.len(); let length = size.min(max_bytes); let offset = size - length; let mut file = fs::File::open(path)?; file.seek(SeekFrom::Start(offset))?; let mut buffer = Vec::with_capacity(length as usize); file.take(length).read_to_end(&mut buffer)?; let mut text = String::from_utf8_lossy(&buffer).to_string(); if offset > 0 { text = match text.find('\n') { Some(index) => text[index + 1..].to_string(), None => String::new(), }; } Ok(LogTail { text, truncated: offset > 0, size, read_at: now }) } #[cfg(test)] mod tests { use super::*; #[test] fn redacts_sensitive_fields() { let value = sanitize(&json!({"password": "x", "nested": {"privateKey": "k", "ok": 1}, "sudoPassword": "y"}), 0); assert_eq!(value["password"], "[redacted]"); assert_eq!(value["sudoPassword"], "[redacted]"); assert_eq!(value["nested"]["privateKey"], "[redacted]"); assert_eq!(value["nested"]["ok"], 1); } #[test] fn tail_drops_partial_first_line() { let dir = tempfile::tempdir().unwrap(); let logger = Logger::new(dir.path()); for i in 0..100 { logger.info(&format!("line {i}"), json!({})); } let tail = read_tail(logger.path(), 300).unwrap(); assert!(tail.truncated); assert!(tail.text.starts_with('{')); } }