diff --git a/crates/app-iced/src/app/dialog/tests.rs b/crates/app-iced/src/app/dialog/tests.rs index 983c1af..7002088 100644 --- a/crates/app-iced/src/app/dialog/tests.rs +++ b/crates/app-iced/src/app/dialog/tests.rs @@ -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; diff --git a/crates/app-iced/src/app/dialog/tests/export.rs b/crates/app-iced/src/app/dialog/tests/export.rs new file mode 100644 index 0000000..8fd2d21 --- /dev/null +++ b/crates/app-iced/src/app/dialog/tests/export.rs @@ -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 + ); +} diff --git a/crates/app-iced/src/app/dialog/view.rs b/crates/app-iced/src/app/dialog/view.rs index 34d6ab8..d96c2e7 100644 --- a/crates/app-iced/src/app/dialog/view.rs +++ b/crates/app-iced/src/app/dialog/view.rs @@ -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) diff --git a/crates/app-iced/src/app/update.rs b/crates/app-iced/src/app/update.rs index efea010..8762ca9 100644 --- a/crates/app-iced/src/app/update.rs +++ b/crates/app-iced/src/app/update.rs @@ -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`, diff --git a/crates/core/src/launch/crash_bundle.rs b/crates/core/src/launch/crash_bundle.rs index 99cbdcf..6eb9a68 100644 --- a/crates/core/src/launch/crash_bundle.rs +++ b/crates/core/src/launch/crash_bundle.rs @@ -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 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, name: &str) -> String { unreachable!("序号一定找得到空位") } -/// 头 + 尾截断:保留前 [`HEAD_BYTES`] 与后 `budget - HEAD_BYTES` 字节,中间插一行说明。 -/// -/// 为什么是头 + 尾:崩溃证据在**尾部**(会话 JSONL 的 `exit` 事件与最后几条 stderr、 -/// `latest.log` 的栈),只留头部会把最有用的那几行丢掉(r1 复审 P2-3)。 -fn clip(body: &[u8], budget: u64) -> Vec { - 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!( - " - -" - ) - .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; diff --git a/crates/core/src/launch/crash_bundle/clip.rs b/crates/core/src/launch/crash_bundle/clip.rs new file mode 100644 index 0000000..e69731b --- /dev/null +++ b/crates/core/src/launch/crash_bundle/clip.rs @@ -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 { + 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\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(" {})", + 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」行首"); + } +} diff --git a/crates/core/src/launch/crash_bundle/tests.rs b/crates/core/src/launch/crash_bundle/tests.rs index d7b6a4d..e4f0062 100644 --- a/crates/core/src/launch/crash_bundle/tests.rs +++ b/crates/core/src/launch/crash_bundle/tests.rs @@ -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 { .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) -> 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(""), "没有 占位符"); + // ③ 尾部证据仍在(截断仍保尾) + assert!(kept.contains("\"kind\":\"exit\""), "尾部 exit 事件丢了"); +}