vigil_redaction/scan.rs
1//! ISS-005:Stage 2 T0 统一 scan 入口(`scan_text`)。
2//!
3//! **本 Stage 1 scaffold 语义**(ADR 0013 + `docs/design/vigil-redaction-selection.md`):
4//! - 只跑 v0.3 硬指纹路径(`HARD_RULES`)→ 映射成 `Vec<Finding>` 作为 merge 的 hard 侧输入
5//! - Model 侧置空(真模型推理接入留给 **ISS-008**;届时在本函数内部扩 hard + model 组合
6//! 调用 `merge_findings`,外部 API 形态保持不变)
7//! - 产出统一 `RedactionResult { findings, redacted_text, risk_signals }`
8//!
9//! **fail-closed 不变量**:
10//! - 空输入返 `Err(EmptyInput)`(**不**返 OK 空 findings,避免 caller 误判"已扫并安全")
11//! - 内部纯字符串运算,不引入 panic 路径;未来 ISS-008 接入模型时,推理失败应返
12//! `Err(InferenceFailed { .. })`,由 caller 决定 block 还是降级
13//!
14//! **risk_delta 分级**(ADR 0012 §1.3,Stage 1 简化实装):
15//! - Secret 类(服务凭证泄漏):25
16//! - Email / Internal IP / URL(识别身份/拓扑):10
17//! - 其它 PII(person / phone / address / date / account_number):5
18//!
19//! **Stage 1 简化口径说明**(ISS-010 R1 新发现 2):roadmap 曾提议细粒度分级
20//! (如 account_number +15 / phone +10 / 多命中 +10),但 Stage 1 scaffold 为保持
21//! 实装最小 / 测试可守门,采用**粗粒度 3 档**(Secret / Url&Email / 其他)。
22//! 细粒度分级 + 多命中加权留给 **ISS-008 真模型接入**同步实装;届时 ADR 0012 §1.3
23//! Revised 段会明确新口径。在那之前,firewall 侧 `PolicyContext.risk_score` 仅
24//! 消费本层透传的 `total_risk_delta`,不在 caller 重算分级(避免规则漂移)。
25//!
26//! 本层不关心 "caller 要不要 block" —— 只给数据,由 ISS-010 firewall preflight 决策。
27
28use std::collections::BTreeMap;
29use std::sync::Arc;
30use std::time::Duration;
31
32use crate::engine::{NoopEngine, RedactionEngine};
33use crate::label::PrivacyLabel;
34use crate::merge::{merge_findings, Finding};
35use crate::HARD_RULES;
36
37/// `scan_text` 的综合输出。
38///
39/// 与 v0.3 `(Value, String)` 的 `redact` 返值不同:本 API 面向 **Stage 2 文本扫描**
40/// 场景(firewall preflight / browser classifier / CLI scan),返回结构化 findings
41/// 供 caller 按需拼 UI / 累加 risk。
42#[derive(Debug, Clone, PartialEq)]
43pub struct RedactionResult {
44 /// 合并后的 findings,按 `span.start` 升序(继承 `merge_findings` 不变量)。
45 /// 元素类型是 ISS-013 的 `Finding`,`source` 字段可区分 Hard / Model。
46 pub findings: Vec<Finding>,
47 /// 原文按 findings 的 span 全部替换为 `[REDACTED <label>]` 后的脱敏文本。
48 ///
49 /// 替换策略:
50 /// - `PrivacyLabel::from_kind` 命中 → `[REDACTED <label.as_str()>]`(稳定外部契约)
51 /// - 未命中 → `[REDACTED <raw_kind>]`(兼容未来新 kind,不阻塞实装)
52 pub redacted_text: String,
53 /// 聚合风险信号,供 caller 快速判定"是否有 secret"/"总风险分"。
54 pub risk_signals: RiskSignals,
55}
56
57/// 聚合风险信号。纯派生数据,可从 `findings` 重算;先算好避免 caller 每次重跑。
58#[derive(Debug, Clone, PartialEq, Default)]
59pub struct RiskSignals {
60 /// 按 `PrivacyLabel` 分档计数(未识别 kind 不计入此 map,但仍保留在 findings)。
61 pub counts_by_label: BTreeMap<PrivacyLabel, u32>,
62 /// 总风险分 = `sum(finding.risk_delta)`。
63 /// 继承 ISS-013 D4 不变量:同 span 重叠时 Model 已在 merge 时被 drop,不双倍。
64 pub total_risk_delta: u32,
65 /// 是否含至少一个 `Secret` 类 finding(caller 可直接作 fail-closed 早判)。
66 pub has_secret: bool,
67}
68
69/// `scan_text` 错误类型。Stage 1 scaffold 主要覆盖 `EmptyInput`;
70/// `InferenceFailed` 为 ISS-008 真模型接入预留 variant,现阶段不会由本函数返出。
71#[derive(Debug, thiserror::Error)]
72pub enum ScanError {
73 /// 未来真模型(onnxruntime / Transformers.js via native host)推理失败时返此。
74 /// caller 应视为 fail-closed:不要降级为"空 findings = 安全"。
75 #[error("inference failed: {reason}")]
76 InferenceFailed {
77 /// 失败原因(模型名 / backend / errno 等);不应含用户原文内容。
78 reason: String,
79 },
80 /// 空输入约定:scaffold 对空输入返 Err 而非空 OK,避免 caller 误以为"已扫并安全"。
81 /// 调用点应该在上游用 `if input.is_empty() { .. }` 明确决策分支。
82 #[error("empty input not allowed (use Option before calling)")]
83 EmptyInput,
84}
85
86/// Stage 2 T0 统一入口:扫描文本,产出 findings + 脱敏文本 + 风险信号。
87///
88/// # Stage 1 scaffold 可产出边界(R1 MUST-FIX 1 修复)
89///
90/// **本函数当前(Stage 1)只运行 `HARD_RULES`**,因此能端到端产出 finding 的
91/// `PrivacyLabel` 仅覆盖**硬指纹可识别**的 3 类:
92///
93/// | 可产出 | label 对应 kind(HARD_RULES name) |
94/// |--|--|
95/// | [`PrivacyLabel::Secret`] | aws_access_key_id / github_token / anthropic_api_key / openai_api_key / jwt / pem_private_key / env_assignment / slack_webhook / stripe_secret_key / google_api_key / gitlab_pat / database_url |
96/// | [`PrivacyLabel::Email`] | email(**注**:v0.3 HARD_RULES 的 `email` 规则在 `ALL_RULES` 里,但 **不在 `HARD_RULES` 里**,因此当前 Stage 1 `scan_text` 实际**也不会产出 Email finding**。见下文"Stage 1 实测覆盖") |
97/// | [`PrivacyLabel::Url`] | internal_ipv4(同上,`internal_ipv4` 不在 `HARD_RULES`,Stage 1 也不产出) |
98///
99/// **Stage 1 实测覆盖**:上表的 Email / Url 受 `HARD_RULES` 豁免影响,当前 scan_text
100/// 只对 `Secret` 类输入端到端产 finding。其余 5 类 —— [`PrivacyLabel::AccountNumber`] /
101/// [`PrivacyLabel::Phone`] / [`PrivacyLabel::Person`] / [`PrivacyLabel::Address`] /
102/// [`PrivacyLabel::Date`] —— 需要 **ISS-008 真模型(OpenAI Privacy Filter via ONNX
103/// Runtime 1.24)**接入后才会被识别。caller **不应**假设 Stage 1 能覆盖 8 类全部。
104///
105/// ## 为什么 email / internal_ipv4 不在 HARD_RULES
106/// 见 `crates/vigil-redaction/src/lib.rs` §HARD_RULES 注释:这两类在合法业务上下文
107/// 里频繁出现,硬指纹直接拦会导致大量误报;改由 ISS-008 模型层给出软标签 + 上下文
108/// 加权,而不是零门槛 regex。
109///
110/// # Errors
111/// - [`ScanError::EmptyInput`]:`input` 为空字符串(fail-closed:**不**返 OK 空 findings)
112/// - [`ScanError::InferenceFailed`]:Stage 1 不会触发(留给 ISS-008 真模型路径)
113pub fn scan_text(input: &str) -> Result<RedactionResult, ScanError> {
114 // ISS-008 Phase 1:`scan_text` 等价委托到 `scan_text_with_engine(input, &NoopEngine)`。
115 // NoopEngine.infer 永远返 Ok(vec![]),与 Stage 1 scaffold 原 "merge_findings(&hard, &[])"
116 // 行为完全一致 —— v0.3 公共 API 形态 / `scan_text_v03_public_api_intact` 守门测试均不动。
117 scan_text_with_engine(input, &NoopEngine)
118}
119
120/// 引擎可注入版本的 [`scan_text`](ISS-008 Phase 1)。
121///
122/// 与 [`scan_text`] 唯一区别:Model 侧 findings 由 `engine.infer(input)` 产出,
123/// 而非硬编码空向量。所有 hard / merge / redact / aggregate 逻辑零分叉复用。
124///
125/// # 不变量
126/// - **EmptyInput fail-closed**:与 [`scan_text`] 一致,空输入返 [`ScanError::EmptyInput`]
127/// - **engine 失败 fail-closed**:`engine.infer` 返 `Err` → 经
128/// `From<crate::engine::EngineError> for ScanError` 塌缩到
129/// [`ScanError::InferenceFailed`](`reason` 不含 input 内容,由 caller 保证)
130/// - **risk_delta 注入**:engine 产 Finding 时不知道 risk 分级表(ADR 0012 §1.3 SSOT
131/// 在本层),本函数逐条按 [`risk_of`] 补值;engine 与 risk 表彻底解耦,避免新增 kind
132/// 时出现"两处都写错就漂移"
133/// - **merge 策略 SSOT 不下放**:hard × model 重叠决策仍由 [`merge_findings`] 编排
134/// (ADR 0013 D3)
135///
136/// # Errors
137/// - [`ScanError::EmptyInput`]:`input` 为空字符串
138/// - [`ScanError::InferenceFailed`]:`engine.infer` 失败
139pub fn scan_text_with_engine(
140 input: &str,
141 engine: &dyn RedactionEngine,
142) -> Result<RedactionResult, ScanError> {
143 // **v0.9 Sprint 1 P1.2**:legacy 路径 → scan_text_with_engine_with_lang(.., None)
144 // (lang None 等价 v0.8 行为;不命中 lang_conditional_profile.overrides)
145 scan_text_with_engine_with_lang(input, engine, None)
146}
147
148/// **v0.9 Sprint 1 P1.2** — lang-aware 版本(spike)。
149///
150/// 与 [`scan_text_with_engine`] 同口径(Hard + Model 合并 / risk_delta 注入 /
151/// merge_findings 决策),唯一区别:**engine.infer_with_lang(input, lang)** 取代
152/// `engine.infer(input)`,让 engine 内部 threshold 应用走 lang-conditional 路径
153/// (若 engine 实现支持,如 `OrtEngine` for `XlmrPiiDescriptor`)。
154///
155/// **lang 参数**:
156/// - `Some("en"|"de"|"it"|"fr"|...)`:case-sensitive,推荐 ISO 639-1 lowercase;
157/// 命中 (lang, label) override 即按 lang-conditional threshold 屏蔽
158/// - `None`:等价 [`scan_text_with_engine`],engine 走 default `threshold_profile()`
159///
160/// **caller 责任**:lang 来源 — fixture lang 字段透传 / 业务上下文 / 启发式
161/// (`feedback_lang_review_authoritative` 警告:启发式不可作权威);若不确定
162/// 应传 `None` 走 default 安全路径。
163///
164/// # Errors
165/// 同 [`scan_text_with_engine`]:[`ScanError::EmptyInput`] / [`ScanError::InferenceFailed`]
166pub fn scan_text_with_engine_with_lang(
167 input: &str,
168 engine: &dyn RedactionEngine,
169 lang: Option<&str>,
170) -> Result<RedactionResult, ScanError> {
171 if input.is_empty() {
172 return Err(ScanError::EmptyInput);
173 }
174
175 // engine 失败 → ? + From<EngineError> for ScanError → InferenceFailed(fail-closed)
176 let mut model_findings = engine.infer_with_lang(input, lang)?;
177 // risk_delta 由 caller 补(C-7);engine 不依赖 risk 表,避免分级口径双写漂移。
178 for f in &mut model_findings {
179 f.risk_delta = risk_of(f.kind);
180 }
181
182 // v0.7-α3 R1a:除 HARD_RULES(secret 类子集)外,补 ALL_RULES url 类
183 // (generic_url + internal_ipv4)— Phase 3 ensemble 路径需 url canonical
184 // 兜底(yonigo 仅 IP / xlmr 无 url native)。提升 hard 路径完备性,无回归
185 // risk:既有测试 Hard secret 仍命中,新增 url canonical 通过 PrivacyLabel::Url
186 // 累加 risk_delta=10。
187 let mut hard_findings = collect_hard_findings(input);
188 hard_findings.extend(collect_url_hard_findings(input));
189 let merged = merge_findings(&hard_findings, &model_findings);
190
191 let redacted_text = build_redacted_text(input, &merged);
192 let risk_signals = aggregate_risk(&merged);
193 Ok(RedactionResult {
194 findings: merged,
195 redacted_text,
196 risk_signals,
197 })
198}
199
200/// v0.7-α2 Phase 2D(ADR 0016 Fail-Closed Bottom Line):模型路径执行状态。
201///
202/// caller 通过 [`BudgetedScanOutcome::status`] 拿到本枚举,用于审计 ledger
203/// 标记(`decision_id = model_path_degraded`)与 UI 展示退化原因。
204#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub enum EngineStatus {
206 /// 模型路径在 budget 内成功完成;`findings` 含 Hard + Model 完整合并
207 Ok,
208 /// 模型路径超 budget(timeout);退化到 Hard-only,`findings` 缺 Model 增强语义
209 /// (Hard 路径继续守 secret 类,fail-closed 不变量保留)
210 DegradedTimeout,
211 /// 模型路径返 [`crate::engine::EngineError`];同样退化到 Hard-only。
212 /// 字段为 stringified reason(诊断用,`reason` 不含 input 内容)
213 DegradedError,
214}
215
216/// v0.7-α2 Phase 2D — [`scan_text_with_engine_budgeted`] 输出。
217///
218/// 区别于 [`RedactionResult`]:多带一个 [`EngineStatus`] 标记供 caller 决策审计/UI。
219#[derive(Debug, Clone)]
220pub struct BudgetedScanOutcome {
221 /// 与 [`scan_text_with_engine`] 同形态;findings 已合并(Hard + 可能的 Model)
222 pub result: RedactionResult,
223 /// 模型路径执行状态(Ok / DegradedTimeout / DegradedError)
224 pub status: EngineStatus,
225}
226
227/// v0.7-α2 Phase 2D(ADR 0016)— 带 budget 的 scan,模型路径超时即退化 Hard-only。
228///
229/// **不变量**(ADR 0016 § 2.2 Fail-Closed Bottom Line):
230/// - **空输入 fail-closed**:与 [`scan_text`] 一致,空输入返 [`ScanError::EmptyInput`]
231/// - **退化非 fail-open**:模型 timeout / error → 退化路径仍跑 Hard 规则,Hard 类
232/// `secret` 命中正常拦截;**绝不**返空 findings 假装"已扫并安全"
233/// - **engine.infer 不可被中断**:`std::sync::mpsc::recv_timeout` 仅放弃等待,后台
234/// worker thread 继续跑直到 ORT inference 自然结束(orphan thread 风险可控,因
235/// ORT 推理终会 ~462ms 自然完成)
236/// - **caller 责任**:拿到 `EngineStatus::Degraded*` 后,审计 ledger 应 append
237/// `engine.degraded` 事件 + `decision_id = model_path_degraded`(本层不写 ledger,
238/// 保持纯函数语义)
239///
240/// **线程模型**:engine 必须 `Arc<dyn RedactionEngine + 'static>`(spawn 需 'static)。
241///
242/// # Errors
243/// - [`ScanError::EmptyInput`]:`input` 为空字符串
244///
245/// 注意:engine 失败 / timeout **不** propagate 为 Err;退化为 Hard-only 成功路径,
246/// caller 通过 `status` 字段判断。这是与 [`scan_text_with_engine`] 的核心区别 ——
247/// 后者 fail-closed 把 engine 错误传给 caller,本 API 把"模型路径增强"视为 best-effort。
248pub fn scan_text_with_engine_budgeted(
249 input: &str,
250 engine: Arc<dyn RedactionEngine>,
251 budget: Duration,
252) -> Result<BudgetedScanOutcome, ScanError> {
253 if input.is_empty() {
254 return Err(ScanError::EmptyInput);
255 }
256
257 // 启 worker thread 跑 engine.infer;主线程等 budget,超时则放弃等待
258 let (tx, rx) = std::sync::mpsc::channel();
259 let input_owned = input.to_string();
260 let engine_for_thread = Arc::clone(&engine);
261 std::thread::spawn(move || {
262 let res = engine_for_thread.infer(&input_owned);
263 // tx.send 失败(接收端已 timeout drop)即静默丢弃,worker 自然结束
264 let _ = tx.send(res);
265 });
266
267 let (mut model_findings, status) = match rx.recv_timeout(budget) {
268 Ok(Ok(findings)) => (findings, EngineStatus::Ok),
269 Ok(Err(_engine_err)) => {
270 // engine 内部失败 → 退化 Hard-only;诊断信息丢弃避免 reason 泄露 input
271 (Vec::new(), EngineStatus::DegradedError)
272 }
273 Err(_recv_timeout) => {
274 // budget 超 → 放弃等待 worker(后台仍跑直到自然结束)
275 (Vec::new(), EngineStatus::DegradedTimeout)
276 }
277 };
278
279 // risk_delta 注入(与 scan_text_with_engine 同口径)
280 for f in &mut model_findings {
281 f.risk_delta = risk_of(f.kind);
282 }
283
284 // v0.7-α3 R1a:除 HARD_RULES(secret 类子集)外,补 ALL_RULES url 类
285 // (generic_url + internal_ipv4)— Phase 3 ensemble 路径需 url canonical
286 // 兜底(yonigo 仅 IP / xlmr 无 url native)。提升 hard 路径完备性,无回归
287 // risk:既有测试 Hard secret 仍命中,新增 url canonical 通过 PrivacyLabel::Url
288 // 累加 risk_delta=10。
289 let mut hard_findings = collect_hard_findings(input);
290 hard_findings.extend(collect_url_hard_findings(input));
291 let merged = merge_findings(&hard_findings, &model_findings);
292 let redacted_text = build_redacted_text(input, &merged);
293 let risk_signals = aggregate_risk(&merged);
294
295 Ok(BudgetedScanOutcome {
296 result: RedactionResult {
297 findings: merged,
298 redacted_text,
299 risk_signals,
300 },
301 status,
302 })
303}
304
305/// 把 v0.3 `HARD_RULES` 的命中转换成带 span 的 `Finding` 列表。
306///
307/// 与 `scan_hard_findings`(v0.3 只返规则名)的区别:这里需要 `find_iter` 拿出
308/// 每个命中的 `(start, end)`,供 `merge_findings` 做 span-overlap 决策。
309///
310/// **纪律**:
311/// - 同规则可在同文本多次命中 → 产出多个 Finding(每个 Match 一条)
312/// - 不同规则命中重叠片段时,保留两条 —— 由 `merge_findings` 的后续稳定排序
313/// 与 caller 审计侧决策是否去重(这里不做筛选,避免吃掉 caller 信息)
314/// - 顺序:按 `HARD_RULES` 声明顺序 × `find_iter` 位置顺序 append;
315/// `merge_findings` 最后按 `span.start` 升序稳定排序
316fn collect_hard_findings(text: &str) -> Vec<Finding> {
317 let mut out: Vec<Finding> = Vec::new();
318 for rule in HARD_RULES.iter() {
319 for m in rule.pattern.find_iter(text) {
320 out.push(Finding::hard(
321 rule.name,
322 (m.start(), m.end()),
323 risk_of(rule.name),
324 ));
325 }
326 }
327 out
328}
329
330/// v0.7-α3 R1a(E6a) — 收集 ALL_RULES 中 **url 类** 命中(`generic_url` +
331/// `internal_ipv4`),补 [`HARD_RULES`] secret-only 子集对 url canonical 的覆盖
332/// 不足(P3-spike R1 暴露 gap)。
333///
334/// **设计纪律**:
335/// - 不动 [`HARD_RULES`](保 vigil-browser RULE_PROFILE_VERSION v5 守门数字)
336/// - 仅在 `scan_text_with_engine` 路径调用(scan_text 默认调 NoopEngine 也走此
337/// 路径,默认 v0.3 测试不破:secret 仍命中,新增 url 是增量信号)
338/// - canonical 经 [`crate::PrivacyLabel::from_kind`] 路由:`generic_url` /
339/// `internal_ipv4` → [`crate::PrivacyLabel::Url`]
340/// - risk_delta = 10(URL 类,ADR 0012 §1.3)
341fn collect_url_hard_findings(text: &str) -> Vec<Finding> {
342 use crate::ALL_RULES;
343 let mut out: Vec<Finding> = Vec::new();
344 for rule in ALL_RULES.iter() {
345 // 只挑 url canonical 类(generic_url + internal_ipv4);其他 ALL_RULES 命中
346 // 应仍由 HARD_RULES 走默认路径(避免 email 等被加进 hard 路径破 v0.3 期望)
347 if rule.name == "generic_url" || rule.name == "internal_ipv4" {
348 for m in rule.pattern.find_iter(text) {
349 out.push(Finding::hard(
350 rule.name,
351 (m.start(), m.end()),
352 risk_of(rule.name),
353 ));
354 }
355 }
356 }
357 out
358}
359
360/// 风险分级(ADR 0012 §1.3):Secret = 25 / Email | Url = 10 / 其它 PII = 5。
361///
362/// 入参是 `Finding.kind` 字面量;未知 kind 走 PII 默认 5(保守估值,不 0 避免"隐式忽略")。
363///
364/// **可见性**:`pub(crate)` 因为 `scan_text_with_engine` 内部要按 kind 给 engine 产出的
365/// model findings 补 risk_delta;crate 外不应直接调,risk 注入是本层职责。
366pub(crate) fn risk_of(kind: &str) -> u32 {
367 match PrivacyLabel::from_kind(kind) {
368 Some(PrivacyLabel::Secret) => 25,
369 Some(PrivacyLabel::Email) | Some(PrivacyLabel::Url) => 10,
370 Some(_) => 5,
371 None => 5,
372 }
373}
374
375/// 按 findings 的 span 从后往前替换,避免 offset 漂移。
376/// 替换文案走 `PrivacyLabel::as_str()`(稳定契约);未识别 kind 降级用原字面量,
377/// 保证不丢信息。
378///
379/// **注**:当前 `merge_findings` 已去掉 Hard 重叠的 Model,正常 span 不交叠;
380/// 但防御性考虑 —— 若未来扩 Model 规则允许非重叠但紧邻,本排序仍正确。
381/// 若真出现重叠(例如多条 Hard 同位命中),按 start 降序处理,前面的替换不影响
382/// 后面(index 大的 span 不在 index 小的替换区之外)—— Stage 1 下 HARD_RULES 内部
383/// 不出现跨规则同位重叠,该边界由 ISS-008 进一步加固。
384fn build_redacted_text(input: &str, findings: &[Finding]) -> String {
385 // sort 副本,不改 caller 给的 findings;span.0 降序(右→左替换避免 index 漂移)。
386 // v0.13:rust 1.95 clippy::unnecessary_sort_by 推荐用 sort_by_key + Reverse 表达。
387 let mut sorted: Vec<&Finding> = findings.iter().collect();
388 sorted.sort_by_key(|f| std::cmp::Reverse(f.span.0));
389
390 let mut out = input.to_string();
391 for f in sorted {
392 let (start, end) = f.span;
393 // 越界防御:理论上 merge 后的 span 来自 regex 命中,不会越界;但 caller
394 // 可能构造 Finding 手动塞进 merge。越界直接跳过,避免 panic。
395 if start > end || end > out.len() {
396 continue;
397 }
398 // UTF-8 char boundary 校验:非 char boundary 跳过,保证 out.replace_range
399 // 不会 panic。regex Match 总在 char boundary,但手工构造的 span 可能不是。
400 if !out.is_char_boundary(start) || !out.is_char_boundary(end) {
401 continue;
402 }
403 let placeholder = match PrivacyLabel::from_kind(f.kind) {
404 Some(label) => format!("[REDACTED {}]", label.as_str()),
405 None => format!("[REDACTED {}]", f.kind),
406 };
407 out.replace_range(start..end, &placeholder);
408 }
409 out
410}
411
412/// 聚合 findings 为 `RiskSignals`。
413fn aggregate_risk(findings: &[Finding]) -> RiskSignals {
414 let mut counts: BTreeMap<PrivacyLabel, u32> = BTreeMap::new();
415 let mut total: u32 = 0;
416 for f in findings {
417 total = total.saturating_add(f.risk_delta);
418 if let Some(label) = PrivacyLabel::from_kind(f.kind) {
419 *counts.entry(label).or_insert(0) += 1;
420 }
421 // 未识别 kind:risk 已累加,count 不落档(避免"未知桶"污染精确断言)
422 }
423 let has_secret = counts.contains_key(&PrivacyLabel::Secret);
424 RiskSignals {
425 counts_by_label: counts,
426 total_risk_delta: total,
427 has_secret,
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use crate::engine::{EngineError, RedactionEngine};
435 use crate::merge::{Finding, FindingSource};
436 use std::sync::Mutex;
437
438 // ─── v0.9 Sprint 1 P1.2 — lang transport 守门 ───
439
440 /// 捕获 caller 传入的 lang 参数,验证 scan_text_with_engine_with_lang 真把
441 /// lang 透传到 engine.infer_with_lang,且 legacy scan_text_with_engine 调用
442 /// engine.infer(lang None 等价路径)。
443 struct LangCapturingMockEngine {
444 captured: Mutex<Vec<Option<String>>>,
445 }
446
447 impl LangCapturingMockEngine {
448 fn new() -> Self {
449 Self {
450 captured: Mutex::new(Vec::new()),
451 }
452 }
453
454 fn captured(&self) -> Vec<Option<String>> {
455 self.captured.lock().unwrap().clone()
456 }
457 }
458
459 impl RedactionEngine for LangCapturingMockEngine {
460 fn infer(&self, _text: &str) -> Result<Vec<Finding>, EngineError> {
461 // legacy 路径:走 default(等价 infer_with_lang(text, None))
462 self.captured.lock().unwrap().push(None);
463 Ok(Vec::new())
464 }
465
466 fn infer_with_lang(
467 &self,
468 text: &str,
469 lang: Option<&str>,
470 ) -> Result<Vec<Finding>, EngineError> {
471 // P1.2 路径:override 捕获 lang;若 caller 传 None 走 self.infer
472 // (与 trait default 一致;但本测试 override 让两路径可分辨)
473 if lang.is_none() {
474 return self.infer(text);
475 }
476 self.captured.lock().unwrap().push(lang.map(String::from));
477 Ok(Vec::new())
478 }
479 }
480
481 /// scan_text_with_engine(legacy 入口)→ engine.infer(等价 lang None)
482 #[test]
483 fn scan_text_with_engine_calls_infer_no_lang() {
484 let engine = LangCapturingMockEngine::new();
485 let _ = scan_text_with_engine("hello world test", &engine).unwrap();
486 let captured = engine.captured();
487 assert_eq!(captured, vec![None], "legacy 路径应走 infer (lang=None)");
488 }
489
490 /// scan_text_with_engine_with_lang(text, engine, None)→ engine.infer
491 /// (lang None 等价 legacy 路径,不走 lang-conditional)
492 #[test]
493 fn scan_text_with_engine_with_lang_none_equivalent_to_legacy() {
494 let engine = LangCapturingMockEngine::new();
495 let _ = scan_text_with_engine_with_lang("hello world test", &engine, None).unwrap();
496 let captured = engine.captured();
497 assert_eq!(
498 captured,
499 vec![None],
500 "lang None 应等价 legacy 路径(走 infer)"
501 );
502 }
503
504 /// scan_text_with_engine_with_lang(text, engine, Some("de"))→ engine.infer_with_lang
505 /// 透传真实 lang 字符串
506 #[test]
507 fn scan_text_with_engine_with_lang_transports_real_lang() {
508 let engine = LangCapturingMockEngine::new();
509 let _ = scan_text_with_engine_with_lang("hello world test", &engine, Some("de")).unwrap();
510 let captured = engine.captured();
511 assert_eq!(
512 captured,
513 vec![Some("de".to_string())],
514 "lang Some 应通过 infer_with_lang 透传 caller 字符串"
515 );
516 }
517
518 /// EmptyInput fail-closed 路径在 lang-aware 入口同样成立(不应调用 engine)
519 #[test]
520 fn scan_text_with_engine_with_lang_empty_input_fail_closed() {
521 let engine = LangCapturingMockEngine::new();
522 let r = scan_text_with_engine_with_lang("", &engine, Some("de"));
523 assert!(matches!(r, Err(ScanError::EmptyInput)));
524 assert!(engine.captured().is_empty(), "空输入早返,不应调用 engine");
525 }
526
527 // ──────────────────────────── 空输入 fail-closed ────────────────────────────
528 #[test]
529 fn scan_text_empty_input_fail_closed() {
530 let r = scan_text("");
531 assert!(
532 matches!(r, Err(ScanError::EmptyInput)),
533 "空输入应返 EmptyInput,实际: {:?}",
534 r
535 );
536 }
537
538 // ──────────────────────────── Secret 类(Hard 路径)────────────────────────────
539 #[test]
540 fn scan_text_secret_variant() {
541 // 用真实 github token 形态(ghp_ + 40 chars)
542 let text = "log: token = ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ is rotated";
543 let r = scan_text(text).expect("非空应成功");
544 assert!(!r.findings.is_empty(), "应命中 github_token");
545 assert!(r.findings.iter().any(|f| f.kind == "github_token"));
546 assert!(r.risk_signals.has_secret, "counts 应含 Secret 桶");
547 assert!(
548 r.risk_signals
549 .counts_by_label
550 .get(&PrivacyLabel::Secret)
551 .copied()
552 .unwrap_or(0)
553 >= 1
554 );
555 // redacted_text 不得含原 token
556 assert!(!r
557 .redacted_text
558 .contains("ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"));
559 assert!(r.redacted_text.contains("[REDACTED secret]"));
560 }
561
562 // ──────────────────────────── Email 类 ────────────────────────────
563 // 注:HARD_RULES 不含 email(email 在 ALL_RULES 但 HARD_RULES 刻意豁免,
564 // 避免误伤合法上下文;见 lib.rs §HARD_RULES 注释)。因此 Hard 路径不产生
565 // email finding;必须走 Model 路径(直接构造)来验证 Email 桶映射。
566 // 该覆盖在 scan_text_email_via_model_mock 中完成。
567 //
568 // 但 scan_hard_findings(v0.3 公开 API)对 email 的覆盖已由现有 lib.rs 测试保障,
569 // 本层只保证:若 caller 将来把 email 塞进 Finding,映射正确。
570
571 #[test]
572 fn scan_text_email_via_model_mock() {
573 // 构造 Model 侧 Finding,验证 from_kind("private_email") 进 Email 桶
574 let model_email = Finding::model("private_email", (8, 28), 0.99, 10);
575 let merged = merge_findings(&[], &[model_email]);
576 let signals = aggregate_risk(&merged);
577 assert_eq!(
578 signals.counts_by_label.get(&PrivacyLabel::Email).copied(),
579 Some(1)
580 );
581 }
582
583 // ──────────────────────────── Url 类(internal_ipv4)────────────────────────────
584 // internal_ipv4 同样不在 HARD_RULES(见 lib.rs 注释)。走 Model 构造验证映射。
585 #[test]
586 fn scan_text_url_variant() {
587 // 直接校验 PrivacyLabel 映射契约
588 assert_eq!(
589 PrivacyLabel::from_kind("internal_ipv4"),
590 Some(PrivacyLabel::Url)
591 );
592 // 通过构造 Finding 走 aggregate_risk 路径验证
593 let ip = Finding::model("private_url", (12, 25), 0.95, 10);
594 let merged = merge_findings(&[], &[ip]);
595 let signals = aggregate_risk(&merged);
596 assert_eq!(
597 signals.counts_by_label.get(&PrivacyLabel::Url).copied(),
598 Some(1)
599 );
600 }
601
602 // ──────────────────────────── Model 专属标签映射 ────────────────────────────
603 #[test]
604 fn scan_text_person_via_model_mock() {
605 let f = Finding::model("private_person", (0, 13), 0.9, 5);
606 let merged = merge_findings(&[], &[f]);
607 assert_eq!(merged.len(), 1);
608 assert_eq!(merged[0].source, FindingSource::Model);
609 assert_eq!(
610 PrivacyLabel::from_kind(merged[0].kind),
611 Some(PrivacyLabel::Person)
612 );
613 let signals = aggregate_risk(&merged);
614 assert_eq!(
615 signals.counts_by_label.get(&PrivacyLabel::Person).copied(),
616 Some(1)
617 );
618 }
619
620 #[test]
621 fn scan_text_phone_via_model_mock() {
622 let f = Finding::model("private_phone", (5, 18), 0.88, 5);
623 let merged = merge_findings(&[], &[f]);
624 let signals = aggregate_risk(&merged);
625 assert_eq!(
626 signals.counts_by_label.get(&PrivacyLabel::Phone).copied(),
627 Some(1)
628 );
629 }
630
631 #[test]
632 fn scan_text_address_via_model_mock() {
633 let f = Finding::model("private_address", (10, 50), 0.91, 5);
634 let merged = merge_findings(&[], &[f]);
635 let signals = aggregate_risk(&merged);
636 assert_eq!(
637 signals.counts_by_label.get(&PrivacyLabel::Address).copied(),
638 Some(1)
639 );
640 }
641
642 #[test]
643 fn scan_text_date_via_model_mock() {
644 let f = Finding::model("private_date", (20, 30), 0.96, 5);
645 let merged = merge_findings(&[], &[f]);
646 let signals = aggregate_risk(&merged);
647 assert_eq!(
648 signals.counts_by_label.get(&PrivacyLabel::Date).copied(),
649 Some(1)
650 );
651 }
652
653 #[test]
654 fn scan_text_account_number_via_model_mock() {
655 let f = Finding::model("private_account_number", (0, 16), 0.97, 5);
656 let merged = merge_findings(&[], &[f]);
657 let signals = aggregate_risk(&merged);
658 assert_eq!(
659 signals
660 .counts_by_label
661 .get(&PrivacyLabel::AccountNumber)
662 .copied(),
663 Some(1)
664 );
665 }
666
667 // ──────────────────────────── 完整脱敏往返 ────────────────────────────
668 #[test]
669 fn scan_text_roundtrip_redacts_all_findings() {
670 // 两类 secret:github + anthropic
671 let token = "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ";
672 let anth = "sk-ant-api03_ABCDEFGHIJKLMNOPQRSTUVWX";
673 let text = format!("one {token} two {anth} three");
674 let r = scan_text(&text).expect("非空");
675 assert!(
676 r.findings.len() >= 2,
677 "至少 2 条 finding,实际 {}: {:?}",
678 r.findings.len(),
679 r.findings
680 );
681 // 脱敏文本完全不含原始 secret
682 assert!(
683 !r.redacted_text.contains(token),
684 "github token 原文泄漏:{}",
685 r.redacted_text
686 );
687 assert!(
688 !r.redacted_text.contains(anth),
689 "anthropic key 原文泄漏:{}",
690 r.redacted_text
691 );
692 // 替换后应含两处 [REDACTED secret]
693 let count = r.redacted_text.matches("[REDACTED secret]").count();
694 assert!(count >= 2, "应至少 2 处 [REDACTED secret],实际 {count}");
695 // has_secret 必为真
696 assert!(r.risk_signals.has_secret);
697 }
698
699 // ──────────────────────────── D4 不双倍加权回归 ────────────────────────────
700 #[test]
701 fn scan_text_risk_signals_no_double_weighting() {
702 // 复用 ISS-013 merge 决策:Hard + 同 span Model 应只计 Hard 一次
703 // 直接构造两侧 Finding 走 aggregate_risk(因为 scan_text Stage 1 model 置空)
704 let hard = vec![Finding::hard("email", (10, 30), 10)];
705 let model = vec![Finding::model("private_email", (10, 30), 1.0, 10)];
706 let merged = merge_findings(&hard, &model);
707 assert_eq!(merged.len(), 1, "同 span 重叠应只留 Hard");
708 let signals = aggregate_risk(&merged);
709 assert_eq!(
710 signals.total_risk_delta, 10,
711 "重叠时只计 Hard 一次,不应累加到 20"
712 );
713 // 对照:非重叠时两者都计
714 let model2 = vec![Finding::model("private_email", (100, 120), 1.0, 10)];
715 let merged2 = merge_findings(&hard, &model2);
716 let s2 = aggregate_risk(&merged2);
717 assert_eq!(s2.total_risk_delta, 20, "非重叠时应 Hard + Model 累加");
718 }
719
720 // ──────────────────────────── v0.3 pub API 不变回归 ────────────────────────────
721 // 证据等级:feedback_production_logic_testable —— 公共 API 必须进默认测试矩阵,
722 // 任何删除 / 重命名 / 签名漂移都应被此测试捕获。
723 #[test]
724 fn scan_text_v03_public_api_intact() {
725 // 1) redact(&Value) -> (Value, String)
726 let v = serde_json::json!({"token": "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"});
727 let (redacted, summary) = crate::redact(&v);
728 let s = serde_json::to_string(&redacted).expect("ser");
729 assert!(!s.contains("ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"));
730 // key-hint 命中,summary 应有 finding 条目
731 assert!(summary.contains("finding:"));
732
733 // 2) scrub_text(&str) -> String
734 let out = crate::scrub_text("token = ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ");
735 assert!(!out.contains("ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"));
736 assert!(out.contains("[REDACTED"));
737
738 // 3) scan_hard_findings(&str) -> Vec<&'static str>
739 let names = crate::scan_hard_findings("x = ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ");
740 assert!(names.contains(&"github_token"));
741
742 // 4) detect_hard_secret(&str) -> Option<&'static str>
743 assert_eq!(
744 crate::detect_hard_secret("x=ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"),
745 Some("github_token")
746 );
747 assert_eq!(crate::detect_hard_secret("hello"), None);
748
749 // 5) ITERATION 常量不变
750 assert_eq!(crate::ITERATION, "I01");
751 }
752
753 // ──────────────────────────── build_redacted_text 边界防御 ────────────────────────────
754 /// 手工构造越界 span 不应 panic(防御性测试)
755 #[test]
756 fn build_redacted_text_out_of_bounds_span_is_skipped() {
757 let bad_finding = Finding::hard("github_token", (100, 200), 25);
758 let out = build_redacted_text("short text", &[bad_finding]);
759 assert_eq!(out, "short text", "越界 span 应跳过,原文不变");
760 }
761
762 /// UTF-8 非 char boundary span 不应 panic
763 #[test]
764 fn build_redacted_text_non_char_boundary_is_skipped() {
765 // "你好" 在 UTF-8 下每字 3 字节;span (1, 5) 切在中间
766 let text = "你好 world";
767 let bad = Finding::model("private_person", (1, 5), 0.9, 5);
768 let out = build_redacted_text(text, &[bad]);
769 // 跳过则原文不变;不崩就算通过
770 assert_eq!(out, text);
771 }
772
773 // ──────────────────────────── risk_of 分级回归 ────────────────────────────
774 #[test]
775 fn risk_of_tiers_match_adr_0012() {
776 assert_eq!(risk_of("github_token"), 25, "Secret = 25");
777 assert_eq!(risk_of("anthropic_api_key"), 25);
778 assert_eq!(risk_of("email"), 10, "Email = 10");
779 assert_eq!(risk_of("internal_ipv4"), 10, "Url = 10");
780 assert_eq!(risk_of("private_person"), 5, "Person = 5");
781 assert_eq!(risk_of("private_date"), 5, "Date = 5");
782 // 未知 kind 保守 5
783 assert_eq!(risk_of("not_a_kind"), 5, "未知 kind 保守 5");
784 }
785
786 // ──────────────────── v0.7-α2 Phase 2D — budgeted scan + degraded fallback ────────────────────
787 // EngineError 已在测试模块顶层 import(P1.2 加,line 433)
788
789 /// 慢 engine mock:用 sleep 模拟 ORT inference 长延迟,触发 budget timeout
790 struct SleepyEngine {
791 dur: Duration,
792 }
793 impl RedactionEngine for SleepyEngine {
794 fn infer(&self, _text: &str) -> Result<Vec<Finding>, EngineError> {
795 std::thread::sleep(self.dur);
796 Ok(Vec::new())
797 }
798 }
799
800 /// 错误 engine mock:立即返 Err,触发 DegradedError 路径
801 struct ErrorEngine;
802 impl RedactionEngine for ErrorEngine {
803 fn infer(&self, _text: &str) -> Result<Vec<Finding>, EngineError> {
804 Err(EngineError::InferRun("mock failure".to_string()))
805 }
806 }
807
808 /// budget 内完成 → status = Ok;findings 含 Hard 命中(NoopEngine 模型路径返空)
809 #[test]
810 fn budgeted_scan_within_budget_returns_ok() {
811 let engine: Arc<dyn RedactionEngine> = Arc::new(NoopEngine);
812 let outcome = scan_text_with_engine_budgeted(
813 "token=ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ",
814 engine,
815 Duration::from_millis(500),
816 )
817 .expect("budgeted scan should succeed within budget");
818 assert_eq!(outcome.status, EngineStatus::Ok, "NoopEngine 立即返,应 Ok");
819 // Hard 路径 github_token 必须命中(fail-closed 守 secret 类)
820 assert!(
821 outcome
822 .result
823 .findings
824 .iter()
825 .any(|f| f.kind == "github_token"),
826 "Hard 路径应命中 github_token"
827 );
828 }
829
830 /// 模型路径超 budget → status = DegradedTimeout;Hard 路径 secret 仍命中(fail-closed)
831 #[test]
832 fn budgeted_scan_timeout_degrades_to_hardonly() {
833 let engine: Arc<dyn RedactionEngine> = Arc::new(SleepyEngine {
834 dur: Duration::from_millis(500), // engine 慢
835 });
836 let outcome = scan_text_with_engine_budgeted(
837 "token=ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ",
838 engine,
839 Duration::from_millis(50), // budget 短
840 )
841 .expect("budgeted scan should return outcome even on timeout");
842 assert_eq!(
843 outcome.status,
844 EngineStatus::DegradedTimeout,
845 "engine 500ms vs budget 50ms 应触发 timeout"
846 );
847 // 关键不变量:即使 model 超时,Hard 规则仍守 secret 类
848 assert!(
849 outcome
850 .result
851 .findings
852 .iter()
853 .any(|f| f.kind == "github_token"),
854 "DegradedTimeout 路径下 Hard secret 必须仍命中(fail-closed bottom line)"
855 );
856 }
857
858 /// 模型路径返 Err → status = DegradedError;Hard 路径 secret 仍命中
859 #[test]
860 fn budgeted_scan_engine_error_degrades_to_hardonly() {
861 let engine: Arc<dyn RedactionEngine> = Arc::new(ErrorEngine);
862 let outcome = scan_text_with_engine_budgeted(
863 "token=ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ",
864 engine,
865 Duration::from_millis(500),
866 )
867 .expect("budgeted scan should not propagate engine error");
868 assert_eq!(
869 outcome.status,
870 EngineStatus::DegradedError,
871 "engine InferRun 应触发 DegradedError"
872 );
873 // fail-closed 不变量保留
874 assert!(
875 outcome
876 .result
877 .findings
878 .iter()
879 .any(|f| f.kind == "github_token"),
880 "DegradedError 路径下 Hard secret 必须仍命中"
881 );
882 }
883
884 /// v0.7-α3 R1a — generic_url 通过 ALL_RULES → scan_text_with_engine 路径命中。
885 /// scan_text 默认走 NoopEngine 也激活此路径,公网 URL 应抓 url canonical。
886 #[test]
887 fn r1a_scan_text_generic_url_hard_match() {
888 let text = "Visit https://api.example.com/v1/users for docs.";
889 let r = scan_text(text).expect("scan ok");
890 let url_findings: Vec<_> = r
891 .findings
892 .iter()
893 .filter(|f| crate::PrivacyLabel::from_kind(f.kind) == Some(crate::PrivacyLabel::Url))
894 .collect();
895 assert!(
896 !url_findings.is_empty(),
897 "generic_url 应命中,实际 findings={:?}",
898 r.findings
899 );
900 // 应覆盖 https URL 起始(span.0 == "Visit ".len() == 6)
901 assert!(
902 url_findings.iter().any(|f| f.span.0 == 6),
903 "url span 应起 idx=6,实际: {:?}",
904 url_findings
905 );
906 }
907
908 /// internal_ipv4 仍命中(回归不破)
909 #[test]
910 fn r1a_internal_ipv4_still_matches() {
911 let text = "Server at 10.0.0.5 in cluster.";
912 let r = scan_text(text).expect("scan ok");
913 let url_findings: Vec<_> = r
914 .findings
915 .iter()
916 .filter(|f| crate::PrivacyLabel::from_kind(f.kind) == Some(crate::PrivacyLabel::Url))
917 .collect();
918 assert!(
919 !url_findings.is_empty(),
920 "internal_ipv4 应继续命中,findings={:?}",
921 r.findings
922 );
923 }
924
925 /// generic_url canonical 经 PrivacyLabel::Url 走 risk_delta=10
926 #[test]
927 fn r1a_generic_url_risk_delta_is_10() {
928 assert_eq!(
929 risk_of("generic_url"),
930 10,
931 "generic_url 应走 Url canonical risk = 10(ADR 0012 §1.3)"
932 );
933 }
934
935 /// 空输入 fail-closed(与 scan_text 同口径)
936 #[test]
937 fn budgeted_scan_empty_input_fail_closed() {
938 let engine: Arc<dyn RedactionEngine> = Arc::new(NoopEngine);
939 let r = scan_text_with_engine_budgeted("", engine, Duration::from_millis(500));
940 assert!(
941 matches!(r, Err(ScanError::EmptyInput)),
942 "空输入应返 EmptyInput,实际: {:?}",
943 r
944 );
945 }
946}