终端模块初版
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
//! 会话日志与审计(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 在 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<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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user