1use std::fs;
19use std::path::{Path, PathBuf};
20use std::sync::LazyLock;
21use std::sync::mpsc;
22use std::time::{SystemTime, UNIX_EPOCH};
23use tracing::Level;
24use tracing_appender::non_blocking::WorkerGuard;
25use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
26
27const AUDIT_LOG_MAX_SIZE_BYTES: u64 = 10 * 1024 * 1024; const AUDIT_LOG_RETENTION_DAYS: u64 = 30; const AUDIT_LOG_CHANNEL_SIZE: usize = 1024; const SECS_PER_DAY: u64 = 86400;
31
32struct AuditLogEntry {
33 timestamp: u64,
34 action: String,
35 snippet_id: String,
36 description: String,
37 library_id: String,
38 device_id: String,
39}
40
41static AUDIT_TX: LazyLock<std::sync::Mutex<Option<mpsc::SyncSender<AuditLogEntry>>>> =
42 LazyLock::new(|| std::sync::Mutex::new(None));
43
44static LOG_GUARD: LazyLock<std::sync::Mutex<Option<WorkerGuard>>> =
47 LazyLock::new(|| std::sync::Mutex::new(None));
48
49#[allow(dead_code)]
51pub struct LogConfig {
52 pub log_dir: PathBuf,
53 pub file_name: String,
54 pub level: Level,
55 pub include_target: bool,
56 pub audit_max_size_bytes: u64,
57 pub audit_retention_days: u64,
58}
59
60impl Default for LogConfig {
61 fn default() -> Self {
62 LogConfig {
63 log_dir: get_default_log_dir(),
64 file_name: "snp.log".to_string(),
65 level: Level::INFO,
66 include_target: true,
67 audit_max_size_bytes: AUDIT_LOG_MAX_SIZE_BYTES,
68 audit_retention_days: AUDIT_LOG_RETENTION_DAYS,
69 }
70 }
71}
72
73pub fn get_default_log_dir() -> PathBuf {
74 crate::utils::config::get_config_dir().join("logs")
75}
76
77fn level_str(level: Level) -> &'static str {
78 match level {
79 Level::ERROR => "error",
80 Level::WARN => "warn",
81 Level::INFO => "info",
82 Level::DEBUG => "debug",
83 Level::TRACE => "trace",
84 }
85}
86
87pub fn init_logging(config: &LogConfig) -> Result<(), Box<dyn std::error::Error>> {
88 let log_dir = &config.log_dir;
89
90 fs::create_dir_all(log_dir)?;
91
92 #[cfg(unix)]
93 {
94 use std::os::unix::fs::PermissionsExt;
95 let perms = fs::Permissions::from_mode(0o700);
96 fs::set_permissions(log_dir, perms)?;
97 }
98
99 let file_appender = tracing_appender::rolling::daily(log_dir, &config.file_name);
100
101 let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
102
103 let env_filter = if let Ok(filter_str) = std::env::var("SNP_LOG") {
104 EnvFilter::try_new(&filter_str).unwrap_or_else(|e| {
105 eprintln!("Warning: Invalid SNP_LOG filter '{filter_str}': {e}");
106 EnvFilter::new(format!("snp={}", level_str(config.level)))
107 })
108 } else {
109 EnvFilter::try_from_default_env()
110 .unwrap_or_else(|_| EnvFilter::new(format!("snp={}", level_str(config.level))))
111 };
112
113 let file_layer = fmt::layer()
114 .with_writer(non_blocking)
115 .with_ansi(false)
116 .with_target(config.include_target)
117 .with_thread_ids(true)
118 .with_file(true)
119 .with_line_number(true);
120
121 let subscriber = tracing_subscriber::registry()
122 .with(env_filter)
123 .with(file_layer);
124
125 let _ = subscriber.try_init();
126
127 *LOG_GUARD.lock().unwrap_or_else(|e| e.into_inner()) = Some(guard);
128
129 init_async_audit_log();
130
131 tracing::info!("Logging initialized. Log directory: {}", log_dir.display());
132 tracing::info!("Log level: {:?}", config.level);
133
134 Ok(())
135}
136
137pub fn init_default_logging() {
138 if let Err(e) = crate::utils::config::ensure_config_dir() {
143 eprintln!("Warning: Failed to ensure config directory: {e}");
144 }
145 let config = LogConfig::default();
146 if let Err(e) = init_logging(&config) {
147 eprintln!("Warning: Failed to initialize logging: {e}");
148 }
149 self_check();
150}
151
152fn self_check() {
153 let log_dir = get_default_log_dir();
154 if !log_dir.exists()
155 && let Err(e) = fs::create_dir_all(&log_dir)
156 {
157 eprintln!(
158 "Warning: Failed to create log directory {}: {}",
159 log_dir.display(),
160 e
161 );
162 return;
163 }
164
165 match fs::OpenOptions::new()
166 .create(true)
167 .append(true)
168 .open(log_dir.join(".self_check"))
169 {
170 Ok(_) => {
171 let _ = fs::remove_file(log_dir.join(".self_check"));
172 }
173 Err(e) => {
174 eprintln!(
175 "Warning: Log directory {} is not writable: {}",
176 log_dir.display(),
177 e
178 );
179 }
180 }
181
182 let config_dir = crate::utils::config::get_config_dir();
183 if !config_dir.exists()
184 && let Err(e) = fs::create_dir_all(&config_dir)
185 {
186 eprintln!(
187 "Warning: Failed to create config directory {}: {}",
188 config_dir.display(),
189 e
190 );
191 }
192}
193
194fn init_async_audit_log() {
195 let (tx, rx) = mpsc::sync_channel(AUDIT_LOG_CHANNEL_SIZE);
196
197 std::thread::spawn(move || {
198 let mut writer = AuditLogWriter { rx };
199 writer.run();
200 });
201
202 *AUDIT_TX.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx);
203}
204
205struct AuditLogWriter {
206 rx: mpsc::Receiver<AuditLogEntry>,
207}
208
209impl AuditLogWriter {
210 fn run(&mut self) {
211 while let Ok(entry) = self.rx.recv() {
212 if let Err(e) = self.write_entry(&entry) {
213 tracing::error!(error = %e, "Failed to write audit log entry");
214 }
215 }
216 }
217
218 fn write_entry(&self, entry: &AuditLogEntry) -> std::io::Result<()> {
219 let log_path = match get_audit_log_path() {
220 Ok(p) => p,
221 Err(e) => {
222 tracing::warn!(error = %e, "Failed to get audit log path");
223 return Err(e);
224 }
225 };
226
227 let _ = rotate_audit_log_if_needed(
228 &log_path,
229 AUDIT_LOG_MAX_SIZE_BYTES,
230 AUDIT_LOG_RETENTION_DAYS,
231 );
232
233 let log_entry = format!(
234 "{}|{}|{}|{}|{}|{}\n",
235 entry.timestamp,
236 entry.action,
237 escape_pipe(&entry.snippet_id),
238 escape_pipe(&entry.description),
239 entry.library_id,
240 escape_pipe(&entry.device_id),
241 );
242
243 use std::io::Write;
244 let mut file = fs::OpenOptions::new()
245 .create(true)
246 .append(true)
247 .open(&log_path)?;
248
249 #[cfg(unix)]
250 {
251 use std::os::unix::fs::PermissionsExt;
252 let perms = std::fs::Permissions::from_mode(0o600);
253 if let Err(e) = fs::set_permissions(&log_path, perms) {
254 tracing::warn!(
255 path = %log_path.display(),
256 error = %e,
257 "Failed to set restrictive permissions on log file"
258 );
259 }
260 }
261
262 file.write_all(log_entry.as_bytes())
263 }
264}
265
266pub fn shutdown_logging() {
267 if let Some(tx) = AUDIT_TX.lock().unwrap_or_else(|e| e.into_inner()).take() {
268 drop(tx);
269 }
270 if let Some(guard) = LOG_GUARD.lock().unwrap_or_else(|e| e.into_inner()).take() {
271 tracing::info!("Logging shutdown complete");
272 std::thread::sleep(std::time::Duration::from_millis(100));
273 drop(guard);
274 }
275}
276
277fn extract_panic_info(panic_info: &std::panic::PanicHookInfo) -> (String, String) {
278 let location = panic_info
279 .location()
280 .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
281 .unwrap_or_else(|| "unknown".to_string());
282
283 let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
284 s.to_string()
285 } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
286 s.clone()
287 } else {
288 "Unknown panic".to_string()
289 };
290
291 (location, message)
292}
293
294pub fn log_panic_info(panic_info: &std::panic::PanicHookInfo) {
295 let (location, message) = extract_panic_info(panic_info);
296
297 tracing::error!(
298 target: "panic",
299 location = %location,
300 message = %message,
301 "Application panicked"
302 );
303}
304
305pub fn setup_panic_handler() {
306 let previous_hook = std::panic::take_hook();
307 std::panic::set_hook(Box::new(move |panic_info| {
308 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
309 let _ = crossterm::execute!(std::io::stdout(), crossterm::event::DisableMouseCapture);
313 ratatui::restore();
314 }));
315
316 log_panic_info(panic_info);
317
318 shutdown_logging();
321
322 let (location, message) = extract_panic_info(panic_info);
323
324 eprintln!("PANIC at {location}: {message}");
325
326 previous_hook(panic_info);
327 }));
328}
329
330#[tracing::instrument(level = "info", skip(result), fields(command = %redact_command(command)))]
331pub fn log_command_execution(
332 command: &str,
333 args: &[String],
334 result: &std::result::Result<(), String>,
335 working_dir: Option<&std::path::Path>,
336) {
337 match result {
338 Ok(()) => {
339 tracing::info!(
340 args_count = args.len(),
341 working_dir = ?working_dir,
342 "Command executed successfully"
343 );
344 }
345 Err(e) => {
346 tracing::error!(
347 args_count = args.len(),
348 error = %e,
349 working_dir = ?working_dir,
350 "Command execution failed"
351 );
352 }
353 }
354}
355
356fn redact_command(command: &str) -> String {
357 if command.chars().count() > 80 {
358 let truncated: String = command.chars().take(77).collect();
359 format!("{truncated}...")
360 } else {
361 command.to_string()
362 }
363}
364
365pub fn log_config_operation(operation: &str, path: &Path, result: &Result<(), &str>) {
366 match result {
367 Ok(()) => {
368 tracing::debug!(
369 operation = %operation,
370 path = %path.display(),
371 "Config operation completed"
372 );
373 }
374 Err(e) => {
375 tracing::warn!(
376 operation = %operation,
377 path = %path.display(),
378 error = %e,
379 "Config operation failed"
380 );
381 }
382 }
383}
384
385pub fn log_clipboard_operation(operation: &str, success: bool) {
386 if success {
387 tracing::debug!(operation = %operation, "Clipboard operation successful");
388 } else {
389 tracing::warn!(operation = %operation, "Clipboard operation failed");
390 }
391}
392
393pub fn log_startup_info() {
394 tracing::debug!("=== SNP Application Starting ===");
395 tracing::debug!("Version: {}", env!("CARGO_PKG_VERSION"));
396 tracing::debug!("Platform: {}", std::env::consts::OS);
397 tracing::debug!("Architecture: {}", std::env::consts::ARCH);
398 tracing::debug!(
399 "Config directory: {}",
400 crate::utils::config::get_config_dir().display()
401 );
402 tracing::debug!("Log directory: {}", get_default_log_dir().display());
403}
404
405pub fn log_shutdown_info() {
406 tracing::info!("=== SNP Application Shutting Down ===");
407 shutdown_logging();
408}
409
410pub fn get_audit_log_path() -> std::io::Result<std::path::PathBuf> {
411 let cfg_dir = crate::utils::config::get_config_dir();
412 std::fs::create_dir_all(&cfg_dir)?;
413 Ok(cfg_dir.join("audit.log"))
414}
415
416#[tracing::instrument(level = "info", skip(snippet), fields(action = %action, snippet_id = %snippet.id))]
417pub fn audit_log(
418 action: &str,
419 snippet: &crate::library::Snippet,
420 library_id: Option<&str>,
421) -> std::io::Result<()> {
422 let timestamp = SystemTime::now()
423 .duration_since(UNIX_EPOCH)
424 .unwrap_or_default()
425 .as_secs();
426
427 let entry = AuditLogEntry {
428 timestamp,
429 action: action.to_string(),
430 snippet_id: snippet.id.clone(),
431 description: snippet.description.clone(),
432 library_id: library_id.unwrap_or("").to_string(),
433 device_id: snippet.device_id.clone(),
434 };
435
436 let tx = AUDIT_TX
437 .lock()
438 .unwrap_or_else(|e| e.into_inner())
439 .as_ref()
440 .cloned();
441
442 match tx {
443 Some(tx) => tx.try_send(entry).map_err(|e| {
444 tracing::warn!("Audit log channel full, dropping entry: {}", e);
445 std::io::Error::new(std::io::ErrorKind::WouldBlock, e.to_string())
446 }),
447 None => {
448 tracing::warn!("Audit log channel not initialized, writing synchronously");
449 write_audit_log_entry_sync(&entry)
450 }
451 }
452}
453
454fn write_audit_log_entry_sync(entry: &AuditLogEntry) -> std::io::Result<()> {
455 let log_path = match get_audit_log_path() {
456 Ok(p) => p,
457 Err(e) => {
458 tracing::warn!(error = %e, "Failed to get audit log path");
459 return Err(e);
460 }
461 };
462
463 let _ = rotate_audit_log_if_needed(
464 &log_path,
465 AUDIT_LOG_MAX_SIZE_BYTES,
466 AUDIT_LOG_RETENTION_DAYS,
467 );
468
469 let log_entry = format!(
470 "{}|{}|{}|{}|{}|{}\n",
471 entry.timestamp,
472 entry.action,
473 escape_pipe(&entry.snippet_id),
474 escape_pipe(&entry.description),
475 entry.library_id,
476 escape_pipe(&entry.device_id),
477 );
478
479 use std::io::Write;
480 let mut file = match fs::OpenOptions::new()
481 .create(true)
482 .append(true)
483 .open(&log_path)
484 {
485 Ok(f) => f,
486 Err(e) => {
487 tracing::error!(error = %e, path = %log_path.display(), "Failed to open audit log for writing");
488 return Err(e);
489 }
490 };
491
492 if let Err(e) = file.write_all(log_entry.as_bytes()) {
493 tracing::error!(error = %e, path = %log_path.display(), "Failed to write to audit log");
494 return Err(e);
495 }
496 Ok(())
497}
498
499fn escape_pipe(s: &str) -> String {
500 let mut result = String::with_capacity(s.len());
501 for c in s.chars() {
502 match c {
503 '\\' => result.push_str("\\\\"),
504 '|' => result.push_str("\\|"),
505 '\n' => result.push_str("\\n"),
506 '\r' => result.push_str("\\r"),
507 c if c.is_control() => {
508 result.push_str(&format!("\\x{:02x}", c as u32));
509 }
510 _ => result.push(c),
511 }
512 }
513 result
514}
515
516fn rotate_audit_log_if_needed(
517 log_path: &Path,
518 max_size_bytes: u64,
519 retention_days: u64,
520) -> std::io::Result<()> {
521 let metadata = fs::symlink_metadata(log_path)?;
522 let size = metadata.len();
523
524 if size > max_size_bytes {
525 let timestamp = SystemTime::now()
526 .duration_since(UNIX_EPOCH)
527 .unwrap_or_default()
528 .as_secs();
529 let rotated_path = log_path.with_extension(format!("{timestamp}.rotated"));
530 fs::rename(log_path, rotated_path)?;
531 }
532
533 let log_dir = log_path.parent().unwrap_or(log_path);
534 if let Ok(entries) = fs::read_dir(log_dir) {
535 let retention_secs = retention_days * SECS_PER_DAY;
536
537 for entry in entries.flatten() {
538 let path = entry.path();
539 if path.extension().and_then(|s| s.to_str()) == Some("rotated")
540 && let Ok(metadata) = entry.metadata()
541 && let Ok(modified) = metadata.modified()
542 {
543 let age = SystemTime::now()
544 .duration_since(modified)
545 .unwrap_or_default()
546 .as_secs();
547 if age > retention_secs {
548 let _ = fs::remove_file(path);
549 }
550 }
551 }
552 }
553
554 Ok(())
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 #[test]
562 fn test_escape_pipe_backslash() {
563 assert_eq!(escape_pipe(r"foo\bar"), r"foo\\bar");
564 }
565
566 #[test]
567 fn test_escape_pipe_pipe() {
568 assert_eq!(escape_pipe("foo|bar"), r"foo\|bar");
569 }
570
571 #[test]
572 fn test_escape_pipe_newline() {
573 assert_eq!(escape_pipe("foo\nbar"), r"foo\nbar");
574 }
575
576 #[test]
577 fn test_escape_pipe_carriage_return() {
578 assert_eq!(escape_pipe("foo\rbar"), r"foo\rbar");
579 }
580
581 #[test]
582 fn test_escape_pipe_combined() {
583 assert_eq!(escape_pipe("a\\b|c\nd"), r"a\\b\|c\nd");
584 }
585
586 #[test]
587 fn test_escape_pipe_empty() {
588 assert_eq!(escape_pipe(""), "");
589 }
590
591 #[test]
592 fn test_rotate_audit_log_creates_rotated_file() {
593 let dir = tempfile::TempDir::new().unwrap();
594 let log_path = dir.path().join("audit.log");
595 fs::write(&log_path, "x".repeat(100)).unwrap();
596 rotate_audit_log_if_needed(&log_path, 50, 30).unwrap();
597 assert!(!log_path.exists());
598 let entries: Vec<_> = fs::read_dir(dir.path())
599 .unwrap()
600 .filter_map(|e| e.ok())
601 .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("rotated"))
602 .collect();
603 assert_eq!(entries.len(), 1);
604 }
605
606 #[test]
607 fn test_rotate_audit_log_no_rotation_under_limit() {
608 let dir = tempfile::TempDir::new().unwrap();
609 let log_path = dir.path().join("audit.log");
610 fs::write(&log_path, "small").unwrap();
611 rotate_audit_log_if_needed(&log_path, 1024, 30).unwrap();
612 assert!(log_path.exists());
613 }
614}