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`,
|
||||
|
||||
Reference in new issue
Block a user