Skip to main content

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}