Files
Thing/src-tauri/src/terminal/audit.rs
T
2026-09-18 18:28:13 +08:00

230 lines
8.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 会话日志与审计(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<Mutex<HashMap<String, LogEntry>>> = OnceLock::new();
struct LogEntry {
path: PathBuf,
file: std::fs::File,
/// 已写入字节数(预留:后续可做单文件上限保护)
#[allow(dead_code)]
written: u64,
}
fn registry() -> &'static Mutex<HashMap<String, LogEntry>> {
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<Option<String>, String> {
let mut reg = registry().lock().unwrap_or_else(|e| e.into_inner());
if !enabled {
// flush 在 Dropentry 被 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<String> {
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);
}
}