vigil_firewall/preflight.rs
1//! ISS-010 — Firewall preflight:扫描 tool args 长文本 → T0 PII findings → 喂 PolicyEngine
2//! + 落 SQLite 审计表(ISS-011 CRUD)。
3//!
4//! 设计原则(与任务 prompt 对齐):
5//! 1. **fail-closed**:`PiiScanner::scan` 返 Err(除 `EmptyInput` 外)→ 上层转
6//! `FirewallError::PreflightScanFailed`;caller(`Firewall::evaluate`)视为 Deny 并抛错。
7//! 2. **只加风险,不单独放行**:本模块只产出 `Vec<PiiFindingSummary>` + risk delta,规则
8//! 引擎最终裁决不变(Allow/Deny/Approve 仍由 PolicyEngine 决定)。
9//! 3. **审计缺失不拦业务**:`persist_scan_to_ledger` 失败只原子计数(见
10//! `Firewall::audit_persist_failures`),**不** stderr 污染、**不**拦决策。
11//! 4. **零破坏 v0.3 redaction / audit / policy**:本模块仅消费其 pub API。
12//! 5. **可测性(R2 BLOCKER 2 修复)**:scanner 走 [`PiiScanner`] trait,测试可注入
13//! 测试里本地实现 [`PiiScanner`] 真触发 fail-closed 路径,不再靠"variant 存在"伪守门。
14//!
15//! 调用位置见 `engine.rs::Firewall::evaluate` 的 "3b) preflight" 段。
16
17use std::collections::BTreeMap;
18use std::sync::atomic::{AtomicU64, Ordering};
19use std::sync::Arc;
20
21use sha2::{Digest, Sha256};
22use vigil_audit::{Ledger, NewRedactionFinding, NewRedactionScan};
23use vigil_policy::PiiFindingSummary;
24use vigil_redaction::{PrivacyLabel, RedactionResult, ScanError};
25
26/// v0.8 Sprint 1 A2 — PiiScanner 层引擎状态汇报。
27///
28/// **设计目标**:让 `Firewall::evaluate` 能感知 scanner 内部退化路径(timeout /
29/// runtime error)→ 落审计 `engine_degraded` 事件 + decision_reasons 加 stable code,
30/// 不依赖各 scanner 实现具备 budget 能力。
31///
32/// **与 [`vigil_redaction::EngineStatus`] 的区别**:redaction 层只表达"模型路径运行
33/// 结果"(Ok/DegradedTimeout/DegradedError);本 enum 在 firewall caller 视角额外
34/// 引入 `Unsupported`,用于标记 scanner 实现未 override [`PiiScanner::scan_with_status`]
35/// (Codex § 2 改进版 A:**default 返 Unsupported,不返假安全 Ok**,caller 必须
36/// 显式判此情况)。
37///
38/// **R1 NICE(Codex 019deb53)— SemVer**:`#[non_exhaustive]` 强制 caller 用
39/// 模式匹配时写 `_` 通配,允许未来加 variant(如 `DegradedOom` / `DegradedPanic`)
40/// 不破 SemVer。SDK 暴露(vigil-sdk re-export)的契约文档(docs/sdk-shallow-api.md
41/// §4.2)已声明 non_exhaustive,本 enum 实际加 attribute 让契约和实现一致。
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43#[non_exhaustive]
44pub enum EngineStatusReport {
45 /// 模型路径正常完成(scanner override 返此值表示真"Ok")。
46 Ok,
47 /// 模型路径在 budget 内未完成,已退化为 Hard-only。caller 应落
48 /// `engine_degraded` 审计 + decision_reasons.push("engine.status=degraded_timeout")。
49 DegradedTimeout,
50 /// 模型 panic / runtime error,已退化为 Hard-only。caller 应落
51 /// `engine_degraded` 审计 + decision_reasons.push("engine.status=degraded_error")。
52 DegradedError,
53 /// scanner 实现未 override [`PiiScanner::scan_with_status`] —— caller **必须**
54 /// 显式判此情况,**不能**当 Ok 处理。Codex § 2 改进版 A:default 返此值,
55 /// 强制 caller 在编译期之外的 runtime path 走显式分支(无 status 不可隐式假定)。
56 Unsupported,
57}
58
59impl EngineStatusReport {
60 /// 落审计 / decision_reasons 用稳定字面量。**不**包含 PII;**不**靠 Debug 格式化。
61 /// `Ok` / `Unsupported` 不预期被 caller 写入 reasons(只有退化路径需);返字面量便于
62 /// 测试断言但 caller 不应对此分支落审计。
63 pub fn stable_code(&self) -> &'static str {
64 // crate 内部穷举所有 variant;加新 variant 时 compiler force update,作者
65 // 在新增 variant 时**必须**显式选稳定 code 字面量(进 audit ledger 不破)。
66 // 外部 SDK consumer 因 #[non_exhaustive] 必须写 `_` 兜底。
67 match self {
68 EngineStatusReport::Ok => "ok",
69 EngineStatusReport::DegradedTimeout => "degraded_timeout",
70 EngineStatusReport::DegradedError => "degraded_error",
71 EngineStatusReport::Unsupported => "unsupported",
72 }
73 }
74}
75
76impl From<vigil_redaction::EngineStatus> for EngineStatusReport {
77 fn from(s: vigil_redaction::EngineStatus) -> Self {
78 match s {
79 vigil_redaction::EngineStatus::Ok => Self::Ok,
80 vigil_redaction::EngineStatus::DegradedTimeout => Self::DegradedTimeout,
81 vigil_redaction::EngineStatus::DegradedError => Self::DegradedError,
82 }
83 }
84}
85
86/// **R2 BLOCKER 2 修复** —— 把 PII 扫描抽象为 trait,让测试能注入 failing scanner
87/// 真触发 fail-closed 路径。默认实现 [`DefaultScanner`] 直接 forward 到
88/// [`vigil_redaction::scan_text`]。
89///
90/// 生产 caller 不需要感知此 trait —— `Firewall::new` 默认用 `DefaultScanner`。
91/// 测试可通过 `Firewall::with_scanner` 注入自定义实现。
92pub trait PiiScanner: Send + Sync + 'static {
93 /// 扫一段文本,产 `RedactionResult`。契约与 `vigil_redaction::scan_text` 完全一致:
94 /// - 空输入返 `Err(ScanError::EmptyInput)`(caller 视为 continue)
95 /// - 推理失败返 `Err(ScanError::InferenceFailed { .. })`(caller 视为 fail-closed Deny)
96 fn scan(&self, text: &str) -> Result<RedactionResult, ScanError>;
97
98 /// v0.8 Sprint 1 A2 — 扫文本并汇报 scanner 内部状态。
99 ///
100 /// **default 返 [`EngineStatusReport::Unsupported`]**(Codex § 2 改进版 A 关键改进):
101 /// - 未 override 此方法的实现:`(scan(), Unsupported)`
102 /// - caller(`Firewall::evaluate`)必须显式 match Unsupported,**不能**当 Ok 处理
103 /// - 这避免了"trait default 返 Ok 让所有 scanner 默认伪报 Ok"的假安全
104 ///
105 /// **override 时机**:scanner 内部具备 budget / 退化路径(如
106 /// [`BudgetedOrtPiiScanner`])时 override,返 `(result, status_from_inner)`。
107 /// 简单 scanner(`DefaultScanner` / 不带 budget 的 [`OrtPiiScanner`])保持 default。
108 ///
109 /// 错误语义同 [`scan`](Self::scan):空输入 EmptyInput / 推理失败 InferenceFailed。
110 fn scan_with_status(
111 &self,
112 text: &str,
113 ) -> Result<(RedactionResult, EngineStatusReport), ScanError> {
114 self.scan(text)
115 .map(|r| (r, EngineStatusReport::Unsupported))
116 }
117}
118
119/// 生产默认 scanner:直接走 `vigil_redaction::scan_text`。
120///
121/// **crate-internal only**(R2 NICE):不在 lib.rs pub re-export,生产 caller 不需
122/// 直接构造 —— `Firewall::new` 内部通过 [`default_scanner_arc`] 选择。
123#[derive(Debug, Default, Clone, Copy)]
124pub(crate) struct DefaultScanner;
125
126impl PiiScanner for DefaultScanner {
127 fn scan(&self, text: &str) -> Result<RedactionResult, ScanError> {
128 vigil_redaction::scan_text(text)
129 }
130}
131
132/// 提取 `args` 中所有长度 ≥ `threshold`(字节)的 UTF-8 字符串字段,平铺返回 owned 副本。
133///
134/// **策略**:递归走 `serde_json::Value`。数组/对象一概下钻;遇到 `String` 按 `len()`
135/// (byte,非 char)判定;Null/Bool/Number 跳过。**不保证顺序**与输入完全一致,但同输入
136/// 同顺序(递归是深度优先;对象按 serde_json 的 key 迭代器顺序,insertion-order 保留)。
137///
138/// 返回 `Vec<String>`:每个元素是一段候选 preflight 扫描文本。空列表表示"没有长文本字段"。
139pub(crate) fn extract_long_text_fields(args: &serde_json::Value, threshold: usize) -> Vec<String> {
140 let mut out: Vec<String> = Vec::new();
141 walk(args, threshold, &mut out);
142 out
143}
144
145fn walk(v: &serde_json::Value, threshold: usize, out: &mut Vec<String>) {
146 match v {
147 // v0.13 clippy 1.95 `collapsible_match`:if-guard 合并到 match arm
148 serde_json::Value::String(s) if s.len() >= threshold => {
149 out.push(s.clone());
150 }
151 serde_json::Value::Array(xs) => {
152 for x in xs {
153 walk(x, threshold, out);
154 }
155 }
156 serde_json::Value::Object(m) => {
157 for (_k, vv) in m.iter() {
158 walk(vv, threshold, out);
159 }
160 }
161 _ => {}
162 }
163}
164
165/// 取单次 scan 对 firewall risk_score 的 delta。
166///
167/// 直接透传 `RiskSignals.total_risk_delta`(ISS-005 `aggregate_risk` 已按 ADR 0012 §1.3
168/// 分级累加:Secret 25 / Email|Url 10 / 其他 PII 5)。**不**在本层重算分级,避免规则漂移。
169pub(crate) fn compute_pii_risk_delta(result: &RedactionResult) -> u32 {
170 result.risk_signals.total_risk_delta
171}
172
173/// 把一次 preflight scan 的 `RedactionResult` 落到 SQLite 审计两表。
174///
175/// 纪律:
176/// - scan 级 fingerprint = `sha256(text)[..16]` hex-lower;
177/// - finding 级 fingerprint = `sha256(原 span 切片)[..16]` hex-lower;
178/// - 未被 `PrivacyLabel::from_kind` 识别的 kind **跳过**(不落未知 label,保守);
179/// - UTF-8 非 char boundary 或越界 span 也跳过(防御性,理论上 regex 命中不会越界)。
180///
181/// **错误语义**:任何 audit 写失败都返 `Err`,但 caller(`Firewall::evaluate`)选择
182/// 降级处理(`let _ =` 忽略)—— 审计缺失不应阻断业务决策。Scan 失败是另一条路径,
183/// 走 `FirewallError::PreflightScanFailed`。
184pub(crate) fn persist_scan_to_ledger(
185 ledger: &Ledger,
186 session_id: &str,
187 text: &str,
188 result: &RedactionResult,
189) -> vigil_audit::Result<()> {
190 // 防御:空文本不应到达这里(extract_long_text_fields 用 threshold ≥ 1 时已过滤),
191 // 但 audit 侧 validate_fingerprint 只校验 hex 格式,空文本的 sha256 仍合法,
192 // 这里直接继续,不做额外分支。
193 let fp = sha256_prefix16_hex(text.as_bytes());
194
195 let scan_id = ledger.insert_redaction_scan(NewRedactionScan {
196 session_id,
197 // ISS-010:preflight 总是扫 tool args,对应 audit schema 的 `tool_arg` 枚举。
198 source: "tool_arg",
199 text_length: text.len(),
200 fingerprint: &fp,
201 })?;
202
203 for finding in &result.findings {
204 // 未识别 kind 不落审计(审计 ALLOWED_REDACTION_LABELS 只接 8 枚举)。
205 let Some(label) = PrivacyLabel::from_kind(finding.kind) else {
206 continue;
207 };
208
209 let (start, end) = finding.span;
210 // 边界 / UTF-8 防御:merge_findings 后正常不越界,但 Model 侧将来可能塞进
211 // 非 char boundary;这里跳过避免 slice panic。
212 if start > end || end > text.len() {
213 continue;
214 }
215 if !text.is_char_boundary(start) || !text.is_char_boundary(end) {
216 continue;
217 }
218
219 let span_slice = &text[start..end];
220 let span_fp = sha256_prefix16_hex(span_slice.as_bytes());
221
222 ledger.insert_redaction_finding(NewRedactionFinding {
223 scan_id: &scan_id,
224 label: label.as_str(),
225 offset: start,
226 fingerprint: &span_fp,
227 // ISS-010 preflight:redaction crate 已产出 `redacted_text`,把原文替换视为
228 // "已脱敏";blocked / allowed_once 语义留给下游(e.g. session-exempt 放行)。
229 action_taken: "redacted",
230 })?;
231 }
232 Ok(())
233}
234
235/// 取 `sha256(bytes)` 的前 16 字节(32 个 hex 字符,lowercase),对齐 audit 的 `validate_fingerprint`。
236fn sha256_prefix16_hex(bytes: &[u8]) -> String {
237 let mut h = Sha256::new();
238 h.update(bytes);
239 let digest = h.finalize();
240 let mut s = String::with_capacity(32);
241 for b in digest.iter().take(16) {
242 // 显式 lowercase hex,避免依赖 hex crate 的默认行为改变
243 s.push_str(&format!("{b:02x}"));
244 }
245 s
246}
247
248/// preflight 运行结果:聚合后的 summary + 累加 risk delta + 每 label 条数(供审计 reasons)。
249///
250/// 由 `Firewall::evaluate` 消费:summary 进 `PolicyContext.pii_findings`,risk_delta
251/// 饱和加到 `PolicyContext.risk_score`,`by_label_counts` 进 `DecisionRecord.reasons`。
252pub(crate) struct PreflightOutcome {
253 pub(crate) pii_summary: Vec<PiiFindingSummary>,
254 pub(crate) risk_delta: u32,
255 /// v0.8 Sprint 1 A2 — scanner 层退化状态聚合(取多文本中的"最严重"等级)。
256 ///
257 /// 聚合优先级(高到低):`DegradedError` > `DegradedTimeout` > `Ok` > `Unsupported`。
258 /// `Firewall::evaluate` 收到 `DegradedError` / `DegradedTimeout` 必须落
259 /// `engine_degraded` 审计 + 在 decision_reasons 加 stable code。
260 /// `Ok` / `Unsupported` 不写审计(无信息可写)。
261 pub(crate) engine_status: EngineStatusReport,
262}
263
264impl PreflightOutcome {
265 /// 返回 label → count 的有序列表(供 reasons 构造使用)。
266 pub(crate) fn counts_csv(&self) -> String {
267 self.pii_summary
268 .iter()
269 .map(|s| format!("{}={}", s.label, s.count))
270 .collect::<Vec<_>>()
271 .join(",")
272 }
273}
274
275/// preflight 错误:scan 失败走 fail-closed。
276///
277/// - `EmptyInput` **不**走此路径(`extract_long_text_fields` 按 threshold ≥ 1 过滤,
278/// 不会把空字符串送进 scan;即便送了,caller 也应把 EmptyInput 当 continue 处理)。
279pub(crate) enum PreflightError {
280 /// scanner 返非 EmptyInput 的其他 Err(目前只有 `InferenceFailed`,留给 ISS-008)
281 ScanFailed { reason: String },
282}
283
284/// **R2 MUST-FIX 2 修复**:preflight 审计写失败累计(无原文;原 `eprintln!` stderr 污染
285/// 已删除)。`Firewall::audit_persist_failures()` 暴露只读 snapshot,运维 / 测试可据此
286/// 判断是否出现审计静默丢失。
287pub(crate) type AuditPersistCounter = AtomicU64;
288
289/// 跑一次完整的 preflight:扫所有长文本 → 累加 findings + risk_delta → 同时最佳努力写审计。
290///
291/// `scanner` 走 [`PiiScanner`] trait,生产默认 [`DefaultScanner`];测试可注入失败 scanner
292/// 真触发 fail-closed 路径(见 tests/preflight.rs::preflight_fail_closed_on_scan_err)。
293///
294/// caller 拿到 `Ok(PreflightOutcome)` 正常流程;拿到 `Err` 必须把决策翻成 Deny + 返错。
295/// `audit_failures` 在 audit 写失败时原子递增;caller 可事后查询,不走 stderr 污染路径。
296pub(crate) fn run_preflight(
297 scanner: &dyn PiiScanner,
298 ledger: &Ledger,
299 audit_failures: &AuditPersistCounter,
300 session_id: &str,
301 args: &serde_json::Value,
302 threshold: usize,
303) -> Result<PreflightOutcome, PreflightError> {
304 let long_texts = extract_long_text_fields(args, threshold);
305
306 // label → 累加条数;label 来自 PrivacyLabel::as_str() 字面量(稳定契约)
307 let mut by_label: BTreeMap<&'static str, u32> = BTreeMap::new();
308 let mut total_risk_delta: u32 = 0;
309 // v0.8 Sprint 1 A2 — worst-status 聚合。
310 // **R1 MUST-FIX(Codex 019de925)**:初始值必须是 `Unsupported` 而非 `Ok` —
311 // 否则 scanner 走 default(全程返 Unsupported)的路径会被错误聚合为 Ok,
312 // 复活 Codex § 2 改进版 A 严防的"fake-safe Ok"(虽然当前 engine.rs 对 Ok 不写
313 // audit/reasons,行为上看不到差别,但 PreflightOutcome.engine_status 字段
314 // 本身的语义会被破坏 —— "全 default scanner ≠ 全部正常完成扫描")。
315 // 优先级:DegradedError > DegradedTimeout > Ok > Unsupported(`Ok > Unsupported`
316 // 保持不变,让真"扫过且 Ok"覆盖"未上报"。)
317 let mut worst_status = EngineStatusReport::Unsupported;
318
319 for text in &long_texts {
320 match scanner.scan_with_status(text) {
321 Ok((result, status)) => {
322 // 状态聚合:取严重度更高者
323 worst_status = elevate_status(worst_status, status);
324
325 // 累加 label × count(透传 aggregate_risk 口径)
326 for (label, cnt) in &result.risk_signals.counts_by_label {
327 *by_label.entry(label.as_str()).or_insert(0) += cnt;
328 }
329 total_risk_delta = total_risk_delta.saturating_add(compute_pii_risk_delta(&result));
330
331 // 最佳努力落审计:失败只原子计数,**不**污染 stderr、**不**阻断决策。
332 // 运维通过 `Firewall::audit_persist_failures()` 读 snapshot。
333 if persist_scan_to_ledger(ledger, session_id, text, &result).is_err() {
334 audit_failures.fetch_add(1, Ordering::Relaxed);
335 }
336 }
337 Err(ScanError::EmptyInput) => {
338 // 不该 happen(threshold 过滤了空串);保守 continue。
339 continue;
340 }
341 // T4 ISS-008 Phase 2 secret-hygiene:固定字面量 reason,**严禁** Debug 拼接。
342 // ORT/tokenizer error 的 Display/Debug 可能携带输入文本片段(spike 实测
343 // tokenizer "encode" 错误会含 token 字符串),Debug 拼接会让原文回显到
344 // audit ledger 的 DecisionRecord.reasons,违反 secret-hygiene 不变量。
345 // caller 需要细粒度信息时,改去 ledger redaction_scans / redaction_findings
346 // 或 stderr 跑诊断;DecisionRecord 只记稳定 code。
347 Err(ScanError::InferenceFailed { .. }) => {
348 return Err(PreflightError::ScanFailed {
349 reason: "t0_inference_failed".to_string(),
350 });
351 }
352 // 兜底:未来 ScanError 新 variant 一律塌缩到稳定字面量,绝不 Debug 拼接。
353 // 当前(2026-04-29)ScanError 仅 EmptyInput + InferenceFailed 两个 variant,
354 // 编译期 reachable 但保留对未来扩展的 forward-compat。
355 #[allow(unreachable_patterns)]
356 Err(_other) => {
357 return Err(PreflightError::ScanFailed {
358 reason: "t0_scan_failed".to_string(),
359 });
360 }
361 }
362 }
363
364 let pii_summary: Vec<PiiFindingSummary> = by_label
365 .into_iter()
366 .map(|(label, count)| PiiFindingSummary {
367 label: label.to_string(),
368 count,
369 })
370 .collect();
371
372 Ok(PreflightOutcome {
373 pii_summary,
374 risk_delta: total_risk_delta,
375 engine_status: worst_status,
376 })
377}
378
379/// v0.8 Sprint 1 A2 — 取两个 status 中"更严重"者(用于多 text 循环聚合)。
380///
381/// 严重度从高到低:`DegradedError` > `DegradedTimeout` > `Ok` > `Unsupported`。
382/// 这是 firewall 视角的安全顺序 —— degraded 必须可见,Unsupported 是 scanner 实现层面的"无信息",
383/// 不应覆盖 Ok(Ok 是真"扫过且正常",Unsupported 是"未上报",前者信息量更高)。
384fn elevate_status(a: EngineStatusReport, b: EngineStatusReport) -> EngineStatusReport {
385 fn rank(s: EngineStatusReport) -> u8 {
386 // crate 内部穷举;加新 variant 时 compiler force update,作者必须显式选 rank
387 // (新 variant 应归到 Degraded 类 ≥ 2 或独立优先级,不可默认 0 = 静默降级到
388 // Unsupported)。这条规则比 runtime `_ => 0` 更强。
389 match s {
390 EngineStatusReport::DegradedError => 3,
391 EngineStatusReport::DegradedTimeout => 2,
392 EngineStatusReport::Ok => 1,
393 EngineStatusReport::Unsupported => 0,
394 }
395 }
396 if rank(a) >= rank(b) {
397 a
398 } else {
399 b
400 }
401}
402
403/// 便利构造:生产默认 scanner 作为 `Arc<dyn PiiScanner>`,供 `Firewall::new` 使用。
404pub(crate) fn default_scanner_arc() -> Arc<dyn PiiScanner> {
405 Arc::new(DefaultScanner)
406}
407
408// ──────────────────────────── ISS-008 Phase 2 T2:OrtPiiScanner ────────────────────────────
409//
410// Wrapper 把 [`vigil_redaction::OrtEngine`] 适配成 [`PiiScanner`] trait object,
411// 让 `Firewall::with_scanner` 能透明替换默认 [`DefaultScanner`]。
412//
413// **SSOT 纪律**:wrapper 内**不**复制 merge / risk / redact 逻辑,全部委托
414// `vigil_redaction::scan_text_with_engine`(同 `scan_text` 的 hard×model 决策路径)。
415//
416// **可见性**:`OrtPiiScanner` 类型本身保持 crate-private(T3 决议),外部只能通过
417// `ort_scanner_arc_from_env()` 工厂拿到 `Arc<dyn PiiScanner>`,避免泄漏 ort 类型边界。
418
419/// 仅在 `--features ort` 启用时编译。
420#[cfg(feature = "ort")]
421mod ort_scanner {
422 use std::sync::Arc;
423 use std::time::Duration;
424
425 use vigil_redaction::{
426 scan_text_with_engine, scan_text_with_engine_budgeted, OrtEngine, RedactionEngine,
427 RedactionResult, ScanError,
428 };
429
430 use super::{EngineStatusReport, PiiScanner};
431
432 /// `Arc<OrtEngine>` 适配为 `PiiScanner`。Send + Sync 由 `OrtEngine` 自身保证
433 /// (engine.rs::ort_static_assertions::_check 编译期守门)。
434 pub(crate) struct OrtPiiScanner {
435 engine: Arc<OrtEngine>,
436 }
437
438 impl OrtPiiScanner {
439 pub(crate) fn new(engine: Arc<OrtEngine>) -> Self {
440 Self { engine }
441 }
442 }
443
444 impl PiiScanner for OrtPiiScanner {
445 fn scan(&self, text: &str) -> Result<RedactionResult, ScanError> {
446 // SSOT 在 vigil-redaction;wrapper 不复制 merge/risk/redact 逻辑。
447 scan_text_with_engine(text, &*self.engine)
448 }
449 }
450
451 /// v0.7-α2 Phase 2D-fw(ADR 0016 § 5.4):带 budget 的 OrtPiiScanner 包装。
452 ///
453 /// 模型路径在 `budget` 内未完成 → 自动退化 Hard-only,fail-closed 保留(secret
454 /// 类硬规则仍命中)。本 wrapper 内部走 [`scan_text_with_engine_budgeted`],
455 /// 把 `BudgetedScanOutcome { result, status }` 中的 `status` **吞掉**(状态
456 /// 不透出 `PiiScanner` trait,避免 SemVer breaking;退化决策在 budget 层完成,
457 /// 等价"模型路径无信号" — 与 NoopEngine 路径行为对齐)。
458 ///
459 /// **caller 视角**:scan 仍返 `Result<RedactionResult, ScanError>`,语义同
460 /// 默认 OrtPiiScanner;**唯一区别**是 timeout/error 不会卡住 firewall preflight,
461 /// 而是退化到 Hard-only 后继续走 PolicyEngine 决策。
462 pub(crate) struct BudgetedOrtPiiScanner {
463 engine: Arc<OrtEngine>,
464 budget: Duration,
465 }
466
467 impl BudgetedOrtPiiScanner {
468 pub(crate) fn new(engine: Arc<OrtEngine>, budget: Duration) -> Self {
469 Self { engine, budget }
470 }
471 }
472
473 impl PiiScanner for BudgetedOrtPiiScanner {
474 fn scan(&self, text: &str) -> Result<RedactionResult, ScanError> {
475 // 兼容路径:legacy `scan` 仍丢弃 status(保持 SemVer);新 caller 应走
476 // `scan_with_status` 拿到真实退化标记。
477 self.scan_with_status(text).map(|(r, _status)| r)
478 }
479
480 /// v0.8 Sprint 1 A2 — override 透出真实退化状态。
481 ///
482 /// `BudgetedScanOutcome.status` 三态 (`Ok` / `DegradedTimeout` / `DegradedError`)
483 /// 经 `From<vigil_redaction::EngineStatus>` 映射为 `EngineStatusReport`,
484 /// 永不返 `Unsupported`(本 scanner 实现自带 budget 路径)。
485 ///
486 /// caller(`Firewall::evaluate`)拿到 `DegradedTimeout` / `DegradedError` 应:
487 /// 1. 落 audit `engine_degraded` 事件(reason_code = stable_code())
488 /// 2. decision_reasons.push("engine.status=<stable_code>")
489 /// 3. 仍走 PolicyEngine 决策(已退化为 Hard-only,fail-closed 路径)
490 fn scan_with_status(
491 &self,
492 text: &str,
493 ) -> Result<(RedactionResult, EngineStatusReport), ScanError> {
494 let engine: Arc<dyn RedactionEngine> = Arc::clone(&self.engine) as _;
495 scan_text_with_engine_budgeted(text, engine, self.budget)
496 .map(|outcome| (outcome.result, outcome.status.into()))
497 }
498 }
499
500 /// v0.7-α5 R1g+(E6a)— 三引擎 ensemble 适配 PiiScanner trait。
501 ///
502 /// **架构**:`EnsembleEngine` 内部并联 OpenAI / xlmr / yonigo 三 OrtEngine,
503 /// `scan_text_with_engine` 走完整 Hard rules + ensemble model union + ADR 0013
504 /// merge。SSOT 在 vigil-redaction,wrapper 不复制逻辑。
505 ///
506 /// **lazy-load 决策**(Codex § 3 ACCEPT):**eager load**(构造时三模型同时 init)。
507 /// 1.4-2.2GB RSS 由 caller 决策(企业 release runner 接受;default 用 single-engine
508 /// path);真 lazy-load 推 v0.7-α6+。
509 ///
510 /// **budget 不暴露**(R1g+ 简化):budget 模式需 `EnsembleEngine: Clone`,
511 /// 当前未实现;budget 路径推 v0.7-α6+。无 budget 模式 worst-case warm 856ms
512 /// 实测 ≤ 1500ms ADR 0016 ensemble SLO,production 可接受。
513 ///
514 /// **fail-closed 保留**:任一 engine init 失败即 `EngineError::ModelNotFound` /
515 /// `SessionInit`(沿用 ADR 0012 fail-fast);scan 路径 engine.infer Err
516 /// → `ScanError::InferenceFailed`。
517 pub(crate) struct EnsembleOrtPiiScanner {
518 ensemble: vigil_redaction::EnsembleEngine,
519 }
520
521 impl EnsembleOrtPiiScanner {
522 #[allow(dead_code)] // 留给纯 engines 路径(无 dual_confirm 简化构造)
523 pub(crate) fn new(engines: Vec<Arc<dyn RedactionEngine>>) -> Self {
524 Self {
525 ensemble: vigil_redaction::EnsembleEngine::new(engines),
526 }
527 }
528
529 /// v0.7-α5 A step:已配置好的 EnsembleEngine 注入(支持 with_dual_confirm)
530 pub(crate) fn from_ensemble(ensemble: vigil_redaction::EnsembleEngine) -> Self {
531 Self { ensemble }
532 }
533 }
534
535 impl PiiScanner for EnsembleOrtPiiScanner {
536 fn scan(&self, text: &str) -> Result<RedactionResult, ScanError> {
537 // 走 scan_text_with_engine 完整路径(Hard rules + model ensemble
538 // union + merge_findings + ADR 0013 D1-D6 决策)
539 scan_text_with_engine(text, &self.ensemble)
540 }
541
542 // **v0.8 Sprint 1 A2 决策**:故意**不**override `scan_with_status`,
543 // 走 trait default 返 `EngineStatusReport::Unsupported`。
544 //
545 // 理由:`EnsembleEngine` 当前 R1g+ 简化版**未实现 budget 路径**(无
546 // `EnsembleEngine: Clone`,`scan_text_with_engine_budgeted` 不可用)。
547 // ensemble 内任一 engine 长尾 → 整 scan 长尾,无 timeout 退化。
548 //
549 // caller(`Firewall::evaluate`)拿到 `Unsupported` 必须显式判:
550 // - 不写 audit `engine_degraded` 事件(无信息可写)
551 // - 不加 decision_reasons "engine.status=*"(无状态可报)
552 // - 走正常决策路径(scan 真 fail 仍走 fail-closed Deny via ScanError)
553 //
554 // ensemble + budget 路径推 v0.8+(ADR 0017 Revised § A2 留待 Sprint 3 dual_confirm
555 // calibration 后再看是否引入 EnsembleEngine: Clone)。
556 }
557}
558
559/// 工厂:从 `VIGIL_PRIVACY_FILTER_MODEL_DIR` 同步加载 OrtEngine 并包成
560/// `Arc<dyn PiiScanner>`,供 `Firewall::with_scanner` 注入。
561///
562/// **启动期 fail-fast**:cold-start ~7 s 在此一次性吃掉(模型加载 + Session
563/// commit_from_file),首请求 SLA 不再受影响。错误(env unset / 模型缺失 / ORT
564/// 初始化失败)直接返 [`vigil_redaction::engine::EngineError`],由 caller
565/// (vigil-hub-cli `serve.rs::build_hub`)塌缩到 `ServeError::PrivacyFilterInit`
566/// 启动失败,**不**降级为 NoopEngine。
567///
568/// # Errors
569/// 见 [`vigil_redaction::engine::EngineError`] 的 6 个变体(ModelNotFound /
570/// TokenizerLoad / SessionInit / InferRun / DecodeShape / Internal)。
571#[cfg(feature = "ort")]
572pub fn ort_scanner_arc_from_env(
573) -> Result<Arc<dyn PiiScanner>, vigil_redaction::engine::EngineError> {
574 let engine = Arc::new(vigil_redaction::OrtEngine::from_env()?);
575 Ok(Arc::new(ort_scanner::OrtPiiScanner::new(engine)))
576}
577
578/// v0.7-α5 R1g+(E6a)— 三引擎 ensemble 工厂(production firewall 集成)。
579///
580/// 把 `vigil_redaction::EnsembleEngine`(openai + xlmr + yonigo)包成
581/// `Arc<dyn PiiScanner>`,供 `Firewall::with_scanner` 注入。
582///
583/// **使用场景**:企业 release runner / 自有部署需要 multilang recall,接受
584/// 1.4-2.2GB RAM 代价。Default firewall 路径(GUI / hub-cli)推荐继续用
585/// [`ort_scanner_arc_from_env`] 单 OpenAI engine(838MB RAM)。
586///
587/// **三 dir env vars**(若任一缺即 fail-fast):
588/// - `VIGIL_ENSEMBLE_OPENAI_DIR`(OpenAI Privacy Filter v1)
589/// - `VIGIL_ENSEMBLE_XLMR_DIR`(xlmr-pii-v1)
590/// - `VIGIL_ENSEMBLE_YONIGO_DIR`(yonigo-pii-v1)
591///
592/// **eager load**:构造时三 OrtEngine 同时 init(总 ~17s cold,与 spike-3 对齐)。
593/// 真 lazy-load + warmup 推 v0.7-α6+。
594///
595/// # Errors
596/// 任一 dir env 缺失 / 模型缺失 / ORT init 失败 → `EngineError`(沿用 ADR 0012
597/// fail-fast,绝不降级)。
598#[cfg(feature = "ort")]
599pub fn ort_ensemble_scanner_arc_from_env(
600) -> Result<Arc<dyn PiiScanner>, vigil_redaction::engine::EngineError> {
601 // **v0.10 Sprint 1**:legacy 路径 → xlmr_mode = None(env-driven xlmr profile)
602 build_ort_ensemble_scanner_arc_from_env(None)
603}
604
605/// **v0.10 Sprint 1 F 续** — typed `XlmrProfileMode` ensemble 工厂入口。
606///
607/// 与 [`ort_ensemble_scanner_arc_from_env`] 区别:caller 显式传 typed
608/// [`vigil_redaction::model_descriptor::XlmrProfileMode`],**忽略**
609/// `VIGIL_XLMR_PROFILE` env(SDK reproducible / inspectable)。
610///
611/// 三 model dir env 仍读(`VIGIL_ENSEMBLE_OPENAI_DIR` / `_XLMR_DIR` / `_YONIGO_DIR`)—
612/// 这些是 ops 部署配置,不是 SDK consumer 责任。
613///
614/// **典型场景**:SDK consumer 想 reproducible 走 `XlmrProfileMode::Default`(v0.8
615/// baseline)或显式 opt-in `XlmrProfileMode::FpStrict`(企业 / 高 FP 容忍度)
616/// 而**不**依赖 env(避免 env 漂移)。
617///
618/// # Errors
619/// 同 [`ort_ensemble_scanner_arc_from_env`]:env unset / 模型缺失 / ORT init
620/// 失败 → `EngineError`。
621#[cfg(feature = "ort")]
622pub fn ort_ensemble_scanner_arc_from_env_with_xlmr_mode(
623 xlmr_mode: vigil_redaction::model_descriptor::XlmrProfileMode,
624) -> Result<Arc<dyn PiiScanner>, vigil_redaction::engine::EngineError> {
625 build_ort_ensemble_scanner_arc_from_env(Some(xlmr_mode))
626}
627
628/// internal helper — 共享 3 model dir env 读 + EnsembleEngine 构造 + dual_confirm
629/// env;`xlmr_mode = None` 走 legacy env,`Some(_)` 走 typed 路径(忽略 env)。
630#[cfg(feature = "ort")]
631fn build_ort_ensemble_scanner_arc_from_env(
632 xlmr_mode: Option<vigil_redaction::model_descriptor::XlmrProfileMode>,
633) -> Result<Arc<dyn PiiScanner>, vigil_redaction::engine::EngineError> {
634 use std::path::PathBuf;
635 use vigil_redaction::engine::EngineError;
636 use vigil_redaction::model_descriptor::{
637 OpenAIPrivacyFilterDescriptor, XlmrPiiDescriptor, YonigoPiiDescriptor,
638 };
639
640 let openai_dir = std::env::var("VIGIL_ENSEMBLE_OPENAI_DIR")
641 .map(PathBuf::from)
642 .map_err(|_| EngineError::ModelNotFound {
643 dir: "<VIGIL_ENSEMBLE_OPENAI_DIR unset>".to_string(),
644 })?;
645 let xlmr_dir = std::env::var("VIGIL_ENSEMBLE_XLMR_DIR")
646 .map(PathBuf::from)
647 .map_err(|_| EngineError::ModelNotFound {
648 dir: "<VIGIL_ENSEMBLE_XLMR_DIR unset>".to_string(),
649 })?;
650 let yonigo_dir = std::env::var("VIGIL_ENSEMBLE_YONIGO_DIR")
651 .map(PathBuf::from)
652 .map_err(|_| EngineError::ModelNotFound {
653 dir: "<VIGIL_ENSEMBLE_YONIGO_DIR unset>".to_string(),
654 })?;
655
656 // v0.10 Sprint 1:xlmr descriptor 按 mode 选择
657 let xlmr_descriptor = match xlmr_mode {
658 Some(mode) => XlmrPiiDescriptor::with_mode(mode), // typed,忽略 env
659 None => XlmrPiiDescriptor::default(), // legacy env-driven
660 };
661
662 let openai = Arc::new(vigil_redaction::OrtEngine::from_dir_with_descriptor(
663 &openai_dir,
664 Box::new(OpenAIPrivacyFilterDescriptor),
665 )?);
666 let xlmr = Arc::new(vigil_redaction::OrtEngine::from_dir_with_descriptor(
667 &xlmr_dir,
668 Box::new(xlmr_descriptor),
669 )?);
670 let yonigo = Arc::new(vigil_redaction::OrtEngine::from_dir_with_descriptor(
671 &yonigo_dir,
672 Box::new(YonigoPiiDescriptor),
673 )?);
674
675 let engines: Vec<Arc<dyn vigil_redaction::RedactionEngine>> = vec![openai, xlmr, yonigo];
676
677 // v0.7-α5 A step:可选 dual_confirm via env var(comma-separated canonical labels)
678 // 示例:VIGIL_ENSEMBLE_DUAL_CONFIRM=address,date,account_number
679 // 不设 = 关闭(原 R1h union 行为)
680 let ensemble = vigil_redaction::EnsembleEngine::new(engines);
681 let ensemble = if let Ok(s) = std::env::var("VIGIL_ENSEMBLE_DUAL_CONFIRM") {
682 let labels: Vec<vigil_redaction::PrivacyLabel> = s
683 .split(',')
684 .filter_map(|t| {
685 let trimmed = t.trim().to_lowercase();
686 vigil_redaction::PrivacyLabel::from_kind(&trimmed)
687 })
688 .collect();
689 if !labels.is_empty() {
690 ensemble.with_dual_confirm(labels)
691 } else {
692 ensemble
693 }
694 } else {
695 ensemble
696 };
697
698 Ok(Arc::new(ort_scanner::EnsembleOrtPiiScanner::from_ensemble(
699 ensemble,
700 )))
701}
702
703/// v0.7-α2 Phase 2D-fw(ADR 0016 § 5.4):带 budget 的 OrtPiiScanner 工厂。
704///
705/// 与 [`ort_scanner_arc_from_env`] 唯一区别:scan 路径走
706/// [`vigil_redaction::scan_text_with_engine_budgeted`],模型推理超 `budget` 即
707/// 退化 Hard-only(fail-closed 保留;secret 类硬规则仍命中)。
708///
709/// **生产推荐 budget**:`Duration::from_secs(2)`(ADR 0016 Enhanced path warm < 1s
710/// 上界 + 50% 余量,避免极端慢请求把 firewall preflight 卡住)。
711///
712/// **status 透出**:本 Phase 2D-fw 极简集成,timeout/error 退化路径在 wrapper 内
713/// 吞掉 status(行为等同模型路径无信号)。decision_reasons 审计标 + ledger
714/// 'engine.degraded' 事件留 v0.7-α3 实施。
715///
716/// # Errors
717/// 同 [`ort_scanner_arc_from_env`]:env unset / 模型缺失 / ORT init 失败时返
718/// [`vigil_redaction::engine::EngineError`]。
719#[cfg(feature = "ort")]
720pub fn ort_scanner_arc_from_env_with_budget(
721 budget: std::time::Duration,
722) -> Result<Arc<dyn PiiScanner>, vigil_redaction::engine::EngineError> {
723 let engine = Arc::new(vigil_redaction::OrtEngine::from_env()?);
724 Ok(Arc::new(ort_scanner::BudgetedOrtPiiScanner::new(
725 engine, budget,
726 )))
727}
728
729#[cfg(test)]
730#[allow(clippy::panic)] // 测试内 panic! 是合法失败信号
731mod tests {
732 use super::*;
733
734 #[test]
735 fn extract_long_text_fields_threshold_filters_short() {
736 // threshold=100:短于 100 的字符串被过滤
737 let args = serde_json::json!({
738 "short": "hi",
739 "long": "x".repeat(150),
740 });
741 let out = extract_long_text_fields(&args, 100);
742 assert_eq!(out.len(), 1);
743 assert_eq!(out[0].len(), 150);
744 }
745
746 #[test]
747 fn extract_long_text_fields_recursive_arrays_and_objects() {
748 let args = serde_json::json!({
749 "outer": {
750 "nested": ["a", "b".repeat(50)],
751 "deep": { "leaf": "c".repeat(80) }
752 }
753 });
754 let out = extract_long_text_fields(&args, 30);
755 // 命中两条:b*50 + c*80
756 assert_eq!(out.len(), 2);
757 }
758
759 #[test]
760 fn extract_long_text_fields_null_args_returns_empty() {
761 let out = extract_long_text_fields(&serde_json::Value::Null, 100);
762 assert!(out.is_empty());
763 let out2 = extract_long_text_fields(&serde_json::json!({}), 100);
764 assert!(out2.is_empty());
765 }
766
767 #[test]
768 fn extract_long_text_fields_skips_numbers_and_booleans() {
769 let args = serde_json::json!({
770 "n": 12345,
771 "b": true,
772 "s": "x".repeat(120),
773 });
774 let out = extract_long_text_fields(&args, 100);
775 assert_eq!(out.len(), 1);
776 }
777
778 #[test]
779 fn sha256_prefix16_hex_is_32_lowercase_chars() {
780 let fp = sha256_prefix16_hex(b"hello world");
781 assert_eq!(fp.len(), 32);
782 assert!(fp
783 .chars()
784 .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
785 }
786
787 #[test]
788 fn compute_pii_risk_delta_transparent_to_signals() {
789 // 构造一个带已知 total_risk_delta 的 RedactionResult(走真 scan_text)
790 let text = "junk ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ more";
791 let r = vigil_redaction::scan_text(text).expect("non-empty");
792 let delta = compute_pii_risk_delta(&r);
793 assert!(
794 delta >= 25,
795 "Secret 类应至少 25,实际 {delta}(signals={:?})",
796 r.risk_signals
797 );
798 }
799
800 /// local mock,等价 tests/preflight.rs 的 TestFailingScanner。
801 struct LocalFailingScanner;
802 impl PiiScanner for LocalFailingScanner {
803 fn scan(&self, _: &str) -> Result<RedactionResult, ScanError> {
804 Err(ScanError::InferenceFailed {
805 reason: "unit-test".into(),
806 })
807 }
808 }
809
810 #[test]
811 fn failing_scanner_propagates_inference_failed() {
812 // R2 BLOCKER 2:新增正向测试证本地 failing scanner impl 正确(真 fail-closed 路径)
813 let s = LocalFailingScanner;
814 match s.scan("some text") {
815 Err(ScanError::InferenceFailed { reason }) => {
816 assert_eq!(reason, "unit-test");
817 }
818 other => panic!("local failing scanner should return InferenceFailed, got {other:?}"),
819 }
820 }
821
822 #[test]
823 fn default_scanner_forwards_to_scan_text() {
824 // DefaultScanner 与 vigil_redaction::scan_text 语义一致(空输入 Err)
825 let s = DefaultScanner;
826 match s.scan("") {
827 Err(ScanError::EmptyInput) => {}
828 other => panic!("DefaultScanner('') should be EmptyInput, got {other:?}"),
829 }
830 }
831
832 // ─────────── v0.8 Sprint 1 A2 — R1 NICE(Codex 019de925)守门 ───────────
833
834 /// `elevate_status` 严重度全序检查。锁定 Codex § 2 改进版 A 的安全顺序:
835 /// `DegradedError > DegradedTimeout > Ok > Unsupported`。
836 /// `Ok > Unsupported` 是设计 — 真"扫过且 Ok"覆盖"未上报"(default scanner)。
837 #[test]
838 fn elevate_status_total_order_safety() {
839 use EngineStatusReport::*;
840 // 自反:任何状态 elevate 自己 == 自己
841 for s in [DegradedError, DegradedTimeout, Ok, Unsupported] {
842 assert_eq!(elevate_status(s, s), s);
843 }
844 // 严格递减序对(右更严重 → 取右)
845 assert_eq!(elevate_status(Unsupported, Ok), Ok);
846 assert_eq!(elevate_status(Ok, DegradedTimeout), DegradedTimeout);
847 assert_eq!(
848 elevate_status(DegradedTimeout, DegradedError),
849 DegradedError
850 );
851 assert_eq!(elevate_status(Unsupported, DegradedError), DegradedError);
852 // 对称(参数交换不改变结果)
853 assert_eq!(elevate_status(Ok, Unsupported), Ok);
854 assert_eq!(elevate_status(DegradedError, Ok), DegradedError);
855 }
856
857 /// **R1 MUST-FIX 守门(Codex 019de925)**:scanner 走 trait default
858 /// (不 override `scan_with_status` → 全程返 Unsupported)时,
859 /// `run_preflight` 必须真实返 `outcome.engine_status == Unsupported`,
860 /// **不**得被聚合错误升级为 Ok(fake-safe Ok)。
861 ///
862 /// 这是直接验证字段语义,补 tests/preflight.rs 那条只观察 audit/reasons
863 /// 副作用的负向测试盲区(NICE 项 — 之前那条即使 outcome 报 Ok 也会过)。
864 #[test]
865 fn run_preflight_with_default_status_scanner_yields_unsupported() {
866 struct LocalDefaultStatusScanner;
867 impl PiiScanner for LocalDefaultStatusScanner {
868 fn scan(&self, _text: &str) -> Result<RedactionResult, ScanError> {
869 Ok(RedactionResult {
870 findings: Vec::new(),
871 redacted_text: String::new(),
872 risk_signals: vigil_redaction::RiskSignals::default(),
873 })
874 }
875 // 故意不 override scan_with_status → 走 trait default 返 Unsupported
876 }
877
878 let ledger = vigil_audit::Ledger::open_in_memory().expect("open_in_memory");
879 let sid = ledger
880 .start_session("a2-r1-test", Some("default_status_unsupported"))
881 .expect("start_session");
882 let counter = AuditPersistCounter::new(0);
883 let args = serde_json::json!({ "long": "x".repeat(200) });
884
885 let outcome = run_preflight(
886 &LocalDefaultStatusScanner,
887 &ledger,
888 &counter,
889 &sid,
890 &args,
891 100,
892 )
893 .unwrap_or_else(|_| panic!("run_preflight should succeed with non-failing scanner"));
894
895 assert_eq!(
896 outcome.engine_status,
897 EngineStatusReport::Unsupported,
898 "default scan_with_status path 必须聚合为 Unsupported,**不**得被 fake-safe 升级为 Ok;\
899 这是 Codex § 2 改进版 A 的关键不变量(R1 MUST-FIX 锁定项)"
900 );
901 }
902
903 /// 反向对照:scanner override `scan_with_status` 真返 Ok 时,
904 /// `run_preflight` outcome.engine_status 应为 Ok(不是 Unsupported)。
905 /// 与上一测共同钉死"`Ok > Unsupported` 顺序在多 text 聚合下生效"。
906 #[test]
907 fn run_preflight_with_real_ok_status_overrides_unsupported() {
908 struct LocalOkStatusScanner;
909 impl PiiScanner for LocalOkStatusScanner {
910 fn scan(&self, _text: &str) -> Result<RedactionResult, ScanError> {
911 Ok(RedactionResult {
912 findings: Vec::new(),
913 redacted_text: String::new(),
914 risk_signals: vigil_redaction::RiskSignals::default(),
915 })
916 }
917 fn scan_with_status(
918 &self,
919 text: &str,
920 ) -> Result<(RedactionResult, EngineStatusReport), ScanError> {
921 self.scan(text).map(|r| (r, EngineStatusReport::Ok))
922 }
923 }
924
925 let ledger = vigil_audit::Ledger::open_in_memory().unwrap();
926 let sid = ledger
927 .start_session("a2-r1-test", Some("ok_overrides"))
928 .unwrap();
929 let counter = AuditPersistCounter::new(0);
930 let args = serde_json::json!({ "long": "x".repeat(200) });
931
932 let outcome = run_preflight(&LocalOkStatusScanner, &ledger, &counter, &sid, &args, 100)
933 .unwrap_or_else(|_| panic!("run_preflight should succeed with ok scanner"));
934 assert_eq!(
935 outcome.engine_status,
936 EngineStatusReport::Ok,
937 "真 Ok status 必须覆盖初始 Unsupported(`Ok > Unsupported` 严重度)"
938 );
939 }
940
941 // ─────────── v0.7-α2 Phase 2D-fw(ADR 0016 § 5.4)守门 ───────────
942 //
943 // 工厂 ort_scanner_arc_from_env_with_budget 的真行为测试需要真 OrtEngine env
944 // (VIGIL_PRIVACY_FILTER_MODEL_DIR + 838MB 模型 + onnxruntime.dll),
945 // 与 [`ort_scanner_arc_from_env`] 同走 Linux release runner gate。本守门只验
946 // env 缺失时**不 panic**且返 ModelNotFound — 这是 fail-fast 不变量(ADR 0012)。
947
948 /// v0.7-α5 R1g+ — ensemble 工厂在 3 dir env 缺失时返 ModelNotFound 不 panic
949 /// (沿用 ADR 0012 § fail-fast on env miss);开发机已设 env 时 graceful skip。
950 #[cfg(feature = "ort")]
951 #[test]
952 fn ort_ensemble_scanner_arc_from_env_missing_envs_returns_modelnotfound() {
953 let any_set = [
954 "VIGIL_ENSEMBLE_OPENAI_DIR",
955 "VIGIL_ENSEMBLE_XLMR_DIR",
956 "VIGIL_ENSEMBLE_YONIGO_DIR",
957 ]
958 .iter()
959 .any(|k| std::env::var(k).is_ok());
960 if any_set {
961 eprintln!("skip: VIGIL_ENSEMBLE_*_DIR already set");
962 return;
963 }
964 let r = ort_ensemble_scanner_arc_from_env();
965 match r {
966 Err(vigil_redaction::engine::EngineError::ModelNotFound { dir }) => {
967 assert!(
968 dir.contains("VIGIL_ENSEMBLE_") && dir.contains("unset"),
969 "ModelNotFound.dir 应含 VIGIL_ENSEMBLE_ env 名,实际: {}",
970 dir
971 );
972 }
973 other => panic!(
974 "env unset 应返 ModelNotFound,实际: {:?}",
975 other.map(|_| "Ok(scanner)")
976 ),
977 }
978 }
979
980 /// 工厂在 env 缺失时应返 ModelNotFound 不 panic(贯彻 ADR 0012 § fail-fast)。
981 /// **不**改环境变量(Rust 2024 env::set_var 是 unsafe);测试在 CI 默认 env unset
982 /// 状态下生效;若开发机已设 env(常见),测试 graceful skip。
983 #[cfg(feature = "ort")]
984 #[test]
985 fn ort_scanner_arc_from_env_with_budget_env_miss_returns_modelnotfound() {
986 if std::env::var("VIGIL_PRIVACY_FILTER_MODEL_DIR").is_ok() {
987 // 已设(开发机或 release runner)→ skip,避免触发真模型加载 7s
988 eprintln!("skip: VIGIL_PRIVACY_FILTER_MODEL_DIR already set");
989 return;
990 }
991 let r = ort_scanner_arc_from_env_with_budget(std::time::Duration::from_secs(1));
992 match r {
993 Err(vigil_redaction::engine::EngineError::ModelNotFound { .. }) => {}
994 other => panic!(
995 "env unset 应返 ModelNotFound,实际: {:?}",
996 other.map(|_| "Ok(scanner)")
997 ),
998 }
999 }
1000}