diff --git a/src-tauri/src/pi_config/gateway.rs b/src-tauri/src/pi_config/gateway.rs index beed88b0d..7f9ad65ff 100644 --- a/src-tauri/src/pi_config/gateway.rs +++ b/src-tauri/src/pi_config/gateway.rs @@ -164,7 +164,7 @@ impl DeferredHeaderValue { } else { self.raw.clone() }; - HeaderValue::from_str(&materialized).map_err(|_| PiGatewayReason { + parse_transport_header_value(&materialized).ok_or_else(|| PiGatewayReason { code: PiGatewayReasonCode::InvalidHeaderValue, json_pointer: pointer.to_string(), }) @@ -262,7 +262,7 @@ impl CandidateHeaderPlan { }); continue; } - if HeaderValue::from_str(value).is_err() { + if !is_deferred(value) && parse_transport_header_value(value).is_none() { reasons.push(PiGatewayReason { code: PiGatewayReasonCode::InvalidHeaderValue, json_pointer: pointer, @@ -431,6 +431,13 @@ fn is_deferred(value: &str) -> bool { value.starts_with('!') || value.contains('$') } +fn parse_transport_header_value(value: &str) -> Option { + if !value.bytes().all(|byte| matches!(byte, 0x20..=0x7e)) { + return None; + } + HeaderValue::from_str(value).ok() +} + fn escape_json_pointer(value: &str) -> String { value.replace('~', "~0").replace('/', "~1") } @@ -693,6 +700,46 @@ mod tests { ); } + #[test] + fn deferred_header_values_use_one_post_resolution_validator() { + let expression = "!echo café"; + let composition = composed(json!({ + "api": "openai-responses", + "baseUrl": "https://candidate.example/v1", + "apiKey": "literal", + "headers": {"x-tenant": expression}, + "models": [{"id": "m"}] + })); + let plan = assess_composition(&composition).plans.remove(0); + let resolved = plan + .materialize(&|value: &str| { + (value == expression).then(|| "resolved-secret".to_string()) + }) + .expect("the resolved visible-ASCII value is valid"); + assert_eq!( + resolved.headers[&HeaderName::from_static("x-tenant")], + "resolved-secret" + ); + assert_eq!( + plan.materialize(&|value: &str| (value == expression).then(|| "café".to_string())) + .expect_err("the resolved value still passes through transport validation") + .code, + PiGatewayReasonCode::InvalidHeaderValue + ); + + let literal = composed(json!({ + "api": "openai-responses", + "baseUrl": "https://candidate.example/v1", + "apiKey": "literal", + "headers": {"x-tenant": "café"}, + "models": [{"id": "m"}] + })); + assert_eq!( + assess_composition(&literal).reasons[0].code, + PiGatewayReasonCode::InvalidHeaderValue + ); + } + #[test] fn auth_header_adds_candidate_local_bearer_without_reusing_another_candidate() { let composition = composed(json!({ diff --git a/src-tauri/src/pi_config/native_inspection_certification.rs b/src-tauri/src/pi_config/native_inspection_certification.rs index 75f8a7113..fd4d0f18f 100644 --- a/src-tauri/src/pi_config/native_inspection_certification.rs +++ b/src-tauri/src/pi_config/native_inspection_certification.rs @@ -11,7 +11,7 @@ //! 盲审前置条件而非充分条件。既有测试不得弱化——以测试总数只增不减 + 盲审 //! 核对为准,不设扫描器。 //! -//! ## 三条裁决及其上游证据 +//! ## 四条裁决及其上游证据 //! C1【无损性】pinned schema 对 `thinkingLevelMap` 只约束 7 个标准键 //! (string|null;oracle 实证 `low: 2` 非法),额外键无约束(oracle 实证 //! `future: {nested:true}` 合法);`cost`/tier 同样接受未来键。managed 与 @@ -27,26 +27,36 @@ //! 设时显式头优先于 apiKey 合成值(Anthropic/OpenAI SDK 按"合成 auth → //! 显式 headers"合并,后项覆盖);authHeader:true 时合成 Bearer 反过来 //! 优先(pinned provider-composer 在自定义头之后写入,且只写 -//! Authorization、不动 x-api-key)。Google 的 header-only 凭证不是 Pi 原生 -//! 可请求形态(adapter 无条件要 apiKey),维持 MissingCredential 降级。 +//! Authorization、不动 x-api-key)。**header-only 凭证对四族都不是 Pi 原生 +//! 可请求形态**:pinned `ModelRuntime.prepareRequest()` 先解析 auth,得不到 +//! AuthResult 即抛 "Provider is not configured",在合并 headers 之前返回, +//! 而 headers 本身永不产生 AuthResult(Google adapter 更是无条件要 apiKey)。 +//! 故无 apiKey 时维持 MissingCredential 降级,但认证头本身仍不得被报为 +//! ProtectedHeader。 //! C3【传输层】放宽认证头不得连带放宽传输层:逐跳头完整覆盖并以 `proxy-` //! **前缀**拒绝;契约 header 六分类中的 Gateway/HTTP owned(proxy trace / //! CDN 客户端身份 / 分布式追踪)同样拒绝,清单与生产 forwarder 无条件 //! 剥离的集合对齐。 +//! C4【deferred 值的校验时机】pinned `resolveConfigValueOrThrow()` 先执行 +//! `!command` / 展开 `${ENV}`,再使用结果;**从不按 HTTP 头规则校验原始 +//! 表达式**(命令输出 trim,环境模板不 trim,解析结果亦不做头合法性校验)。 +//! 因此原始表达式含头非法字符、而解析结果合法的配置必须被接受;头合法性 +//! 校验只能发生在物化之后(这是网关自身的传输约束,保留)。字面量值仍按 +//! 原样校验。 //! -//! ## 预期红绿 -//! 应红 4:`certify_managed_losslessness_through_effective_boundary`、 -//! `certify_auth_candidate_headers_are_not_protected`、 -//! `certify_auth_header_bearer_overrides_explicit_authorization`、 -//! `certify_transport_owned_headers_stay_protected`。 -//! 应绿 3:夹具冻结、DuplicateModelId 保留、composer 无损。 -//! 偏离(非清单红、应红变绿、编译失败)即上报。 +//! ## 预期红绿(2026-08-02 复审修订基线) +//! 首轮实现已使 C1/C2/C3 四项转绿。本次修订新增两项: +//! `certify_header_only_credentials_stay_direct_only` **应绿**——它把"无 +//! apiKey 即降级"钉为契约(上游证据见 C2,首轮盲审曾按缺陷提报,现裁定 +//! 实现正确、契约缺失);`certify_deferred_header_values_are_validated_after_resolution` +//! **应红**(C4:现实现对原始表达式做头校验)。 +//! 即:应红 1、应绿 8。偏离(非清单红、应红变绿、编译失败)即上报。 //! //! ## 残余 //! 显式优先只对 Anthropic/OpenAI 两族有 SDK 证据(Google 两值并存的优先级、 -//! OpenAI-Completions、大小写变体未断言);transport oracle 只执行 resolver, -//! 不执行 adapter/SDK 头合并,主工程触碰数据面须先补 request-capture oracle; -//! 其余按盲审 finding 处理。 +//! 大小写变体未断言);transport oracle 只执行 resolver,不执行 adapter/SDK +//! 头合并,主工程触碰数据面须先补 request-capture oracle;命令输出 trim 与 +//! 环境模板不 trim 的差异属数据面语义,本只读面不断言;其余按盲审 finding 处理。 use super::composer::compose_explicit_custom_catalog; use super::gateway::{assess_composition, PiGatewayCapability, PiGatewayReasonCode}; @@ -338,7 +348,7 @@ fn certify_auth_candidate_headers_are_not_protected() { // 认证头永远不进 failover 协议身份。 if let Some((_, protocol_headers)) = materialized.failover_protocol_identity() { assert!( - !protocol_headers.contains_key(&http::HeaderName::from_static("x-api-key")), + !protocol_headers.contains_key(http::HeaderName::from_static("x-api-key")), "auth headers must stay out of the failover protocol identity" ); } @@ -380,30 +390,6 @@ fn certify_auth_candidate_headers_are_not_protected() { "x-goog-api-key is candidate-auth, not protected" ); assert_eq!(gateway.capability, PiGatewayCapability::Proxyable); - - // (d) Google header-only 不是 Pi 原生可请求形态:维持降级,但认证头 - // 依然不得被报为 ProtectedHeader。 - let header_only = composed_catalog(json!({ - "api": "google-generative-ai", - "baseUrl": "https://gemini.example", - "headers": {"x-goog-api-key": "header-secret"}, - "models": [{"id": "m"}] - })); - let gateway = assess_composition(&header_only); - assert_eq!( - gateway.capability, - PiGatewayCapability::DirectOnly, - "header-only credentials stay DirectOnly, mirroring pinned Pi" - ); - assert!(has_gateway_reason( - &gateway, - PiGatewayReasonCode::MissingCredential - )); - assert!( - !has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader), - "an auth-candidate header must not be reported as protected even when the \ - credential is missing" - ); } // --------------------------------------------------------------------------- @@ -526,3 +512,89 @@ fn certify_transport_owned_headers_stay_protected() { ); } } + +// --------------------------------------------------------------------------- +// 应绿(C2):header-only 凭证四族皆非 Pi 原生可请求形态 +// --------------------------------------------------------------------------- + +#[test] +fn certify_header_only_credentials_stay_direct_only() { + // pinned ModelRuntime.prepareRequest() 先解析 auth,得不到 AuthResult 即抛 + // "Provider is not configured",在合并 headers 之前返回;headers 永不产生 + // AuthResult。因此"只有认证头、无 apiKey"必须降级——但认证头本身依然是 + // candidate-auth,不得被报为 ProtectedHeader。 + for (api, header) in [ + ("anthropic-messages", "x-api-key"), + ("openai-completions", "authorization"), + ("openai-responses", "authorization"), + ("google-generative-ai", "x-goog-api-key"), + ] { + let composition = composed_catalog(json!({ + "api": api, + "baseUrl": "https://example.test/v1", + "headers": {header: "header-secret"}, + "models": [{"id": "m"}] + })); + let gateway = assess_composition(&composition); + assert_eq!( + gateway.capability, + PiGatewayCapability::DirectOnly, + "{api}: header-only credentials are not a requestable pinned Pi form" + ); + assert!( + has_gateway_reason(&gateway, PiGatewayReasonCode::MissingCredential), + "{api}: a missing apiKey must be reported as MissingCredential" + ); + assert!( + !has_gateway_reason(&gateway, PiGatewayReasonCode::ProtectedHeader), + "{api}: the auth header itself must not be reported as protected" + ); + } +} + +// --------------------------------------------------------------------------- +// 应红(C4):deferred 值只能在物化之后校验 +// --------------------------------------------------------------------------- + +#[test] +fn certify_deferred_header_values_are_validated_after_resolution() { + // 原始表达式含头非法字符(非可见 ASCII),解析结果合法。pinned Pi 先执行 + // 再用结果,从不校验原始表达式,故这类配置必须被接受。 + let expression = "!echo café"; + let deferred = composed_catalog(json!({ + "api": "openai-responses", + "baseUrl": "https://openai.example/v1", + "apiKey": "literal", + "headers": {"x-tenant": expression}, + "models": [{"id": "m"}] + })); + let gateway = assess_composition(&deferred); + assert!( + !has_gateway_reason(&gateway, PiGatewayReasonCode::InvalidHeaderValue), + "a deferred expression must not be validated as an HTTP header value before \ + it is resolved" + ); + assert_eq!(gateway.capability, PiGatewayCapability::Proxyable); + let materialized = gateway.plans[0] + .materialize(&|value: &str| (value == expression).then(|| "resolved-secret".to_string())) + .expect("materialize resolved candidate"); + assert_eq!( + materialized.headers[&http::HeaderName::from_static("x-tenant")], + http::HeaderValue::from_static("resolved-secret"), + "the resolved value is what reaches the candidate" + ); + + // 防过度放宽:字面量(非 deferred)含头非法字符仍必须当场拒绝。 + let literal = composed_catalog(json!({ + "api": "openai-responses", + "baseUrl": "https://openai.example/v1", + "apiKey": "literal", + "headers": {"x-tenant": "café"}, + "models": [{"id": "m"}] + })); + let gateway = assess_composition(&literal); + assert!( + has_gateway_reason(&gateway, PiGatewayReasonCode::InvalidHeaderValue), + "a literal header value outside visible ASCII must still be rejected" + ); +}