r2 复审挖到的隐私旁路:上一版在**字节**上切两刀,切在多字节字符中间时 String::from_utf8 失败 ⇒ 整份 entry 走「原样字节」分支,家目录 / 用户名 / 令牌原文 按字节进包,而清单什么也不说。命中的正是最容易超过 4 MiB 的 latest.log 与会话 JSONL; 中文 Windows 用户名(C:\Users\张三)让家目录串本身就是 CJK,切点落在附近必然命中。 修法(比复审给的三条更严一点:先脱敏再截断,避免切点留下半截 secret): 解码(lossy,任何字节都能进脱敏器)→ 脱敏整份 → 按**字符/行边界**截断 → 插截断标记。 新增 crash_bundle/clip.rs(切点逻辑 + 4 条用例),write_bundle 里只剩一条通路, 「二进制原样」这条分支彻底删除。 守卫:truncated_entries_are_still_redacted —— 造一个 '中' 正好跨在 HEAD_BYTES 上的 4 MiB+ 文件,内含注册过的会话 id 与家目录;断言 ①包里找不到原文 ②<home> 在 ③尾部 exit 事件仍在。变异(改回字节级截断 + 仅解码成功才脱敏)⇒ 红在「仍带着家目录原文」。 顺带: - P3-1 启动日志写两遍(WindowHandle(None) 分支重复调 record_boot_log)→ 删掉重复调用。 - P3-2 清单里的截断字节数改成**实际保留量**(原来报的是常量 HEAD_BYTES)。 - P3-4 truncated>0 时界面那句话补用例(拆出 dialog/tests/export.rs,两个文件都 < 400 行)。 - P3-6 BundleSpec::manifest() 自己也过脱敏,去掉「谁调谁泄漏」的口子。 - P3-5 卡片宽度取官方上限 560 的语义在代码里写清(不改行为,登记为台账)。
365 lines
14 KiB
Rust
365 lines
14 KiB
Rust
//! 诊断包(M187):把一次启动失败的现场打成一个 zip 交给用户。
|
||
//!
|
||
//! ## 为什么不只是「打开日志文件夹」
|
||
//!
|
||
//! 用户实测(2026-11-08):「MC 启动失败『游戏崩溃 退出码 1』,找半天没找到崩溃日志」。
|
||
//! 现场散在四处——启动器日志(`.ULL/logs/`)、会话事件流 JSONL(`data/agent/sessions/game/`)、
|
||
//! 游戏自己写的 `crash-reports/` 与 `logs/latest.log`、还有环境(系统 / Java / 命令行)。
|
||
//! 让用户自己去四个目录里拼,等于没修。打成一个包 + 一行清单,才是「能发给别人看」的东西。
|
||
//!
|
||
//! ## 三条纪律
|
||
//!
|
||
//! 1. **落盘即脱敏**(`launch::redact` 的唯一一份规则):主目录 → `<home>`,token → 占位符,
|
||
//! 长不透明串(base64/hex)与带点 token(JWT 形状)同样替换;调用方另外把**会话 id**
|
||
//! 之类也要抹掉的串塞进 [`BundleSpec::secrets`]。
|
||
//! 2. **缺什么要写出来**:`crash-reports/` 不存在不是错误——那是「游戏没来得及写」的**证据**,
|
||
//! 清单里必须有一行说清楚,不许静默漏掉。见 [`BundleSpec::missing`]。
|
||
//! 3. **有上限**:单个文件最多收 [`MAX_ENTRY_BYTES`],包内总字节最多 [`MAX_TOTAL_BYTES`]。
|
||
//! 超了就在清单里记一行「已截断」。数百 MB 的 `latest.log` 不该把导出卡成十秒。
|
||
//!
|
||
//! 不做的事:不判断崩溃原因(那是诊断引擎 `diagnose` 的活),不改任何图片之外的二进制,
|
||
//! 不联网。打包只读盘。
|
||
|
||
use std::fs::File;
|
||
use std::io::Write;
|
||
use std::path::{Path, PathBuf};
|
||
|
||
use super::redact::Redactor;
|
||
|
||
/// 单个文件最多收多少字节(超出按**头 + 尾**截断,清单里记「已截断」)。
|
||
///
|
||
/// 为什么不是「只收头部」:崩溃证据在**尾部**——会话 JSONL 的最后几行是 `exit` 事件与
|
||
/// stderr,`latest.log` 的栈也在尾。只留头部的包等于「证据是残的,而用户不知道」。
|
||
pub const MAX_ENTRY_BYTES: u64 = 4 * 1024 * 1024;
|
||
/// 截断时头部保留的字节(另一半点给尾部)。
|
||
pub const HEAD_BYTES: u64 = MAX_ENTRY_BYTES / 2;
|
||
/// 整个包最多收多少字节(超出后剩余文件只记清单、不收内容)。
|
||
pub const MAX_TOTAL_BYTES: u64 = 48 * 1024 * 1024;
|
||
|
||
/// 一个要进包的文件。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct BundleEntry {
|
||
/// 包内分组(`launcher` / `session` / `game` / `instance`),决定包内目录。
|
||
pub group: &'static str,
|
||
/// 源文件绝对路径。
|
||
pub path: PathBuf,
|
||
/// 包内固定文件名(`None` = 用源文件名)。
|
||
///
|
||
/// 为什么要能钉死:会话 JSONL 的源文件名就是**会话 id**(`<uuid>.jsonl`),
|
||
/// 拿它当条目名要么泄漏 id、要么让「清单里的名字」与「包里的名字」对不上。钉成
|
||
/// `session.jsonl` 两个问题一起消失(见 [`BundleSpec::entry_names`])。
|
||
pub name: Option<String>,
|
||
}
|
||
|
||
impl BundleEntry {
|
||
/// 建一条(包内名 = 源文件名)。
|
||
pub fn new(group: &'static str, path: impl Into<PathBuf>) -> Self {
|
||
Self {
|
||
group,
|
||
path: path.into(),
|
||
name: None,
|
||
}
|
||
}
|
||
|
||
/// 建一条并**钉死包内文件名**(会话 JSONL、`latest.log` 这类有唯一身份的用它)。
|
||
pub fn named(group: &'static str, path: impl Into<PathBuf>, name: &str) -> Self {
|
||
Self {
|
||
group,
|
||
path: path.into(),
|
||
name: Some(name.to_string()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 一次导出的输入(全部是事实,不含任何结论)。
|
||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||
pub struct BundleSpec {
|
||
/// 实例名。
|
||
pub instance: String,
|
||
/// 版本 id。
|
||
pub version: String,
|
||
/// 会话 id(**同时会被脱敏**)。
|
||
pub session: String,
|
||
/// 退出码(`None` = 进程没起来)。
|
||
pub exit_code: Option<i32>,
|
||
/// 退出判定 slug(`application_error` / `jvm_error` / `normal` …)。
|
||
pub exit_type: String,
|
||
/// 存活毫秒数。
|
||
pub uptime_ms: u64,
|
||
/// 脱敏后的命令行(**调用方**已经过 [`Redactor::scrub_all`])。
|
||
pub command: String,
|
||
/// Java 一行说明(路径 + 主版本)。
|
||
pub java: String,
|
||
/// 系统一行说明(`OsInfo::summary_line`)。
|
||
pub system: String,
|
||
/// 额外说明(例如「本次没有生成 crash-report」)。
|
||
pub notes: Vec<String>,
|
||
/// 明确不存在的现场(清单里写成「没有:…」)。
|
||
pub missing: Vec<String>,
|
||
/// 要收的文件。
|
||
pub entries: Vec<BundleEntry>,
|
||
/// 除了主目录之外还要抹掉的串(会话 id 等)。
|
||
pub secrets: Vec<String>,
|
||
/// 用户主目录(`None` = 不抹)。
|
||
pub home: Option<PathBuf>,
|
||
}
|
||
|
||
impl BundleSpec {
|
||
/// 清单文本(`manifest.txt`;导出前会再过一遍脱敏)。
|
||
///
|
||
/// 这是「自算条目名」的包装(测试与单独看清单用);真正落盘时用的是
|
||
/// [`Self::header`] + 收集循环里长出来的行,保证与包内条目一字不差。
|
||
#[must_use]
|
||
pub fn manifest(&self) -> String {
|
||
let rows: Vec<String> = self
|
||
.entry_names()
|
||
.iter()
|
||
.zip(&self.entries)
|
||
.map(|(name, entry)| format!("- {name} <- {}", entry.path.display()))
|
||
.collect();
|
||
// 对外出口一律脱敏(r2 复审 P3-6):不给「谁调谁泄漏」的口子。
|
||
self.redactor().scrub(&self.header(&rows)).into_owned()
|
||
}
|
||
|
||
/// 清单的**事实段 + files 段**(files 段由调用方给,行内已是最终条目名)。
|
||
fn header(&self, rows: &[String]) -> String {
|
||
let mut text = String::new();
|
||
text.push_str("Unlucky Launcher — launch failure bundle\n");
|
||
text.push_str("=======================================\n");
|
||
text.push_str(&format!("instance: {}\n", self.instance));
|
||
text.push_str(&format!("version: {}\n", self.version));
|
||
text.push_str(&format!("session: {}\n", self.session));
|
||
text.push_str(&format!(
|
||
"exit_code: {}\n",
|
||
self.exit_code
|
||
.map(|code| code.to_string())
|
||
.unwrap_or_else(|| "(none)".to_string())
|
||
));
|
||
text.push_str(&format!("exit_type: {}\n", self.exit_type));
|
||
text.push_str(&format!("uptime: {:.1}s\n", self.uptime_ms as f64 / 1000.0));
|
||
text.push_str(&format!("os: {}\n", self.system));
|
||
text.push_str(&format!("java: {}\n", self.java));
|
||
text.push_str(&format!("command: {}\n", self.command));
|
||
|
||
if !self.notes.is_empty() {
|
||
text.push_str("\nnotes\n-----\n");
|
||
for note in &self.notes {
|
||
text.push_str(&format!("- {note}\n"));
|
||
}
|
||
}
|
||
if !self.missing.is_empty() {
|
||
text.push_str("\nmissing (不存在)\n----------------\n");
|
||
for item in &self.missing {
|
||
text.push_str(&format!("- {item}\n"));
|
||
}
|
||
}
|
||
|
||
text.push_str("\nfiles\n-----\n");
|
||
// 清单里的名字**必须**与包里的条目一一对应(r1 复审 P2-2 就是这里对不上):
|
||
// 两边都走同一份解析(`rows` 由调用方给出,行内已是最终条目名):
|
||
// 清单与 zip 不许各算一遍 —— 各算一遍就是 r1 复审 P2-2。
|
||
for row in rows {
|
||
text.push_str(row);
|
||
text.push('\n');
|
||
}
|
||
text
|
||
}
|
||
|
||
/// 包内条目名(与 `entries` 一一对应;**名字也过脱敏**)。
|
||
///
|
||
/// 名字与内容分开治:内容走 `redactor` 的整套规则,名字走同一份 redactor 的 token 规则。
|
||
/// **两边都以返回值为准**——清单与 zip 都读这一份,不许各算一遍(各算一遍就是 P2-2)。
|
||
#[must_use]
|
||
pub fn entry_names(&self) -> Vec<String> {
|
||
let redactor = self.redactor();
|
||
self.entries
|
||
.iter()
|
||
.map(|entry| {
|
||
let raw = entry.name.clone().unwrap_or_else(|| file_name(&entry.path));
|
||
let safe = redactor.scrub(&raw).into_owned();
|
||
format!("{}/{}", entry.group, safe)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn redactor(&self) -> Redactor {
|
||
let mut redactor = Redactor::new();
|
||
if let Some(home) = &self.home {
|
||
redactor = redactor.with_home(home);
|
||
}
|
||
for secret in &self.secrets {
|
||
redactor = redactor.with_secret(secret.clone());
|
||
}
|
||
redactor
|
||
}
|
||
}
|
||
|
||
/// 一次导出的结果。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub struct Bundle {
|
||
/// 落盘路径。
|
||
pub path: PathBuf,
|
||
/// 字节数(**落盘后**的真实大小)。
|
||
pub bytes: u64,
|
||
/// 收进包的文件数(不含清单)。
|
||
pub files: usize,
|
||
/// 被截断 / 因总量上限没收内容的文件数。
|
||
pub truncated: usize,
|
||
}
|
||
|
||
impl Bundle {
|
||
/// 一行说明(Toast 与日志共用)。
|
||
pub fn describe(&self) -> String {
|
||
format!(
|
||
"{} ({} bytes, {} files, {} truncated)",
|
||
self.path.display(),
|
||
self.bytes,
|
||
self.files,
|
||
self.truncated
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 把 `spec` 打成一个 zip 写到 `target`(父目录不存在会建)。
|
||
///
|
||
/// # Errors
|
||
/// 目录建不出来、zip 写失败、清单写失败时返回人话(导出失败要能一眼看出哪一步断)。
|
||
pub fn write_bundle(spec: &BundleSpec, target: &Path) -> Result<Bundle, String> {
|
||
let redactor = spec.redactor();
|
||
|
||
if let Some(parent) = target.parent() {
|
||
if !parent.as_os_str().is_empty() {
|
||
std::fs::create_dir_all(parent)
|
||
.map_err(|error| format!("建目录 {} 失败:{error}", parent.display()))?;
|
||
}
|
||
}
|
||
let file = File::create(target)
|
||
.map_err(|error| format!("建诊断包 {} 失败:{error}", target.display()))?;
|
||
let mut zip = zip::ZipWriter::new(file);
|
||
let options =
|
||
zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||
|
||
// 名字与清单**先在同一个循环里长出来**:`files` 段只列真进了包的条目,
|
||
// 谁被跳过、谁被截断都在 `collected` 段写明(r1 复审 P2-2 的修法)。
|
||
let names = spec.entry_names();
|
||
let mut listed: Vec<String> = Vec::new();
|
||
let mut collected: Vec<String> = Vec::new();
|
||
let mut total: u64 = 0;
|
||
let mut files = 0usize;
|
||
let mut truncated = 0usize;
|
||
let mut used: Vec<String> = Vec::new();
|
||
|
||
for (index, entry) in spec.entries.iter().enumerate() {
|
||
let name = unique_name(&mut used, &names[index]);
|
||
let Ok(bytes) = std::fs::read(&entry.path) else {
|
||
collected.push(format!("- {name}: 读不出来(已跳过)"));
|
||
continue;
|
||
};
|
||
let original_len = bytes.len();
|
||
// **唯一的通路**:解码 → 脱敏 → 按字符边界截断(见 `clip.rs` 的模块文档)。
|
||
// 没有「二进制原样」分支,所以也不存在「截坏一个字就整份跳过脱敏」的旁路
|
||
// (r2 复审 P2-1)。`lossy` 保证任何字节序列都能进脱敏器。
|
||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||
let scrubbed = redactor.scrub(&text).into_owned();
|
||
let budget = MAX_TOTAL_BYTES.saturating_sub(total).min(MAX_ENTRY_BYTES);
|
||
let mut kept = scrubbed;
|
||
if kept.len() as u64 > budget {
|
||
let Some(clipped) = clip_text(&kept, budget) else {
|
||
collected.push(format!("- {name}: 超出总量上限,未收内容"));
|
||
truncated += 1;
|
||
continue;
|
||
};
|
||
kept = clipped;
|
||
collected.push(format!(
|
||
"- {name}: 已截断(收 {} B,原 {original_len} B)",
|
||
kept.len()
|
||
));
|
||
truncated += 1;
|
||
}
|
||
let body = kept.into_bytes();
|
||
total += body.len() as u64;
|
||
files += 1;
|
||
listed.push(format!("- {name} <- {}", entry.path.display()));
|
||
write_entry(&mut zip, &name, &body, options)?;
|
||
}
|
||
|
||
let mut manifest = spec.header(&listed);
|
||
if !collected.is_empty() {
|
||
manifest.push_str("\ncollected\n---------\n");
|
||
for line in collected {
|
||
manifest.push_str(&line);
|
||
manifest.push('\n');
|
||
}
|
||
}
|
||
write_entry(
|
||
&mut zip,
|
||
"manifest.txt",
|
||
redactor.scrub(&manifest).as_bytes(),
|
||
options,
|
||
)?;
|
||
zip.finish()
|
||
.map_err(|error| format!("收尾诊断包失败:{error}"))?;
|
||
|
||
let bytes = std::fs::metadata(target)
|
||
.map(|meta| meta.len())
|
||
.unwrap_or(0);
|
||
Ok(Bundle {
|
||
path: target.to_path_buf(),
|
||
bytes,
|
||
files,
|
||
truncated,
|
||
})
|
||
}
|
||
|
||
fn write_entry(
|
||
zip: &mut zip::ZipWriter<File>,
|
||
name: &str,
|
||
body: &[u8],
|
||
options: zip::write::SimpleFileOptions,
|
||
) -> Result<(), String> {
|
||
zip.start_file(name, options)
|
||
.map_err(|error| format!("写入 {name} 失败:{error}"))?;
|
||
zip.write_all(body)
|
||
.map_err(|error| format!("写入 {name} 失败:{error}"))
|
||
}
|
||
|
||
fn file_name(path: &Path) -> String {
|
||
path.file_name()
|
||
.map(|name| name.to_string_lossy().into_owned())
|
||
.unwrap_or_else(|| "file".to_string())
|
||
}
|
||
|
||
/// 包内重名时在**目录名后**加序号(`game/latest.log` 与 `game/2-latest.log` 不会互相覆盖)。
|
||
fn unique_name(used: &mut Vec<String>, name: &str) -> String {
|
||
let base = name.to_string();
|
||
if !used.contains(&base) {
|
||
used.push(base.clone());
|
||
return base;
|
||
}
|
||
let (group, file) = base.split_once('/').unwrap_or(("", base.as_str()));
|
||
for index in 2.. {
|
||
let candidate = format!("{group}/{index}-{file}");
|
||
if !used.contains(&candidate) {
|
||
used.push(candidate.clone());
|
||
return candidate;
|
||
}
|
||
}
|
||
unreachable!("序号一定找得到空位")
|
||
}
|
||
|
||
/// 默认落点:启动器日志分区下的 `exports/`。
|
||
///
|
||
/// 为什么不弹文件选择框:本仓没有 `rfd` 这类原生对话框依赖(Cargo.toml 不在本 mission scope),
|
||
/// 也不该为一次导出引一棵新依赖树。导出后界面把**完整路径**与字节数都给出来,另有
|
||
/// 「打开文件夹」按钮直达——用户不用猜。
|
||
pub fn default_target_dir(logs_dir: &Path) -> PathBuf {
|
||
logs_dir.join("exports")
|
||
}
|
||
|
||
mod clip;
|
||
|
||
use clip::clip_text;
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|