fix(m187): r2 复审 p2——截断过的文件也必须脱敏(截断 改到文本流里做)
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 的语义在代码里写清(不改行为,登记为台账)。
This commit is contained in:
1 parent
3cd371b537
commit
2b6dcbed40
7 files changed
+340
-84
No files matched your search
@@ -233,41 +233,6 @@ fn a_successful_launch_does_not_pop_anything() {
|
||||
assert!(app.state.dialogs.current().is_none());
|
||||
}
|
||||
|
||||
/// 导出:真的落盘、真的报出路径与字节数(**没有 crash-report 也要能导出**)。
|
||||
#[test]
|
||||
fn export_writes_a_bundle_even_without_a_crash_report() {
|
||||
let root = std::env::temp_dir().join(format!("ull-m187-export-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("logs")).expect("建目录");
|
||||
std::fs::write(root.join("logs").join("launcher.log"), "[launch] boom\n").expect("写日志");
|
||||
|
||||
let mut spec_report = report(false, vec!["Error: boom".to_string()]);
|
||||
spec_report.launcher_logs = Some(root.join("logs"));
|
||||
spec_report.session_log = None;
|
||||
spec_report.run_dir = None;
|
||||
spec_report.version_json = None;
|
||||
|
||||
let done = super::export::export(&spec_report, Lang::ZhCn);
|
||||
let text = done.text.clone();
|
||||
assert!(done.ok, "{text}");
|
||||
assert_eq!(done.truncated, 0);
|
||||
assert!(text.contains("已导出"), "{text}");
|
||||
assert!(text.contains("字节"), "{text}");
|
||||
|
||||
let exports = root.join("logs").join("exports");
|
||||
let files: Vec<_> = std::fs::read_dir(&exports)
|
||||
.expect("导出目录")
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
assert_eq!(files.len(), 1, "一次导出只该落一个包");
|
||||
let size = files[0].metadata().expect("元数据").len();
|
||||
assert!(size > 0, "包不该是空文件");
|
||||
assert!(
|
||||
text.contains(&size.to_string()),
|
||||
"报出来的字节数要与磁盘一致:{text} / {size}"
|
||||
);
|
||||
}
|
||||
|
||||
/// P2-1:**卡片那一层只能是卡片大**。
|
||||
///
|
||||
/// 抓得住什么:r1 复审实测的那个 bug —— `mouse_area(整窗居中容器)` 的命中区是整窗,
|
||||
@@ -370,3 +335,6 @@ fn boot_facts_are_recorded_into_the_launcher_log() {
|
||||
assert!(text.contains("theme="), "{text}");
|
||||
assert!(text.contains("window="), "{text}");
|
||||
}
|
||||
|
||||
#[path = "tests/export.rs"]
|
||||
mod export;
|
||||
@@ -0,0 +1,85 @@
|
||||
//! 导出的用例:真的落盘、真的报字节数、真的在有截断时说一句。
|
||||
//!
|
||||
//! 从 `tests.rs` 拆出(那个文件贴着 400 行硬线)。共用父模块的 `report()` 夹具。
|
||||
|
||||
use unlucky_i18n::Lang;
|
||||
|
||||
use super::report;
|
||||
use crate::app::dialog::export;
|
||||
|
||||
/// 导出:真的落盘、真的报出路径与字节数(**没有 crash-report 也要能导出**)。
|
||||
#[test]
|
||||
fn export_writes_a_bundle_even_without_a_crash_report() {
|
||||
let root = std::env::temp_dir().join(format!("ull-m187-export-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("logs")).expect("建目录");
|
||||
std::fs::write(root.join("logs").join("launcher.log"), "[launch] boom\n").expect("写日志");
|
||||
|
||||
let mut spec_report = report(false, vec!["Error: boom".to_string()]);
|
||||
spec_report.launcher_logs = Some(root.join("logs"));
|
||||
spec_report.session_log = None;
|
||||
spec_report.run_dir = None;
|
||||
spec_report.version_json = None;
|
||||
|
||||
let done = export::export(&spec_report, Lang::ZhCn);
|
||||
let text = done.text.clone();
|
||||
assert!(done.ok, "{text}");
|
||||
assert_eq!(done.truncated, 0);
|
||||
assert!(text.contains("已导出"), "{text}");
|
||||
assert!(text.contains("字节"), "{text}");
|
||||
|
||||
let exports = root.join("logs").join("exports");
|
||||
let files: Vec<_> = std::fs::read_dir(&exports)
|
||||
.expect("导出目录")
|
||||
.filter_map(Result::ok)
|
||||
.collect();
|
||||
assert_eq!(files.len(), 1, "一次导出只该落一个包");
|
||||
let size = files[0].metadata().expect("元数据").len();
|
||||
assert!(size > 0, "包不该是空文件");
|
||||
assert!(
|
||||
text.contains(&size.to_string()),
|
||||
"报出来的字节数要与磁盘一致:{text} / {size}"
|
||||
);
|
||||
}
|
||||
|
||||
/// P3-4:`truncated > 0` 时**界面上那一句**真的出现(r2 复审登记的那条没有用例)。
|
||||
///
|
||||
/// 抓得住:把 `Exported.text` 里「有文件被截断」那句删掉/短路(变异证明见报告)。
|
||||
/// 抓不住:那句话排版好不好看(视觉)。
|
||||
#[test]
|
||||
fn a_truncated_export_says_so_in_the_notice() {
|
||||
use unlucky_core::launch::crash_bundle::MAX_ENTRY_BYTES;
|
||||
|
||||
let root = std::env::temp_dir().join(format!("ull-m187-trunc-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("logs")).expect("建目录");
|
||||
std::fs::create_dir_all(root.join("game").join("logs")).expect("建目录");
|
||||
std::fs::write(root.join("logs").join("launcher.log"), "boot\n").expect("写日志");
|
||||
|
||||
// 略超上限的 latest.log:必须用**真实日志行**填充 —— 同一字符的 4 MiB 会被
|
||||
// 「长不透明串」脱敏规则整段替换成占位符,长度就不超限了(那测的是脱敏不是截断)。
|
||||
let line = "2026-11-08T15:30:12.441Z [Render thread/INFO]: loading chunk\n";
|
||||
let mut big = String::new();
|
||||
while big.len() < usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 256 {
|
||||
big.push_str(line);
|
||||
}
|
||||
std::fs::write(root.join("game").join("logs").join("latest.log"), &big).expect("写大日志");
|
||||
|
||||
let mut report = report(false, Vec::new());
|
||||
report.launcher_logs = Some(root.join("logs"));
|
||||
report.run_dir = Some(root.join("game"));
|
||||
report.session_log = None;
|
||||
report.version_json = None;
|
||||
|
||||
let done = export::export(&report, Lang::ZhCn);
|
||||
assert!(done.ok, "{}", done.text);
|
||||
assert_eq!(
|
||||
done.truncated, 1,
|
||||
"这份 latest.log 必须真的被截断(>4 MiB)"
|
||||
);
|
||||
assert!(
|
||||
done.text.contains("已截断"),
|
||||
"界面上没有说「有文件被截断」:{}",
|
||||
done.text
|
||||
);
|
||||
}
|
||||
@@ -178,6 +178,9 @@ fn card<'a>(ui: &'a Ui, modal: &'a Modal) -> Element<'a, Message> {
|
||||
.spacing(ui.space(Sp::X3))
|
||||
.width(Length::Fill);
|
||||
|
||||
// 宽度取官方**上限** 560(`DialogGeometry::max_width`)而不是本项目默认 480:
|
||||
// 崩溃卡片多两行路径 + 三枚辅助按钮,480 会把主按钮压成竖排(实测过)。
|
||||
// 边界:组件层日后若改宽度,这里不会跟(已记 P3 台账)。
|
||||
container(body)
|
||||
.width(Length::Fixed(geometry.max_width))
|
||||
.max_height(CARD_HEIGHT)
|
||||
|
||||
@@ -90,8 +90,6 @@ impl App {
|
||||
None => {
|
||||
eprintln!("[appearance] hwnd=none(拿不到窗口句柄:整窗 α 无法下发)");
|
||||
self.log_appearance("boot", None);
|
||||
// M187(r1 复审 P2-4):启动这一拍的事实进启动器日志的缓冲(第一次启动时落盘)。
|
||||
boot_env::record_boot_log(self);
|
||||
}
|
||||
}
|
||||
// M116:圆角放在 α **之后**——α < 1 时那一步才补上 `WS_EX_LAYERED`,
|
||||
|
||||
@@ -117,7 +117,8 @@ impl BundleSpec {
|
||||
.zip(&self.entries)
|
||||
.map(|(name, entry)| format!("- {name} <- {}", entry.path.display()))
|
||||
.collect();
|
||||
self.header(&rows)
|
||||
// 对外出口一律脱敏(r2 复审 P3-6):不给「谁调谁泄漏」的口子。
|
||||
self.redactor().scrub(&self.header(&rows)).into_owned()
|
||||
}
|
||||
|
||||
/// 清单的**事实段 + files 段**(files 段由调用方给,行内已是最终条目名)。
|
||||
@@ -255,29 +256,27 @@ pub fn write_bundle(spec: &BundleSpec, target: &Path) -> Result<Bundle, String>
|
||||
continue;
|
||||
};
|
||||
let original_len = bytes.len();
|
||||
let mut body = bytes;
|
||||
// **唯一的通路**:解码 → 脱敏 → 按字符边界截断(见 `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);
|
||||
if body.len() as u64 > budget {
|
||||
let clipped = clip(&body, budget);
|
||||
if clipped.is_empty() {
|
||||
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;
|
||||
}
|
||||
body = clipped;
|
||||
};
|
||||
kept = clipped;
|
||||
collected.push(format!(
|
||||
"- {name}: 已截断(收头 {} B + 尾 {} B,原 {original_len} B)",
|
||||
HEAD_BYTES.min(original_len as u64),
|
||||
MAX_ENTRY_BYTES
|
||||
.saturating_sub(HEAD_BYTES)
|
||||
.min(original_len as u64)
|
||||
"- {name}: 已截断(收 {} B,原 {original_len} B)",
|
||||
kept.len()
|
||||
));
|
||||
truncated += 1;
|
||||
}
|
||||
// 文本才脱敏;二进制原样(截断过的文本尾部可能是半个 UTF-8 字符,退化成原样字节)。
|
||||
if let Ok(text) = String::from_utf8(body.clone()) {
|
||||
body = redactor.scrub(&text).into_owned().into_bytes();
|
||||
}
|
||||
let body = kept.into_bytes();
|
||||
total += body.len() as u64;
|
||||
files += 1;
|
||||
listed.push(format!("- {name} <- {}", entry.path.display()));
|
||||
@@ -348,34 +347,6 @@ fn unique_name(used: &mut Vec<String>, name: &str) -> String {
|
||||
unreachable!("序号一定找得到空位")
|
||||
}
|
||||
|
||||
/// 头 + 尾截断:保留前 [`HEAD_BYTES`] 与后 `budget - HEAD_BYTES` 字节,中间插一行说明。
|
||||
///
|
||||
/// 为什么是头 + 尾:崩溃证据在**尾部**(会话 JSONL 的 `exit` 事件与最后几条 stderr、
|
||||
/// `latest.log` 的栈),只留头部会把最有用的那几行丢掉(r1 复审 P2-3)。
|
||||
fn clip(body: &[u8], budget: u64) -> Vec<u8> {
|
||||
let budget = usize::try_from(budget).unwrap_or(usize::MAX);
|
||||
if body.len() <= budget || budget == 0 {
|
||||
return body.to_vec();
|
||||
}
|
||||
let head = usize::try_from(HEAD_BYTES)
|
||||
.unwrap_or(usize::MAX)
|
||||
.min(budget / 2);
|
||||
let tail = budget.saturating_sub(head);
|
||||
let dropped = body.len().saturating_sub(head + tail);
|
||||
let mut out = Vec::with_capacity(head + tail + 64);
|
||||
out.extend_from_slice(&body[..head]);
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"
|
||||
<truncated {dropped} bytes>
|
||||
"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
out.extend_from_slice(&body[body.len() - tail..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// 默认落点:启动器日志分区下的 `exports/`。
|
||||
///
|
||||
/// 为什么不弹文件选择框:本仓没有 `rfd` 这类原生对话框依赖(Cargo.toml 不在本 mission scope),
|
||||
@@ -385,5 +356,9 @@ pub fn default_target_dir(logs_dir: &Path) -> PathBuf {
|
||||
logs_dir.join("exports")
|
||||
}
|
||||
|
||||
mod clip;
|
||||
|
||||
use clip::clip_text;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,138 @@
|
||||
//! 超限文件的**文本流**截断(头 + 尾),切点落在字符/行边界上。
|
||||
//!
|
||||
//! ## 为什么截断必须发生在「文本流」里,而不是字节上
|
||||
//!
|
||||
//! 上一版在**字节**上切两刀(头 2 MiB / 尾 2 MiB),切完才试 `String::from_utf8`
|
||||
//! 决定要不要脱敏。中文与 emoji 是 3~4 字节,只要切点落在字中间,`from_utf8` 就失败,
|
||||
//! 于是**整份文件退回「原样字节」分支、一条脱敏规则都没跑**(r2 复审 P2-1 抓到的隐私旁路:
|
||||
//! 长会话的 `latest.log` 与会话 JSONL 正好是最容易超大、也最该脱敏的两个文件;
|
||||
//! 中文 Windows 用户名让家目录串本身就是 CJK,切在它附近必然命中)。
|
||||
//!
|
||||
//! 现在这条通路只有一种形态:**解码 → 脱敏 → 按字符边界截断**。没有「二进制原样」这条分支,
|
||||
//! 也就没有旁路。
|
||||
//!
|
||||
//! ## 为什么先脱敏再截断(而不是先截断再脱敏)
|
||||
//!
|
||||
//! 顺序反过来会在切点留下**半截 secret**:被切开的前缀不再匹配任何规则,于是以原文形态留在
|
||||
//! 包里。先脱敏就没有这个问题——整串已经变成占位符,截断只能切占位符。
|
||||
//!
|
||||
//! 代价:要先把整份文本过一遍脱敏器。导出是低频人工动作、上限 4 MiB,代价可以忽略。
|
||||
|
||||
/// 在**字符边界**上截断,保留头部与尾部,中间插一行说明。
|
||||
///
|
||||
/// 返回 `None` 表示不需要截(预算足够或预算为 0)。
|
||||
/// 返回值里的 `String` 已按预算裁好;调用方负责把 `truncated` 记进清单。
|
||||
pub(super) fn clip_text(text: &str, budget: u64) -> Option<String> {
|
||||
let budget = usize::try_from(budget).unwrap_or(usize::MAX);
|
||||
if budget == 0 || text.len() <= budget {
|
||||
return None;
|
||||
}
|
||||
|
||||
let head_budget = usize::try_from(super::HEAD_BYTES)
|
||||
.unwrap_or(usize::MAX)
|
||||
.min(budget / 2);
|
||||
let tail_budget = budget - head_budget;
|
||||
|
||||
let head_end = snap_back(text, head_budget);
|
||||
let tail_start = snap_forward(text, text.len() - tail_budget);
|
||||
if tail_start <= head_end {
|
||||
return None;
|
||||
}
|
||||
|
||||
let dropped = tail_start - head_end;
|
||||
Some(format!(
|
||||
"{}\n<truncated {dropped} bytes>\n{}",
|
||||
&text[..head_end],
|
||||
&text[tail_start..]
|
||||
))
|
||||
}
|
||||
|
||||
/// 从 `index` 向左找最近的**行首**,找不到就在字符边界上回退。
|
||||
///
|
||||
/// 行边界只为可读性:切在一条日志行中间,读的人会以为那份日志本身是坏的。
|
||||
fn snap_back(text: &str, index: usize) -> usize {
|
||||
let index = floor_char(text, index);
|
||||
let window = floor_char(text, index.saturating_sub(200));
|
||||
match text[window..index].rfind('\n') {
|
||||
Some(newline) => window + newline + 1,
|
||||
None => index,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 `index` 向右找最近的**行首**,找不到就在字符边界上前进。
|
||||
fn snap_forward(text: &str, index: usize) -> usize {
|
||||
let index = ceil_char(text, index);
|
||||
let window = ceil_char(text, (index + 200).min(text.len()));
|
||||
match text[index..window].find('\n') {
|
||||
Some(newline) => index + newline + 1,
|
||||
None => index,
|
||||
}
|
||||
}
|
||||
|
||||
/// 向左回退到最近的字符边界(`str::floor_char_boundary` 仍是 nightly)。
|
||||
fn floor_char(text: &str, index: usize) -> usize {
|
||||
let mut at = index.min(text.len());
|
||||
while at > 0 && !text.is_char_boundary(at) {
|
||||
at -= 1;
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// 向右前进到最近的字符边界。
|
||||
fn ceil_char(text: &str, index: usize) -> usize {
|
||||
let mut at = index.min(text.len());
|
||||
while at < text.len() && !text.is_char_boundary(at) {
|
||||
at += 1;
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{clip_text, snap_back, snap_forward};
|
||||
use crate::launch::crash_bundle::MAX_ENTRY_BYTES;
|
||||
|
||||
/// 切点必须落在字符边界上(否则下游 `str` 切片会直接 panic)。
|
||||
#[test]
|
||||
fn cuts_land_on_char_boundaries() {
|
||||
// 每个 CJK 字 3 字节:把边界刻意放在字的中间
|
||||
let text = "中".repeat(2000);
|
||||
let clipped = clip_text(&text, 3 * 500 + 1).expect("应当截断");
|
||||
assert!(clipped.contains("<truncated "), "{clipped:.80}");
|
||||
assert!(!clipped.contains('\u{fffd}'), "不许出现替换字符");
|
||||
// 两段都是合法 UTF-8 边界 ⇒ 能再按 `str` 切
|
||||
let head = clipped.split("<truncated ").next().unwrap_or_default();
|
||||
assert!(head.chars().all(|ch| ch == '中' || ch == '\n'), "{head:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn head_and_tail_are_both_kept_and_snapped_to_lines() {
|
||||
let mut text = String::new();
|
||||
while text.len() < usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 128 {
|
||||
text.push_str("2026-11-08 line with some words\n");
|
||||
}
|
||||
text.push_str("{\"kind\":\"exit\"}\n");
|
||||
let clipped = clip_text(&text, MAX_ENTRY_BYTES).expect("应当截断");
|
||||
assert!(clipped.starts_with("2026-11-08 line"), "头部丢了");
|
||||
assert!(clipped.ends_with("{\"kind\":\"exit\"}\n"), "尾部丢了");
|
||||
assert!(
|
||||
clipped.len() as u64 <= MAX_ENTRY_BYTES + 64,
|
||||
"截断后不该超出预算({} > {})",
|
||||
clipped.len(),
|
||||
MAX_ENTRY_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_files_are_not_touched() {
|
||||
assert!(clip_text("short", 1024).is_none());
|
||||
assert!(clip_text("short", 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapping_prefers_line_bounds() {
|
||||
let text = "aaaa\nbbbb\ncccc";
|
||||
assert_eq!(snap_back(text, 7), 5, "应回退到「bbbb」行首");
|
||||
assert_eq!(snap_forward(text, 5), 10, "应前进到「cccc」行首");
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{default_target_dir, write_bundle, BundleEntry, BundleSpec, MAX_ENTRY_BYTES};
|
||||
use super::{
|
||||
default_target_dir, write_bundle, BundleEntry, BundleSpec, HEAD_BYTES, MAX_ENTRY_BYTES,
|
||||
};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("ull-m187-{}-{tag}", std::process::id()));
|
||||
@@ -43,6 +45,21 @@ fn names(path: &Path) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 接近真实日志的填充物。
|
||||
///
|
||||
/// 为什么不用 `"x".repeat(n)`:连续 4 MiB 的同字符会被脱敏器的「长不透明串」规则
|
||||
/// (长度 ≥64 的 base64/hex 形状)整段替换成占位符,长度就不再超限 —— 那样测的是脱敏,
|
||||
/// 不是截断。
|
||||
fn filler(bytes: usize) -> String {
|
||||
let line = "2026-11-08T15:30:12.441Z [Render thread/INFO]: loading chunk with some words
|
||||
";
|
||||
let mut text = String::with_capacity(bytes + line.len());
|
||||
while text.len() < bytes {
|
||||
text.push_str(line);
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn spec(root: &Path, secrets: Vec<String>) -> BundleSpec {
|
||||
BundleSpec {
|
||||
instance: "1.20.4 · Fabric".into(),
|
||||
@@ -159,7 +176,7 @@ fn missing_crash_report_is_stated_in_the_manifest() {
|
||||
fn oversized_entries_are_truncated_and_recorded() {
|
||||
let root = temp_dir("truncate");
|
||||
let big = root.join("game").join("latest.log");
|
||||
let body = "x".repeat(usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 32);
|
||||
let body = filler(usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 32);
|
||||
write(&big, &body);
|
||||
|
||||
let target = default_target_dir(&root).join("m187.zip");
|
||||
@@ -273,7 +290,7 @@ fn truncation_keeps_the_tail_where_the_crash_evidence_lives() {
|
||||
"{head_marker}
|
||||
{}{tail_marker}
|
||||
",
|
||||
"x".repeat(usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 64)
|
||||
filler(usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 64)
|
||||
);
|
||||
write(&session, &body);
|
||||
|
||||
@@ -296,3 +313,75 @@ fn truncation_keeps_the_tail_where_the_crash_evidence_lives() {
|
||||
let manifest = entry_text(&target, "manifest.txt").expect("清单");
|
||||
assert!(manifest.contains("已截断"), "{manifest}");
|
||||
}
|
||||
|
||||
/// P2-1(r2 复审新增,按 p1 对待):**截断过的文件也必须脱敏**。
|
||||
///
|
||||
/// 抓得住的原 bug:上一版在字节上切两刀,切在多字节字符中间时 `from_utf8` 失败 ⇒
|
||||
/// 整份 entry 走「原样字节」分支,家目录 / 用户名 / 令牌原文按字节进包(隐私旁路)。
|
||||
///
|
||||
/// 构造要点:让一个 3 字节的 `中` **正好跨在 `HEAD_BYTES` 边界上**,文件里放注册过的
|
||||
/// 会话 id 与家目录路径,尾部再放 exit 事件(证明截断仍然保尾)。
|
||||
#[test]
|
||||
fn truncated_entries_are_still_redacted() {
|
||||
let root = temp_dir("trunc-redact");
|
||||
let home = root.join("home");
|
||||
let session = "0f8fad5b-d9cb-469f-a165-70867728950e";
|
||||
let head_budget = usize::try_from(HEAD_BYTES).expect("常数");
|
||||
|
||||
// 前段:真实的日志行 + 家目录 + 会话 id;填充到「离边界只差 2 字节」,
|
||||
// 再放一个 3 字节的 CJK 字 ⇒ 它必然跨在 HEAD_BYTES 上。
|
||||
let mut text = String::new();
|
||||
text.push_str(&format!(
|
||||
"[boot] home={}
|
||||
",
|
||||
home.display()
|
||||
));
|
||||
text.push_str(&format!(
|
||||
"[launch] session={session}
|
||||
"
|
||||
));
|
||||
let line = "2026-11-08T15:30:12.441Z [Render thread/INFO]: loading chunk
|
||||
";
|
||||
while text.len() + line.len() < head_budget - 2 {
|
||||
text.push_str(line);
|
||||
}
|
||||
while text.len() < head_budget - 2 {
|
||||
text.push('a');
|
||||
}
|
||||
text.push('中');
|
||||
while text.len() < usize::try_from(MAX_ENTRY_BYTES).expect("常数") + 64 {
|
||||
text.push_str(line);
|
||||
}
|
||||
text.push_str(
|
||||
"{\"kind\":\"exit\",\"payload\":{\"code\":1}}
|
||||
",
|
||||
);
|
||||
|
||||
let log = root.join("game").join("latest.log");
|
||||
write(&log, &text);
|
||||
assert!(
|
||||
!text.is_char_boundary(head_budget),
|
||||
"构造失败:切点应落在字中间"
|
||||
);
|
||||
|
||||
let mut spec = spec(&root, vec![session.to_string()]);
|
||||
spec.entries = vec![BundleEntry::named("game", &log, "latest.log")];
|
||||
let target = default_target_dir(&root).join("trunc-redact.zip");
|
||||
let bundle = write_bundle(&spec, &target).expect("导出");
|
||||
assert_eq!(
|
||||
bundle.truncated, 1,
|
||||
"这份文件必须真的被截断(否则测不到旁路)"
|
||||
);
|
||||
|
||||
let kept = entry_text(&target, "game/latest.log").expect("条目");
|
||||
// ① 包里找不到原文(家目录 / 会话 id)
|
||||
assert!(
|
||||
!kept.contains(&home.display().to_string()),
|
||||
"截断后的文件仍带着家目录原文 —— 脱敏被跳过了"
|
||||
);
|
||||
assert!(!kept.contains(session), "截断后的文件仍带着会话 id 原文");
|
||||
// ② 占位符在(说明脱敏真的跑过)
|
||||
assert!(kept.contains("<home>"), "没有 <home> 占位符");
|
||||
// ③ 尾部证据仍在(截断仍保尾)
|
||||
assert!(kept.contains("\"kind\":\"exit\""), "尾部 exit 事件丢了");
|
||||
}
|
||||
Reference in new issue
Block a user