//! 会话日志与审计(P2)。 //! //! 把会话的**原始输出字节流**(含 ANSI 序列)追加写入磁盘文件。 //! 「审计」的另一半——命令文本与退出码——已由命令历史(OSC 133 落库)承担, //! 本模块补的是命令历史覆盖不了的部分:完整屏幕内容、非命令输出、时序原貌。 //! //! # 设计取舍 //! //! - **记原始字节,不做任何转义清洗**:与 PuTTY 的会话日志同策略。 //! 清洗(剥 ANSI / 转可读文本)是**阅读时**的事,日志要保真—— //! 排查「界面显示为什么这样」恰恰需要原始序列。 //! - **只记输出不记输入**:PTY 会回显输入(本地与 SSH 皆然), //! 输出流已包含用户敲了什么。更重要的是,关闭回显的密码输入 //! **不会**出现在输出流里——这保证了日志不会意外存下密码。 //! - **每批同步追加 + 即时 flush**:日志的价值在崩溃/断线后仍然完整, //! 攒缓冲反而丢最关键的最后几行。单批最大 4 MiB(聚合缓冲上限), //! 同步写无性能问题。 //! - **不持久化开关状态**:会话结束日志自然终止,下次会话默认关闭。 //! 自动记录所有会话涉及磁盘占用与隐私权衡(日志含屏幕上的一切), //! 交给用户显式开启更稳妥。 //! //! # 挂点 //! //! 本地 ConPTY 与 SSH 两个后端的 `flush_output` 是所有输出的必经之路 //! (聚合 → 发前端事件),在此处写日志能保证**零遗漏**且与前端所见一致。 use std::collections::HashMap; use std::io::Write; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; /// 注册表:会话 id → 日志条目。 static LOGS: OnceLock>> = OnceLock::new(); struct LogEntry { path: PathBuf, file: std::fs::File, /// 已写入字节数(预留:后续可做单文件上限保护) #[allow(dead_code)] written: u64, } fn registry() -> &'static Mutex> { LOGS.get_or_init(|| Mutex::new(HashMap::new())) } /// 开启或关闭某会话的日志。 /// /// 开启:在 `dir` 下创建 `{session_id}_{时间戳}.log`,写入头部说明, /// 返回 `Some(路径)`。已在记录中则幂等(返回现有路径,不重复建文件)。 /// 关闭:flush 并移除条目,返回 `None`。未在记录中时关闭是空操作。 pub fn toggle( session_id: &str, enabled: bool, dir: &PathBuf, header: &str, ) -> Result, String> { let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner()); if !enabled { // flush 在 Drop(entry 被 remove)时完成;显式 flush 一次更稳 if let Some(e) = reg.remove(session_id) { let mut e = e; let _ = e.file.flush(); } return Ok(None); } if let Some(e) = reg.get(session_id) { return Ok(Some(e.path.to_string_lossy().to_string())); } std::fs::create_dir_all(dir).map_err(|e| format!("创建日志目录失败: {e}"))?; let stamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); let path = dir.join(format!("{session_id}_{stamp}.log")); let mut file = std::fs::OpenOptions::new() .create(true) .append(true) .open(&path) .map_err(|e| format!("创建日志文件失败({}): {e}", path.display()))?; // 头部人可读:定位「这是谁的日志」不需要任何工具 writeln!( file, "# 终端会话日志 | {header} | 开始于 {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S") ) .map_err(|e| format!("写入日志头部失败: {e}"))?; let path_str = path.to_string_lossy().to_string(); reg.insert( session_id.to_string(), LogEntry { path, file, written: 0, }, ); Ok(Some(path_str)) } /// 会话是否正在记录。 pub fn is_logging(session_id: &str) -> bool { registry() .lock() .unwrap_or_else(|e| e.into_inner()) .contains_key(session_id) } /// 当前日志文件路径(未记录时为 `None`)。 pub fn path_of(session_id: &str) -> Option { registry() .lock() .unwrap_or_else(|e| e.into_inner()) .get(session_id) .map(|e| e.path.to_string_lossy().to_string()) } /// 追加一批输出(热路径:未开启时只付一次哈希查表的钱)。 pub fn write(session_id: &str, bytes: &[u8]) { if bytes.is_empty() { return; } let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner()); let Some(entry) = reg.get_mut(session_id) else { return; }; if entry.file.write_all(bytes).is_ok() { entry.written += bytes.len() as u64; } // 单条写入失败不中断会话:日志是尽力而为的旁路,不能反过来影响终端 I/O let _ = entry.file.flush(); } /// 会话关闭时的清理:flush 并移除条目。幂等。 pub fn cleanup(session_id: &str) { if let Some(mut e) = registry() .lock() .unwrap_or_else(|x| x.into_inner()) .remove(session_id) { let _ = e.file.flush(); } } #[cfg(test)] mod tests { use super::*; fn temp_dir(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!( "thing-audit-test-{tag}-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&d); d } /// 每条测试用独立会话 id,避免注册表(全局静态)在测试间串扰 fn sid(tag: &str) -> String { format!("{tag}-{}", std::process::id()) } #[test] fn toggle_write_and_stop_roundtrip() { let id = sid("roundtrip"); let dir = temp_dir("roundtrip"); // 开启:返回路径,头部已写入 let path = toggle(&id, true, &dir, "测试会话 ops@example").unwrap().unwrap(); assert!(is_logging(&id)); assert_eq!(path_of(&id).unwrap(), path); assert!(path.contains(&id)); // 写入:文件里能找到原始字节与头部 write(&id, b"hello \x1b[31mred\x1b[0m\n"); let content = std::fs::read(&path).unwrap(); let text = String::from_utf8_lossy(&content); assert!(text.contains("# 终端会话日志"), "头部缺失"); assert!(text.contains("ops@example"), "头部信息缺失"); assert!(content.windows(5).any(|w| w == b"\x1b[31m"), "原始 ANSI 序列应原样保留"); assert!(text.contains("hello ")); // 关闭:条目移除,文件保留在磁盘上 assert!(toggle(&id, false, &dir, "").unwrap().is_none()); assert!(!is_logging(&id)); assert!(path_of(&id).is_none()); assert!(std::path::Path::new(&path).exists(), "关闭后日志文件应保留"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn write_without_log_is_noop() { let id = sid("noop"); // 未开启时写入不应 panic、不应创建任何文件 write(&id, b"ignored"); assert!(!is_logging(&id)); } #[test] fn toggle_on_twice_is_idempotent() { let id = sid("idempotent"); let dir = temp_dir("idempotent"); let p1 = toggle(&id, true, &dir, "a").unwrap().unwrap(); let p2 = toggle(&id, true, &dir, "b").unwrap().unwrap(); assert_eq!(p1, p2, "重复开启应返回同一路径而不是新建文件"); cleanup(&id); let _ = std::fs::remove_dir_all(&dir); } #[test] fn cleanup_and_off_when_not_logging_are_safe() { let id = sid("safe"); cleanup(&id); // 未开启时清理是空操作 assert!(toggle(&id, false, &temp_dir("safe"), "").unwrap().is_none()); } #[test] fn disabled_log_file_keeps_bytes_after_cleanup() { let id = sid("persist"); let dir = temp_dir("persist"); let path = toggle(&id, true, &dir, "h").unwrap().unwrap(); write(&id, b"line1\n"); write(&id, b"line2\n"); cleanup(&id); // 等价于会话关闭路径 let text = std::fs::read_to_string(&path).unwrap(); assert!(text.contains("line1") && text.contains("line2"), "cleanup 后内容应完整落盘"); let _ = std::fs::remove_dir_all(&dir); } }