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 替换为 `[REDACTED <label>]`;替换文案走 `PrivacyLabel::as_str()`
376/// (稳定契约),未识别 kind 降级用原字面量,保证不丢信息。
377///
378/// **重叠 span 的 leak 安全性(VIGIL-SEC-OVERLAP 审计修正)**:`merge_findings` 只去掉与
379/// Hard 重叠的 Model,但 **Hard×Hard / Model×Model 内部仍会重叠**(env_assignment 套
380/// github_token;模型对相邻实体吐嵌套 person/address span)。
381///
382/// 旧实现靠"按 start **降序** + 内层先 `replace_range` + 外层 `end > out.len()` 越界跳过"
383/// 达成 leak 安全 —— 但该不变量**仅当外层独有前缀非密时成立**(env_assignment 的 `KEY=` 前缀)。
384/// 若外层独有前缀本身是 PII(模型嵌套 span:外 `[0,40]` person 套内 `[15,40]` person),内层
385/// 替换缩短串令外层 `end > len` 被跳过 → 外层前缀 `[0,15]` 明文 PII **泄漏**(标准复现:
386/// `"Jonathan Whitfield Robert Smith Junior"` → `"Jonathan Whitfi[REDACTED person]"`)。
387///
388/// 故改为与网关 [`crate::redact_string`] 同款 **并集合并**:重叠 span 先合并成互不重叠的
389/// `[min_start, max_end)` 区间,再单次替换。保证每个被任一 finding 命中的字节都落入某个被替换
390/// 区间(leak-safe),且占位符良构(无嵌套破碎)。`overlapping_hard_spans_no_leak` +
391/// `model_overlap_no_leak` 回归测试锁定该不变量。
392fn build_redacted_text(input: &str, findings: &[Finding]) -> String {
393 // 拷贝 + 排序:start 升序;同 start 时 end **降序**(长 span 作并集代表名,与网关
394 // redact_string 对齐)。不改 caller 给的 findings。
395 let mut sorted: Vec<&Finding> = findings.iter().collect();
396 sorted.sort_by(|a, b| a.span.0.cmp(&b.span.0).then(b.span.1.cmp(&a.span.1)));
397
398 // 重叠区间**并集合并**(leak-safe):代表 kind 取并集内 start 最小→span 最长者(排序已保证)。
399 // 越界 / 非 char boundary 的手工构造 Finding 在并入前剔除,避免 replace_range panic。
400 let mut merged: Vec<(usize, usize, &str)> = Vec::with_capacity(sorted.len());
401 for f in sorted {
402 let (start, end) = f.span;
403 if start > end
404 || end > input.len()
405 || !input.is_char_boundary(start)
406 || !input.is_char_boundary(end)
407 {
408 continue;
409 }
410 match merged.last_mut() {
411 // 重叠(strict-less,相邻 end==start 不算)→ 扩展并集上界;代表名保持先者
412 Some(last) if start < last.1 => {
413 if end > last.1 {
414 last.1 = end;
415 }
416 }
417 _ => merged.push((start, end, f.kind)),
418 }
419 }
420
421 // 右→左替换:merged 已 start 升序且互不重叠,从后往前替换不影响前面区间 index。
422 let mut out = input.to_string();
423 for &(start, end, kind) in merged.iter().rev() {
424 let placeholder = match PrivacyLabel::from_kind(kind) {
425 Some(label) => format!("[REDACTED {}]", label.as_str()),
426 None => format!("[REDACTED {}]", kind),
427 };
428 out.replace_range(start..end, &placeholder);
429 }
430 out
431}
432
433/// 聚合 findings 为 `RiskSignals`。
434fn aggregate_risk(findings: &[Finding]) -> RiskSignals {
435 let mut counts: BTreeMap<PrivacyLabel, u32> = BTreeMap::new();
436 let mut total: u32 = 0;
437 for f in findings {
438 total = total.saturating_add(f.risk_delta);
439 if let Some(label) = PrivacyLabel::from_kind(f.kind) {
440 *counts.entry(label).or_insert(0) += 1;
441 }
442 // 未识别 kind:risk 已累加,count 不落档(避免"未知桶"污染精确断言)
443 }
444 let has_secret = counts.contains_key(&PrivacyLabel::Secret);
445 RiskSignals {
446 counts_by_label: counts,
447 total_risk_delta: total,
448 has_secret,
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::*;
455 use crate::engine::{EngineError, RedactionEngine};
456 use crate::merge::{Finding, FindingSource};
457 use std::sync::Mutex;
458
459 // ─── v0.9 Sprint 1 P1.2 — lang transport 守门 ───
460
461 /// 捕获 caller 传入的 lang 参数,验证 scan_text_with_engine_with_lang 真把
462 /// lang 透传到 engine.infer_with_lang,且 legacy scan_text_with_engine 调用
463 /// engine.infer(lang None 等价路径)。
464 struct LangCapturingMockEngine {
465 captured: Mutex<Vec<Option<String>>>,
466 }
467
468 impl LangCapturingMockEngine {
469 fn new() -> Self {
470 Self {
471 captured: Mutex::new(Vec::new()),
472 }
473 }
474
475 fn captured(&self) -> Vec<Option<String>> {
476 self.captured.lock().unwrap().clone()
477 }
478 }
479
480 impl RedactionEngine for LangCapturingMockEngine {
481 fn infer(&self, _text: &str) -> Result<Vec<Finding>, EngineError> {
482 // legacy 路径:走 default(等价 infer_with_lang(text, None))
483 self.captured.lock().unwrap().push(None);
484 Ok(Vec::new())
485 }
486
487 fn infer_with_lang(
488 &self,
489 text: &str,
490 lang: Option<&str>,
491 ) -> Result<Vec<Finding>, EngineError> {
492 // P1.2 路径:override 捕获 lang;若 caller 传 None 走 self.infer
493 // (与 trait default 一致;但本测试 override 让两路径可分辨)
494 if lang.is_none() {
495 return self.infer(text);
496 }
497 self.captured.lock().unwrap().push(lang.map(String::from));
498 Ok(Vec::new())
499 }
500 }
501
502 /// scan_text_with_engine(legacy 入口)→ engine.infer(等价 lang None)
503 #[test]
504 fn scan_text_with_engine_calls_infer_no_lang() {
505 let engine = LangCapturingMockEngine::new();
506 let _ = scan_text_with_engine("hello world test", &engine).unwrap();
507 let captured = engine.captured();
508 assert_eq!(captured, vec![None], "legacy 路径应走 infer (lang=None)");
509 }
510
511 /// scan_text_with_engine_with_lang(text, engine, None)→ engine.infer
512 /// (lang None 等价 legacy 路径,不走 lang-conditional)
513 #[test]
514 fn scan_text_with_engine_with_lang_none_equivalent_to_legacy() {
515 let engine = LangCapturingMockEngine::new();
516 let _ = scan_text_with_engine_with_lang("hello world test", &engine, None).unwrap();
517 let captured = engine.captured();
518 assert_eq!(
519 captured,
520 vec![None],
521 "lang None 应等价 legacy 路径(走 infer)"
522 );
523 }
524
525 /// scan_text_with_engine_with_lang(text, engine, Some("de"))→ engine.infer_with_lang
526 /// 透传真实 lang 字符串
527 #[test]
528 fn scan_text_with_engine_with_lang_transports_real_lang() {
529 let engine = LangCapturingMockEngine::new();
530 let _ = scan_text_with_engine_with_lang("hello world test", &engine, Some("de")).unwrap();
531 let captured = engine.captured();
532 assert_eq!(
533 captured,
534 vec![Some("de".to_string())],
535 "lang Some 应通过 infer_with_lang 透传 caller 字符串"
536 );
537 }
538
539 /// EmptyInput fail-closed 路径在 lang-aware 入口同样成立(不应调用 engine)
540 #[test]
541 fn scan_text_with_engine_with_lang_empty_input_fail_closed() {
542 let engine = LangCapturingMockEngine::new();
543 let r = scan_text_with_engine_with_lang("", &engine, Some("de"));
544 assert!(matches!(r, Err(ScanError::EmptyInput)));
545 assert!(engine.captured().is_empty(), "空输入早返,不应调用 engine");
546 }
547
548 // ──────────────────────────── 空输入 fail-closed ────────────────────────────
549 #[test]
550 fn scan_text_empty_input_fail_closed() {
551 let r = scan_text("");
552 assert!(
553 matches!(r, Err(ScanError::EmptyInput)),
554 "空输入应返 EmptyInput,实际: {:?}",
555 r
556 );
557 }
558
559 // ──────────────────────────── Secret 类(Hard 路径)────────────────────────────
560 #[test]
561 fn scan_text_secret_variant() {
562 // 用真实 github token 形态(ghp_ + 40 chars)
563 let text = "log: token = ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ is rotated";
564 let r = scan_text(text).expect("非空应成功");
565 assert!(!r.findings.is_empty(), "应命中 github_token");
566 assert!(r.findings.iter().any(|f| f.kind == "github_token"));
567 assert!(r.risk_signals.has_secret, "counts 应含 Secret 桶");
568 assert!(
569 r.risk_signals
570 .counts_by_label
571 .get(&PrivacyLabel::Secret)
572 .copied()
573 .unwrap_or(0)
574 >= 1
575 );
576 // redacted_text 不得含原 token
577 assert!(!r
578 .redacted_text
579 .contains("ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"));
580 assert!(r.redacted_text.contains("[REDACTED secret]"));
581 }
582
583 // ──────────────────────────── Email 类 ────────────────────────────
584 // 注:HARD_RULES 不含 email(email 在 ALL_RULES 但 HARD_RULES 刻意豁免,
585 // 避免误伤合法上下文;见 lib.rs §HARD_RULES 注释)。因此 Hard 路径不产生
586 // email finding;必须走 Model 路径(直接构造)来验证 Email 桶映射。
587 // 该覆盖在 scan_text_email_via_model_mock 中完成。
588 //
589 // 但 scan_hard_findings(v0.3 公开 API)对 email 的覆盖已由现有 lib.rs 测试保障,
590 // 本层只保证:若 caller 将来把 email 塞进 Finding,映射正确。
591
592 #[test]
593 fn scan_text_email_via_model_mock() {
594 // 构造 Model 侧 Finding,验证 from_kind("private_email") 进 Email 桶
595 let model_email = Finding::model("private_email", (8, 28), 0.99, 10);
596 let merged = merge_findings(&[], &[model_email]);
597 let signals = aggregate_risk(&merged);
598 assert_eq!(
599 signals.counts_by_label.get(&PrivacyLabel::Email).copied(),
600 Some(1)
601 );
602 }
603
604 // ──────────────────────────── Url 类(internal_ipv4)────────────────────────────
605 // internal_ipv4 同样不在 HARD_RULES(见 lib.rs 注释)。走 Model 构造验证映射。
606 #[test]
607 fn scan_text_url_variant() {
608 // 直接校验 PrivacyLabel 映射契约
609 assert_eq!(
610 PrivacyLabel::from_kind("internal_ipv4"),
611 Some(PrivacyLabel::Url)
612 );
613 // 通过构造 Finding 走 aggregate_risk 路径验证
614 let ip = Finding::model("private_url", (12, 25), 0.95, 10);
615 let merged = merge_findings(&[], &[ip]);
616 let signals = aggregate_risk(&merged);
617 assert_eq!(
618 signals.counts_by_label.get(&PrivacyLabel::Url).copied(),
619 Some(1)
620 );
621 }
622
623 // ──────────────────────────── Model 专属标签映射 ────────────────────────────
624 #[test]
625 fn scan_text_person_via_model_mock() {
626 let f = Finding::model("private_person", (0, 13), 0.9, 5);
627 let merged = merge_findings(&[], &[f]);
628 assert_eq!(merged.len(), 1);
629 assert_eq!(merged[0].source, FindingSource::Model);
630 assert_eq!(
631 PrivacyLabel::from_kind(merged[0].kind),
632 Some(PrivacyLabel::Person)
633 );
634 let signals = aggregate_risk(&merged);
635 assert_eq!(
636 signals.counts_by_label.get(&PrivacyLabel::Person).copied(),
637 Some(1)
638 );
639 }
640
641 #[test]
642 fn scan_text_phone_via_model_mock() {
643 let f = Finding::model("private_phone", (5, 18), 0.88, 5);
644 let merged = merge_findings(&[], &[f]);
645 let signals = aggregate_risk(&merged);
646 assert_eq!(
647 signals.counts_by_label.get(&PrivacyLabel::Phone).copied(),
648 Some(1)
649 );
650 }
651
652 #[test]
653 fn scan_text_address_via_model_mock() {
654 let f = Finding::model("private_address", (10, 50), 0.91, 5);
655 let merged = merge_findings(&[], &[f]);
656 let signals = aggregate_risk(&merged);
657 assert_eq!(
658 signals.counts_by_label.get(&PrivacyLabel::Address).copied(),
659 Some(1)
660 );
661 }
662
663 #[test]
664 fn scan_text_date_via_model_mock() {
665 let f = Finding::model("private_date", (20, 30), 0.96, 5);
666 let merged = merge_findings(&[], &[f]);
667 let signals = aggregate_risk(&merged);
668 assert_eq!(
669 signals.counts_by_label.get(&PrivacyLabel::Date).copied(),
670 Some(1)
671 );
672 }
673
674 #[test]
675 fn scan_text_account_number_via_model_mock() {
676 let f = Finding::model("private_account_number", (0, 16), 0.97, 5);
677 let merged = merge_findings(&[], &[f]);
678 let signals = aggregate_risk(&merged);
679 assert_eq!(
680 signals
681 .counts_by_label
682 .get(&PrivacyLabel::AccountNumber)
683 .copied(),
684 Some(1)
685 );
686 }
687
688 // ──────────────────────────── 完整脱敏往返 ────────────────────────────
689 #[test]
690 fn scan_text_roundtrip_redacts_all_findings() {
691 // 两类 secret:github + anthropic
692 let token = "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ";
693 let anth = "sk-ant-api03_ABCDEFGHIJKLMNOPQRSTUVWX";
694 let text = format!("one {token} two {anth} three");
695 let r = scan_text(&text).expect("非空");
696 assert!(
697 r.findings.len() >= 2,
698 "至少 2 条 finding,实际 {}: {:?}",
699 r.findings.len(),
700 r.findings
701 );
702 // 脱敏文本完全不含原始 secret
703 assert!(
704 !r.redacted_text.contains(token),
705 "github token 原文泄漏:{}",
706 r.redacted_text
707 );
708 assert!(
709 !r.redacted_text.contains(anth),
710 "anthropic key 原文泄漏:{}",
711 r.redacted_text
712 );
713 // 替换后应含两处 [REDACTED secret]
714 let count = r.redacted_text.matches("[REDACTED secret]").count();
715 assert!(count >= 2, "应至少 2 处 [REDACTED secret],实际 {count}");
716 // has_secret 必为真
717 assert!(r.risk_signals.has_secret);
718 }
719
720 // ──────────────────────────── D4 不双倍加权回归 ────────────────────────────
721 #[test]
722 fn scan_text_risk_signals_no_double_weighting() {
723 // 复用 ISS-013 merge 决策:Hard + 同 span Model 应只计 Hard 一次
724 // 直接构造两侧 Finding 走 aggregate_risk(因为 scan_text Stage 1 model 置空)
725 let hard = vec![Finding::hard("email", (10, 30), 10)];
726 let model = vec![Finding::model("private_email", (10, 30), 1.0, 10)];
727 let merged = merge_findings(&hard, &model);
728 assert_eq!(merged.len(), 1, "同 span 重叠应只留 Hard");
729 let signals = aggregate_risk(&merged);
730 assert_eq!(
731 signals.total_risk_delta, 10,
732 "重叠时只计 Hard 一次,不应累加到 20"
733 );
734 // 对照:非重叠时两者都计
735 let model2 = vec![Finding::model("private_email", (100, 120), 1.0, 10)];
736 let merged2 = merge_findings(&hard, &model2);
737 let s2 = aggregate_risk(&merged2);
738 assert_eq!(s2.total_risk_delta, 20, "非重叠时应 Hard + Model 累加");
739 }
740
741 // ──────────────── D16 审计:重叠 Hard span 的 leak 安全性回归 ────────────────
742 /// `env_assignment` 包裹内层硬 secret(`KEY=ghp_...`)会让两条 Hard 规则同位重叠。
743 /// 锁定 `build_redacted_text` 在此重叠下:① 绝不泄漏内层硬 secret;② 占位符良构
744 /// (无 D16 那种破碎 `] github_token]`)。本路径靠"内层(start 更大)先 replace_range +
745 /// 外层越界跳过"达成 leak 安全,而非并集合并 —— 此测试防未来规则改动破坏该不变量。
746 #[test]
747 fn overlapping_hard_spans_no_leak() {
748 let gh = "ghp_aBcD1234567890aBcD1234567890aBcD1234"; // 40 字符硬指纹(非真实 secret)
749 for input in [
750 format!("api_token={gh}"), // env_assignment 与 github 共终点
751 format!("token={gh}.suffixdata"), // github 内层结束于 env 之前(非共终点)
752 format!("a={gh} b={gh}"), // 两段独立重叠组
753 ] {
754 let r = scan_text(&input).unwrap();
755 assert!(
756 !r.redacted_text.contains(gh),
757 "内层硬 secret 泄漏:in={input:?} out={:?}",
758 r.redacted_text
759 );
760 // 占位符良构:`[REDACTED` 与 `]` 数量相等(D16 破碎占位符会不等)
761 let opens = r.redacted_text.matches("[REDACTED").count();
762 let closes = r.redacted_text.matches(']').count();
763 assert_eq!(
764 opens, closes,
765 "占位符不配对(破碎):in={input:?} out={:?}",
766 r.redacted_text
767 );
768 assert!(
769 opens >= 1,
770 "应至少一处脱敏:in={input:?} out={:?}",
771 r.redacted_text
772 );
773 }
774 }
775
776 // ──────────────────────────── v0.3 pub API 不变回归 ────────────────────────────
777 // 证据等级:feedback_production_logic_testable —— 公共 API 必须进默认测试矩阵,
778 // 任何删除 / 重命名 / 签名漂移都应被此测试捕获。
779 #[test]
780 fn scan_text_v03_public_api_intact() {
781 // 1) redact(&Value) -> (Value, String)
782 let v = serde_json::json!({"token": "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"});
783 let (redacted, summary) = crate::redact(&v);
784 let s = serde_json::to_string(&redacted).expect("ser");
785 assert!(!s.contains("ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"));
786 // key-hint 命中,summary 应有 finding 条目
787 assert!(summary.contains("finding:"));
788
789 // 2) scrub_text(&str) -> String
790 let out = crate::scrub_text("token = ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ");
791 assert!(!out.contains("ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"));
792 assert!(out.contains("[REDACTED"));
793
794 // 3) scan_hard_findings(&str) -> Vec<&'static str>
795 let names = crate::scan_hard_findings("x = ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ");
796 assert!(names.contains(&"github_token"));
797
798 // 4) detect_hard_secret(&str) -> Option<&'static str>
799 assert_eq!(
800 crate::detect_hard_secret("x=ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"),
801 Some("github_token")
802 );
803 assert_eq!(crate::detect_hard_secret("hello"), None);
804
805 // 5) ITERATION 常量不变
806 assert_eq!(crate::ITERATION, "I01");
807 }
808
809 // ──────────────────────────── build_redacted_text 边界防御 ────────────────────────────
810 /// 手工构造越界 span 不应 panic(防御性测试)
811 #[test]
812 fn build_redacted_text_out_of_bounds_span_is_skipped() {
813 let bad_finding = Finding::hard("github_token", (100, 200), 25);
814 let out = build_redacted_text("short text", &[bad_finding]);
815 assert_eq!(out, "short text", "越界 span 应跳过,原文不变");
816 }
817
818 /// UTF-8 非 char boundary span 不应 panic
819 #[test]
820 fn build_redacted_text_non_char_boundary_is_skipped() {
821 // "你好" 在 UTF-8 下每字 3 字节;span (1, 5) 切在中间
822 let text = "你好 world";
823 let bad = Finding::model("private_person", (1, 5), 0.9, 5);
824 let out = build_redacted_text(text, &[bad]);
825 // 跳过则原文不变;不崩就算通过
826 assert_eq!(out, text);
827 }
828
829 /// VIGIL-SEC-OVERLAP 回归:**模型嵌套 / 跨类重叠 span** 必须 leak-safe + 占位符良构。
830 /// 旧"右→左 + 越界跳过"实现下,内层(start 更大)替换缩短串令外层 `end > len` 被跳过,
831 /// 外层独有的 PII 前缀明文泄漏。union-merge 修复后并集全覆盖,无泄漏、无破碎占位符。
832 #[test]
833 fn model_overlap_no_leak() {
834 // ① 嵌套同类:外 person [0,40] 套内 person [15,40](内层长→缩串→旧实现跳外层)。
835 // 旧输出 "Jonathan Whitfi[REDACTED person]"(前缀泄漏);修复后并集 [0,40) 全脱敏。
836 let input = "Jonathan Whitfield Robert Smith Junior!!";
837 let findings = vec![
838 Finding::model("private_person", (0, 40), 0.9, 5),
839 Finding::model("private_person", (15, 40), 0.9, 5),
840 ];
841 let out = build_redacted_text(input, &findings);
842 assert!(!out.contains("Jonathan"), "外层 PII 前缀泄漏:{out:?}");
843 assert!(!out.contains("Whitfi"), "外层 PII 前缀泄漏:{out:?}");
844 assert_eq!(out, "[REDACTED person]", "嵌套同类应并集为单一占位符");
845
846 // ② 跨类部分重叠:address [0,47] 与 email [41,54] 交叠(a<c<b<d)。
847 // 旧实现产 "[REDACTED address]TED email]"(破碎);修复后并集 [0,54) 良构。
848 let input2 = "742 Evergreen Terrace Springfield, email jw@clinic.org";
849 let findings2 = vec![
850 Finding::model("private_address", (0, 47), 0.9, 5),
851 Finding::model("private_email", (41, 54), 0.9, 5),
852 ];
853 let out2 = build_redacted_text(input2, &findings2);
854 assert!(!out2.contains("Evergreen"), "address 泄漏:{out2:?}");
855 assert!(!out2.contains("jw@clinic.org"), "email 泄漏:{out2:?}");
856 assert_eq!(
857 out2.matches("[REDACTED").count(),
858 out2.matches(']').count(),
859 "占位符破碎(开闭不配对):{out2:?}"
860 );
861
862 // ③ 非重叠 span 仍各自独立占位符(防 union-merge 过度合并回归)。
863 let input3 = "Bob from Berlin";
864 let findings3 = vec![
865 Finding::model("private_person", (0, 3), 0.9, 5),
866 Finding::model("private_address", (9, 15), 0.9, 5),
867 ];
868 let out3 = build_redacted_text(input3, &findings3);
869 assert_eq!(
870 out3.matches("[REDACTED").count(),
871 2,
872 "非重叠应保两占位符:{out3:?}"
873 );
874 }
875
876 // ──────────────────────────── risk_of 分级回归 ────────────────────────────
877 #[test]
878 fn risk_of_tiers_match_adr_0012() {
879 assert_eq!(risk_of("github_token"), 25, "Secret = 25");
880 assert_eq!(risk_of("anthropic_api_key"), 25);
881 assert_eq!(risk_of("email"), 10, "Email = 10");
882 assert_eq!(risk_of("internal_ipv4"), 10, "Url = 10");
883 assert_eq!(risk_of("private_person"), 5, "Person = 5");
884 assert_eq!(risk_of("private_date"), 5, "Date = 5");
885 // 未知 kind 保守 5
886 assert_eq!(risk_of("not_a_kind"), 5, "未知 kind 保守 5");
887 }
888
889 // ──────────────────── v0.7-α2 Phase 2D — budgeted scan + degraded fallback ────────────────────
890 // EngineError 已在测试模块顶层 import(P1.2 加,line 433)
891
892 /// 慢 engine mock:用 sleep 模拟 ORT inference 长延迟,触发 budget timeout
893 struct SleepyEngine {
894 dur: Duration,
895 }
896 impl RedactionEngine for SleepyEngine {
897 fn infer(&self, _text: &str) -> Result<Vec<Finding>, EngineError> {
898 std::thread::sleep(self.dur);
899 Ok(Vec::new())
900 }
901 }
902
903 /// 错误 engine mock:立即返 Err,触发 DegradedError 路径
904 struct ErrorEngine;
905 impl RedactionEngine for ErrorEngine {
906 fn infer(&self, _text: &str) -> Result<Vec<Finding>, EngineError> {
907 Err(EngineError::InferRun("mock failure".to_string()))
908 }
909 }
910
911 /// budget 内完成 → status = Ok;findings 含 Hard 命中(NoopEngine 模型路径返空)
912 #[test]
913 fn budgeted_scan_within_budget_returns_ok() {
914 let engine: Arc<dyn RedactionEngine> = Arc::new(NoopEngine);
915 let outcome = scan_text_with_engine_budgeted(
916 "token=ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ",
917 engine,
918 Duration::from_millis(500),
919 )
920 .expect("budgeted scan should succeed within budget");
921 assert_eq!(outcome.status, EngineStatus::Ok, "NoopEngine 立即返,应 Ok");
922 // Hard 路径 github_token 必须命中(fail-closed 守 secret 类)
923 assert!(
924 outcome
925 .result
926 .findings
927 .iter()
928 .any(|f| f.kind == "github_token"),
929 "Hard 路径应命中 github_token"
930 );
931 }
932
933 /// 模型路径超 budget → status = DegradedTimeout;Hard 路径 secret 仍命中(fail-closed)
934 #[test]
935 fn budgeted_scan_timeout_degrades_to_hardonly() {
936 let engine: Arc<dyn RedactionEngine> = Arc::new(SleepyEngine {
937 dur: Duration::from_millis(500), // engine 慢
938 });
939 let outcome = scan_text_with_engine_budgeted(
940 "token=ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ",
941 engine,
942 Duration::from_millis(50), // budget 短
943 )
944 .expect("budgeted scan should return outcome even on timeout");
945 assert_eq!(
946 outcome.status,
947 EngineStatus::DegradedTimeout,
948 "engine 500ms vs budget 50ms 应触发 timeout"
949 );
950 // 关键不变量:即使 model 超时,Hard 规则仍守 secret 类
951 assert!(
952 outcome
953 .result
954 .findings
955 .iter()
956 .any(|f| f.kind == "github_token"),
957 "DegradedTimeout 路径下 Hard secret 必须仍命中(fail-closed bottom line)"
958 );
959 }
960
961 /// 模型路径返 Err → status = DegradedError;Hard 路径 secret 仍命中
962 #[test]
963 fn budgeted_scan_engine_error_degrades_to_hardonly() {
964 let engine: Arc<dyn RedactionEngine> = Arc::new(ErrorEngine);
965 let outcome = scan_text_with_engine_budgeted(
966 "token=ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ",
967 engine,
968 Duration::from_millis(500),
969 )
970 .expect("budgeted scan should not propagate engine error");
971 assert_eq!(
972 outcome.status,
973 EngineStatus::DegradedError,
974 "engine InferRun 应触发 DegradedError"
975 );
976 // fail-closed 不变量保留
977 assert!(
978 outcome
979 .result
980 .findings
981 .iter()
982 .any(|f| f.kind == "github_token"),
983 "DegradedError 路径下 Hard secret 必须仍命中"
984 );
985 }
986
987 /// v0.7-α3 R1a — generic_url 通过 ALL_RULES → scan_text_with_engine 路径命中。
988 /// scan_text 默认走 NoopEngine 也激活此路径,公网 URL 应抓 url canonical。
989 #[test]
990 fn r1a_scan_text_generic_url_hard_match() {
991 let text = "Visit https://api.example.com/v1/users for docs.";
992 let r = scan_text(text).expect("scan ok");
993 let url_findings: Vec<_> = r
994 .findings
995 .iter()
996 .filter(|f| crate::PrivacyLabel::from_kind(f.kind) == Some(crate::PrivacyLabel::Url))
997 .collect();
998 assert!(
999 !url_findings.is_empty(),
1000 "generic_url 应命中,实际 findings={:?}",
1001 r.findings
1002 );
1003 // 应覆盖 https URL 起始(span.0 == "Visit ".len() == 6)
1004 assert!(
1005 url_findings.iter().any(|f| f.span.0 == 6),
1006 "url span 应起 idx=6,实际: {:?}",
1007 url_findings
1008 );
1009 }
1010
1011 /// internal_ipv4 仍命中(回归不破)
1012 #[test]
1013 fn r1a_internal_ipv4_still_matches() {
1014 let text = "Server at 10.0.0.5 in cluster.";
1015 let r = scan_text(text).expect("scan ok");
1016 let url_findings: Vec<_> = r
1017 .findings
1018 .iter()
1019 .filter(|f| crate::PrivacyLabel::from_kind(f.kind) == Some(crate::PrivacyLabel::Url))
1020 .collect();
1021 assert!(
1022 !url_findings.is_empty(),
1023 "internal_ipv4 应继续命中,findings={:?}",
1024 r.findings
1025 );
1026 }
1027
1028 /// generic_url canonical 经 PrivacyLabel::Url 走 risk_delta=10
1029 #[test]
1030 fn r1a_generic_url_risk_delta_is_10() {
1031 assert_eq!(
1032 risk_of("generic_url"),
1033 10,
1034 "generic_url 应走 Url canonical risk = 10(ADR 0012 §1.3)"
1035 );
1036 }
1037
1038 /// 空输入 fail-closed(与 scan_text 同口径)
1039 #[test]
1040 fn budgeted_scan_empty_input_fail_closed() {
1041 let engine: Arc<dyn RedactionEngine> = Arc::new(NoopEngine);
1042 let r = scan_text_with_engine_budgeted("", engine, Duration::from_millis(500));
1043 assert!(
1044 matches!(r, Err(ScanError::EmptyInput)),
1045 "空输入应返 EmptyInput,实际: {:?}",
1046 r
1047 );
1048 }
1049}