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:
AskaEth committed 2026-11-08 23:57:49 +08:00
1 parent 3cd371b537
commit 2b6dcbed40
7 files changed
+340 -84

No files matched your search

+19 -44
View File
@@ -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;