From 2bc92e0f79a39e8fd9634706b8231950d726e183 Mon Sep 17 00:00:00 2001 From: SaladDay Date: Sat, 1 Aug 2026 07:48:57 +0000 Subject: [PATCH] refactor(provider): certify typed write ownership Freeze prerequisite A as a component-level certification unit. Add the immutable v5 certification suite, split create/update row DTOs, preserve immutable creation time, map strict-create races to AppError::Conflict, make aggregate compensation insert-or-restore, and enforce reconcile preconditions through a single-lock transaction primitive. Old save_provider callsite classification remains exhaustively recorded in 4f78451405575158ff6562c7021c7f31f2860780; this checkpoint does not add or reclassify an omitted legacy callsite. It tightens the remaining reconciliation classifications there: default live import is [create]; OpenCode/OpenClaw/Hermes existing branches are [update] and absent branches are [create]; universal Claude/Codex/Gemini branches are [create/update] selected from an observed fingerprint. The sealed compensation helper remains the only [restore] path. The old reconcile_provider_record symbol is deleted. Remaining update_provider_settings_config callsites are classified as [update]: codex_history_migration updates an already-read Codex row; proxy token synchronization updates already-read Claude, Codex, Gemini, and GrokBuild rows. Each now uses ProviderKey plus ProviderRowUpdate, explicitly removes hydrated endpoint projections, preserves endpoint authority, and fails on a missing row instead of silently succeeding. --- docs/pi-support-restructure-zh.md | 57 + src-tauri/src/architecture_tests.rs | 8 + src-tauri/src/codex_history_migration.rs | 17 +- src-tauri/src/database/dao/mod.rs | 2 + src-tauri/src/database/dao/provider_write.rs | 118 +- .../dao/provider_write_certification.rs | 2353 +++++++++++++++++ src-tauri/src/database/dao/providers.rs | 30 +- src-tauri/src/database/tests.rs | 2 + src-tauri/src/deeplink/tests.rs | 2 + src-tauri/src/error.rs | 5 + src-tauri/src/provider.rs | 66 + src-tauri/src/services/provider/live.rs | 54 +- src-tauri/src/services/provider/mod.rs | 89 +- src-tauri/src/services/proxy.rs | 67 +- tests/fixtures/pi/provider-write-api-v1.json | 2 +- 15 files changed, 2763 insertions(+), 109 deletions(-) create mode 100644 docs/pi-support-restructure-zh.md create mode 100644 src-tauri/src/database/dao/provider_write_certification.rs diff --git a/docs/pi-support-restructure-zh.md b/docs/pi-support-restructure-zh.md new file mode 100644 index 000000000..2273fb13f --- /dev/null +++ b/docs/pi-support-restructure-zh.md @@ -0,0 +1,57 @@ +# Pi 支持项目级重启裁决:前置工程结构与测试先行 + +> 文档状态:项目级裁决(用户 2026-08-01 批准),优先级高于既有契约、修正案 1/2 的交付与审查结构条款;契约的技术条款(不变量、类型、义务)继续有效。 +> 触发:R4 未通过,三个 invariant 家族第四次出现,按修正案 2 §G 项目级硬停止。 +> 裁决依据:四轮证据表明问题不再是契约缺失或未落实(R4 时 oracle 已真实、类型屏障已就位),而是**认证单元过大**(12k 行 × 高不变量密度 × 零 High 标准 = 每轮必有新 High)与**实现方稳定的"九成对一成微妙错"率**共同作用。修复对象是认证结构与工作方式,不是再写一份修正案。 + +## 1. 新结构:三个前置工程 + 主工程,认证单元 ≠ PR 数量 + +单体累积认证(检查点 A/B/C)废止。改为: + +| 工程 | 范围 | 认证对象 | 预算 | +|---|---|---|---| +| 前置 A | Provider 类型化写面 + endpoint 所有权(全 app) | `dao/provider_write.rs`、相关 service 入口、扫描器及其测试 | 2–3 轮 | +| 前置 B | Data-only canonical restore(含 R4 新发现:safety backup 并发丢写窗口、binary TOCTOU、incremental auto-vacuum 保留) | `database/backup.rs`、`schema.rs` 的 restore 面及测试 | 2–3 轮 | +| 前置 C | 只读 native inspection(完全由 pinned Pi 语义向量驱动) | `pi_config/raw_schema.rs`、`composer.rs`、`native.rs`、`gateway.rs`(capability 部分)、oracle 夹具及测试 | 2–3 轮 | +| 主工程 | Skills/Prompts/Sessions/UI/i18n + gateway 数据面 | 建立在已认证前置之上,认证结构届时另定 | 另计 | + +- 每个前置工程是独立认证对象:两位 fresh blind reviewer 审**该组件的完整文件与测试**(组件级审计,不是 diff 审计),小到一轮看得透; +- 依据 handoff §2.2"双盲审数量不等于 PR 数量"与规则 8/9"同一 invariant 重复失败 → 重审 boundary 与 test strategy":重构认证结构正是执行该规则,各前置工程作为新认证对象持有独立小预算,这不是绕过七轮纪律,而是其结论; +- **最终交付仍是一个 PR**(用户要求不变);前置工程在同一分支上以 commit 组推进; +- 任一前置工程在自己的预算内不收敛 → 单独停止上报,不牵连其他工程。 + +## 2. 工作方式反转:测试先行 + +四轮共同病根之一是实现方的字面合规:契约文字与测试之间的任何缝隙都会变成缺陷。自本裁决起: + +1. **Claude 先交付认证级测试套件**:把 R1–R4 审出的全部故障场景 + 契约义务固化为可执行测试(含扫描器负向 fixture);测试即契约的字面; +2. **Codex 实现到全绿**:不得修改认证测试(发现测试本身有错时,报告并由 Claude 裁决修订);可以自由增补自己的测试; +3. 全绿 + 内部自审(修正案 2 §F 对应节)后进入该前置工程的盲审; +4. 盲审 finding 若揭示测试套件的缺口,缺口先补进测试,再修实现——测试套件是活的认证资产,逐工程滚动加厚。 + +## 3. R4 finding 的归属(全部并入对应前置工程的测试范围) + +| Finding | 归属 | +|---|---| +| managed DTO 把 thinkingLevelMap 收窄为 `Option`,oracle 证明必须无损 | 前置 C | +| gateway 把 Authorization/x-api-key/x-goog-api-key 列为 protected 并强制 apiKey,header-auth 配置误降 DirectOnly | 前置 C | +| restore 行校验只查 storage class/JSON/decimal,`sort_index=-1` 可发布后生产读取失败 | 前置 B | +| safety backup 与 publish 之间并发丢写窗口;binary restore TOCTOU;canonical stage 丢 incremental auto-vacuum | 前置 B | +| `update_provider_settings_config` 绕过类型化写面且零行静默成功 | 前置 A | +| `reconcile_provider_record` 先查存在再分支,并发 create 冲突退化为覆盖更新 | 前置 A | + +结构教训一并纳入:**DML allowlist 必须缩到 `provider_write.rs` 模块级**(R4 逃逸正是因为按文件豁免了整个旧 DAO);"252/252 字段执行过"不等于下游语义正确,前置 C 必须有 oracle→managed→inspection 的**端到端无损往返**测试。 + +## 4. 盲审材料与验证(每个前置工程) + +- 材料:该组件的规范契约节选(中立重生成)、组件文件清单、oracle/provenance(前置 C)、验证命令、通用审查维度;不含任何轮次历史、finding、自审报告; +- 验证:`cargo fmt --all -- --check`、**`cargo clippy --lib --tests -- -D warnings`(必须带 `--tests`,认证模块是 `#[cfg(test)]`,不带就不编译它)**、`cargo test --lib` 全套 + 组件认证测试全绿 + 扫描器负向 fixture 红名单确认,均在干净 SHA 上; +- 完成条件:零 validated blocker/high/data-integrity finding。 + +> 文档考据说明:修正案 1/2(`pi-support-contracts-amendment-*.md`)的条款已按其自身要求**合并**进 `pi-support-contracts-zh.md` 与 `pi-support-review-contract-zh.md`,独立文件已随合并删除,这是预期状态而非丢失;本文引用的"修正案 2 §F/E1"以合并后的规范文档对应章节为准。 + +## 5. 冻结事实(2026-08-01) + +- 分支 `feat/pi-native-support`,HEAD = 10f2dacb(R4 检查点),工作树干净; +- R1–R4 已耗于已废止的单体认证;前置工程各持新的 2–3 轮预算; +- 未 push、无 PR;`stash@{0}`、`legacy/pi-041ff113`、PR #5598 不触碰。 diff --git a/src-tauri/src/architecture_tests.rs b/src-tauri/src/architecture_tests.rs index 951020060..4705116be 100644 --- a/src-tauri/src/architecture_tests.rs +++ b/src-tauri/src/architecture_tests.rs @@ -1,3 +1,5 @@ +#![cfg(test)] + use regex::Regex; use serde_json::json; use std::collections::{BTreeMap, BTreeSet}; @@ -298,6 +300,11 @@ fn scan_source(path: &str, source: &str) -> (Vec, BTreeSet) { ) } }; + // 文件级 #![cfg(test)] 的文件(认证套件等)不进入任何构建的生产目标, + // 不参与生产扫描;该属性的存在性由认证套件的注册元测试强制。 + if is_cfg_test(&syntax.attrs) { + return (Vec::new(), BTreeSet::new()); + } let mut visitor = ArchitectureVisitor { path, violations: Vec::new(), @@ -345,6 +352,7 @@ fn provider_write_api_snapshot(source: &str) -> serde_json::Value { let syntax = syn::parse_file(source).expect("parse provider write authority"); let type_names = [ "ProviderKey", + "ProviderRowCreate", "ProviderRowUpdate", "NewEndpoint", "NewProviderAggregate", diff --git a/src-tauri/src/codex_history_migration.rs b/src-tauri/src/codex_history_migration.rs index b7a10683e..f4c986ba2 100644 --- a/src-tauri/src/codex_history_migration.rs +++ b/src-tauri/src/codex_history_migration.rs @@ -8,8 +8,9 @@ use crate::codex_config::{ }; use crate::codex_state_db::codex_state_db_paths; use crate::config::{atomic_write, copy_file, get_app_config_dir}; -use crate::database::{is_official_seed_id, Database}; +use crate::database::{is_official_seed_id, Database, ProviderKey, ProviderRowUpdate}; use crate::error::AppError; +use crate::services::provider::provider_to_mutation_input; use crate::settings::{ CodexOfficialHistoryUnifyMigration, CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration, @@ -663,7 +664,7 @@ fn migrate_codex_provider_templates_to_custom( let providers = db.get_all_providers("codex")?; let mut migrated_provider_ids = Vec::new(); - for (_, provider) in providers { + for (_, mut provider) in providers { if provider.category.as_deref() == Some("official") || is_official_seed_id(&provider.id) || provider.is_codex_oauth() @@ -694,8 +695,16 @@ fn migrate_codex_provider_templates_to_custom( }; backup_provider_settings_config(&provider.id, &provider.settings_config, backup_root)?; obj.insert("config".to_string(), Value::String(migrated_config_text)); - db.update_provider_settings_config("codex", &provider.id, &settings)?; - migrated_provider_ids.push(provider.id); + let provider_id = provider.id.clone(); + provider.settings_config = settings; + if let Some(meta) = provider.meta.as_mut() { + meta.custom_endpoints.clear(); + } + let input = provider_to_mutation_input(provider); + let key = ProviderKey::new("codex", &provider_id)?; + let row = ProviderRowUpdate::from_input(&input)?; + db.update_provider(&key, &row)?; + migrated_provider_ids.push(provider_id); } Ok(CodexProviderTemplateBucketMigrationOutcome { diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index b1a7ea941..c0ef811bd 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -8,6 +8,8 @@ pub mod pi_projections; pub mod profiles; pub mod prompts; pub mod provider_write; +#[cfg(test)] +mod provider_write_certification; pub mod providers; pub mod providers_seed; pub mod proxy; diff --git a/src-tauri/src/database/dao/provider_write.rs b/src-tauri/src/database/dao/provider_write.rs index 6b2d0e21a..37b9b8264 100644 --- a/src-tauri/src/database/dao/provider_write.rs +++ b/src-tauri/src/database/dao/provider_write.rs @@ -6,6 +6,8 @@ use rusqlite::{params, OptionalExtension, Transaction}; use serde_json::Value; use std::collections::HashSet; +use super::providers::{StoredProviderRow, PROVIDER_SELECT}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProviderKey { app_type: String, @@ -39,7 +41,6 @@ pub struct ProviderRowUpdate { settings_config: Value, website_url: Option, category: Option, - created_at: Option, notes: Option, meta: ProviderMeta, icon: Option, @@ -60,7 +61,6 @@ impl ProviderRowUpdate { settings_config: input.settings_config.clone(), website_url: input.website_url.clone(), category: input.category.clone(), - created_at: input.created_at, notes: input.notes.clone(), meta, icon: input.icon.clone(), @@ -69,6 +69,12 @@ impl ProviderRowUpdate { } } +#[derive(Debug, Clone)] +pub struct ProviderRowCreate { + content: ProviderRowUpdate, + created_at: Option, +} + #[derive(Debug, Clone)] pub struct NewEndpoint { url: String, @@ -111,7 +117,7 @@ impl TryFrom for NewEndpoint { #[derive(Debug, Clone)] pub struct NewProviderAggregate { key: ProviderKey, - row: ProviderRowUpdate, + row: ProviderRowCreate, sort_index: Option, in_failover_queue: bool, initial_endpoints: Vec, @@ -142,7 +148,10 @@ impl NewProviderAggregate { initial_endpoints.push(endpoint.try_into()?); } let key = ProviderKey::new(app_type, input.id.clone())?; - let row = ProviderRowUpdate::from_input(&input)?; + let row = ProviderRowCreate { + content: ProviderRowUpdate::from_input(&input)?, + created_at: input.created_at, + }; Ok(Self { key, row, @@ -203,6 +212,7 @@ fn insert_row( tx: &Transaction<'_>, key: &ProviderKey, row: &ProviderRowUpdate, + created_at: Option, sort_index: Option, is_current: bool, in_failover_queue: bool, @@ -223,7 +233,7 @@ fn insert_row( settings_config, row.website_url, row.category, - row.created_at, + created_at, sort_index, row.notes, row.icon, @@ -233,7 +243,21 @@ fn insert_row( in_failover_queue, ], ) - .map_err(|error| AppError::Database(error.to_string()))?; + .map_err(|error| match &error { + rusqlite::Error::SqliteFailure(code, _) + if matches!( + code.extended_code, + rusqlite::ffi::SQLITE_CONSTRAINT_PRIMARYKEY + | rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE + ) => + { + AppError::Conflict(format!( + "provider '{}/{}' already exists", + key.app_type, key.id + )) + } + _ => AppError::Database(error.to_string()), + })?; Ok(()) } @@ -262,21 +286,38 @@ fn insert_endpoint( /// catalog compensation coordinator introduced with the ordered mutation /// pipeline is the only intended caller. #[allow(dead_code)] +// The certification contract keeps immutable creation time separate from the +// mutable row DTO and calls this sealed helper directly with the full snapshot. +#[allow(clippy::too_many_arguments)] pub(super) fn restore_provider_aggregate_on_tx( tx: &Transaction<'_>, key: &ProviderKey, row: &ProviderRowUpdate, + created_at: Option, sort_index: Option, is_current: bool, in_failover_queue: bool, endpoints: &[NewEndpoint], ) -> Result<(), AppError> { let updated = update_row(tx, key, row)?; - if updated != 1 { - return Err(AppError::NotFound(format!( - "provider '{}/{}'", - key.app_type, key.id - ))); + if updated == 0 { + insert_row( + tx, + key, + row, + created_at, + sort_index, + is_current, + in_failover_queue, + )?; + } else { + // Exact compensation is the only path allowed to restore immutable + // creation time after a prior aggregate mutation. + tx.execute( + "UPDATE providers SET created_at = ?1 WHERE id = ?2 AND app_type = ?3", + params![created_at, key.id, key.app_type], + ) + .map_err(|error| AppError::Database(error.to_string()))?; } tx.execute( "DELETE FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2", @@ -317,18 +358,16 @@ fn update_row( settings_config = ?2, website_url = ?3, category = ?4, - created_at = ?5, - notes = ?6, - icon = ?7, - icon_color = ?8, - meta = ?9 - WHERE id = ?10 AND app_type = ?11", + notes = ?5, + icon = ?6, + icon_color = ?7, + meta = ?8 + WHERE id = ?9 AND app_type = ?10", params![ row.name, settings_config, row.website_url, row.category, - row.created_at, row.notes, row.icon, row.icon_color, @@ -349,7 +388,8 @@ impl Database { insert_row( &tx, &input.key, - &input.row, + &input.row.content, + input.row.created_at, input.sort_index, false, input.in_failover_queue, @@ -380,6 +420,42 @@ impl Database { .map_err(|error| AppError::Database(error.to_string())) } + pub(crate) fn update_provider_if_content_fingerprint( + &self, + key: &ProviderKey, + expected_fingerprint: &str, + row: &ProviderRowUpdate, + ) -> Result<(), AppError> { + let mut conn = lock_conn!(self.conn); + let tx = conn + .transaction() + .map_err(|error| AppError::Database(error.to_string()))?; + let current = tx + .query_row( + &format!("{PROVIDER_SELECT} WHERE id = ?1 AND app_type = ?2"), + params![key.id, key.app_type], + StoredProviderRow::from_row, + ) + .optional() + .map_err(|error| AppError::Database(error.to_string()))? + .ok_or_else(|| AppError::NotFound(format!("provider '{}/{}'", key.app_type, key.id)))? + .decode(key.app_type())?; + if current.row_content_fingerprint() != expected_fingerprint { + return Err(AppError::Conflict(format!( + "provider '{}/{}' changed since it was read", + key.app_type, key.id + ))); + } + if update_row(&tx, key, row)? != 1 { + return Err(AppError::NotFound(format!( + "provider '{}/{}'", + key.app_type, key.id + ))); + } + tx.commit() + .map_err(|error| AppError::Database(error.to_string())) + } + pub fn rename_db_only_additive_provider(&self, input: RenameProvider) -> Result<(), AppError> { let mut conn = lock_conn!(self.conn); let tx = conn @@ -387,7 +463,7 @@ impl Database { .map_err(|error| AppError::Database(error.to_string()))?; let source_state = tx .query_row( - "SELECT sort_index, is_current, in_failover_queue, category + "SELECT sort_index, is_current, in_failover_queue, category, created_at FROM providers WHERE id = ?1 AND app_type = ?2", params![input.source.id, input.source.app_type], @@ -397,6 +473,7 @@ impl Database { row.get::<_, bool>(1)?, row.get::<_, bool>(2)?, row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, )) }, ) @@ -418,6 +495,7 @@ impl Database { &tx, &target, &input.row, + source_state.4, source_state.0, source_state.1, source_state.2, diff --git a/src-tauri/src/database/dao/provider_write_certification.rs b/src-tauri/src/database/dao/provider_write_certification.rs new file mode 100644 index 000000000..14e876af9 --- /dev/null +++ b/src-tauri/src/database/dao/provider_write_certification.rs @@ -0,0 +1,2353 @@ +#![cfg(test)] +//! 前置工程 A:Provider 写面认证测试套件 v5(测试先行) +//! +//! 本文件是认证契约的可执行字面,固化 R1–R4 盲审与三轮对抗审查揭示的全部 +//! 写面故障场景。规则(见 docs/pi-support-restructure-zh.md): +//! - 实现方不得修改本文件;认为某测试有误时,停止并上报裁决,不得绕过; +//! - 全绿是进入前置 A 盲审的前置条件,但不是充分条件; +//! - 对写面(provider_write.rs)新增任何函数、对 infra 三文件的任何改动、 +//! 对本文件清单的任何调整,均须先经裁决。 +//! +//! ## 已裁决的语义决定 +//! 1. `created_at` 不可变:update 不得改写创建时间;`ProviderRowUpdate` +//! 必须不含 `created_at` 字段。create/restore 所需创建时间由各自入参单独 +//! 携带(restore 已由裁决方加 `created_at` 参数;create 拆分是实现方职责)。 +//! `tests/fixtures/pi/provider-write-api-v1.json` 与 +//! `architecture_tests.rs` 的 snapshot 生成器随 DTO 拆分同步更新(生成器 +//! 必须收录新建的 create/restore 专属类型)——这是实现方义务;拆分落地前 +//! 旧 fixture 保持一致属预期。 +//! 2. 结构化冲突:重复 create(含并发输家)必须返回 `AppError::Conflict`。 +//! 注:`AppError` 的 `Serialize` 目前把错误序列化为字符串,IPC 层的结构化 +//! discriminant 是后续裁决项(P1),不在本 PR 强制。 +//! 3. reconcile 显式前置期望(T9):脚手架已落地(`ReconcilePrecondition`、 +//! `provider_row_fingerprint`(规范化排序哈希,不含 endpoint)、 +//! `reconcile_provider_record_with_precondition`,故意保留旧语义使 T9 红)。 +//! 实现方必须以**单事务原语**实现:ExpectAbsent → `create_provider` +//! (冲突 → Conflict);ExpectPresent → 新 DAO 原语 +//! `update_provider_if_content_fingerprint`(单事务内读-比-写,过期 → +//! Conflict)。reconcile 函数体内禁止内联 aggregate 读取后再分支 +//! (`certify_reconcile_uses_single_transaction_primitives` 机械强制)。 +//! **盲审重点核查项**:`update_provider_if_content_fingerprint` 内部必须 +//! 在单次连接锁/单事务内完成读-比-写(本仓库为单连接 Mutex,持锁即全局 +//! 串行);静态测试只能约束委托关系,原语内部"读后释放锁再写"的变体由 +//! 组件盲审逐行核查——reviewer 材料必须包含本条。 +//! 完成后迁移全部调用方并删除旧 `reconcile_provider_record`,由裁决方将 +//! 旧符号加入禁止清单。 +//! 4. infra 时序缺口机械冻结:`schema.rs`/`migration.rs`/`backup.rs` 由 +//! SHA-256 基线钉死;任何变更使前置 A 认证失效,须回裁决方重审。 +//! +//! ## 扫描器 authority 表(精确相对路径 × DML 种类 × 列集合) +//! - `database/dao/provider_write.rs`:全部 provider DML 允许(写面本体); +//! - `database/dao/providers.rs`:仅 `UPDATE providers`(列 ⊆ {is_current}) +//! 与 `DELETE FROM providers`; +//! - `database/dao/failover.rs`:仅 `UPDATE providers`(列 ⊆ {in_failover_queue}); +//! - infra 三文件:Deferred to 前置工程 B,由 SHA-256 基线冻结兜底; +//! - 测试专属文件必须自带文件级 `#![cfg(test)]`(注册元测试机械强制;借用 +//! 他处注册的伪测试名生产文件在此失败),扫描器凭该属性天然跳过其内容; +//! - cfg 判定按布尔语义:仅当谓词蕴含 test 才跳过;`not(test)`、 +//! `any(test, unix)` 一律扫描; +//! - 宏 token 纳入扫描:宏内字符串经 `syn::LitStr::value()` 解码(覆盖 +//! `\xNN`/`\u{}`);纯字符串宏(`concat!`)拼接整体参与分类;含非字面量 +//! token 且出现 provider DML 锚点的宏(`format!`、`stringify!` 构造) +//! 一律 fail-closed 记为违规;`include!` 全生产源禁止,`include_str!` +//! token 含 `.sql` 时禁止; +//! - 解析 fail-closed:SET 子句引号/括号不闭合或列集无法确定时产出 +//! `!unparseable` 哨兵列,任何 authority 不放行; +//! - 已知残余风险(接受,由盲审与前置 B 兜底,须向 reviewer 声明): +//! 完全运行时构造、无任何可识别字面锚点的动态 SQL;trigger/view 间接写; +//! SQLite Backup API 整库复制;`r#"..."#` 多井号原始字符串宏字面量; +//! `#[path]`/非 `.rs` 重定向包含;非 `.sql` 扩展名文件装载 SQL 文本; +//! 宏展开生成的 impl(inventory 已禁 item 级宏与 out-of-line 子模块, +//! 属性宏路径由盲审兜底);T10 的 identifier 探测只证明"标识符存在", +//! 真实调用行为由盲审核对。 +//! 本清单为对抗加固的**收口边界**:静态扫描是护栏,组件盲审才是认证; +//! 清单外的新绕过按盲审 finding 处理,不再无限扩充扫描器。 +//! +//! ## 交接时的预期红绿(实现方修复目标) +//! 应红 11:column_authority(providers.rs 越权 settings_config)、 +//! forbidden_symbols(update_provider_settings_config 定义+调用点)、 +//! update_dto_has_no_created_at、duplicate_create_returns_structured_conflict、 +//! update_cannot_change_created_at、delete_compensation_recreates(restore +//! 无法重建已删行)、delete_compensation_failure(应败于注入而非 NotFound)、 +//! reconcile_expect_absent_loser、reconcile_expect_present_stale_fingerprint、 +//! write_surface_symbol_inventory(缺 update_provider_if_content_fingerprint +//! 原语)、reconcile_uses_single_transaction_primitives(脚手架委托旧语义)。 +//! 其余应绿;任何偏离(非清单红、应红变绿)都必须上报,不得自行解释。 + +use crate::database::dao::provider_write::{ + self, NewEndpoint, NewProviderAggregate, ProviderKey, ProviderRowUpdate, RenameProvider, +}; +use crate::database::Database; +use crate::error::AppError; +use crate::provider::{ProviderMeta, ProviderMutationInput}; +use crate::services::provider::{ + provider_row_fingerprint, reconcile_provider_record_with_precondition, ReconcilePrecondition, +}; +use crate::settings::CustomEndpoint; +use regex::Regex; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeSet, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; +use syn::visit::{self, Visit}; +use syn::{Attribute, ExprLit, ImplItem, Item, Lit, Meta}; + +// --------------------------------------------------------------------------- +// 测试基建 +// --------------------------------------------------------------------------- + +fn db() -> Database { + Database::memory().expect("memory db") +} + +fn base_input(id: &str, name: &str) -> ProviderMutationInput { + ProviderMutationInput { + id: id.to_string(), + name: name.to_string(), + settings_config: json!({"env": {"KEY": "v"}}), + website_url: None, + category: None, + created_at: Some(1_700_000_000), + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + } +} + +fn with_endpoints( + mut input: ProviderMutationInput, + endpoints: &[(&str, Option, Option)], +) -> ProviderMutationInput { + let mut map = HashMap::new(); + for (url, added_at, last_used) in endpoints { + map.insert( + url.to_string(), + CustomEndpoint { + url: url.to_string(), + added_at: *added_at, + last_used: *last_used, + }, + ); + } + let mut meta = input.meta.take().unwrap_or_default(); + meta.custom_endpoints = map; + input.meta = Some(meta); + input +} + +type RowSnapshot = ( + String, // name + String, // settings_config + Option, // website_url + Option, // category + Option, // created_at + Option, // sort_index + Option, // notes + Option, // icon + Option, // icon_color + String, // meta + i64, // is_current + i64, // in_failover_queue +); + +type EndpointRows = Vec<(String, Option, Option)>; + +/// 逐列快照,用于"零副作用"断言。绕过 hydration 直接读库,以免 hydration +/// 自身的有损转换掩盖破坏;查询错误必须炸出来,不得伪装成"不存在"。 +fn snapshot(database: &Database, app_type: &str, id: &str) -> (Option, EndpointRows) { + use rusqlite::OptionalExtension; + let conn = database.conn.lock().expect("lock certification database"); + let row = conn + .query_row( + "SELECT name, settings_config, website_url, category, created_at, + sort_index, notes, icon, icon_color, meta, is_current, in_failover_queue + FROM providers WHERE id = ?1 AND app_type = ?2", + rusqlite::params![id, app_type], + |r| { + Ok(( + r.get(0)?, + r.get(1)?, + r.get(2)?, + r.get(3)?, + r.get(4)?, + r.get(5)?, + r.get(6)?, + r.get(7)?, + r.get(8)?, + r.get(9)?, + r.get(10)?, + r.get(11)?, + )) + }, + ) + .optional() + .expect("snapshot row query must not error"); + let mut stmt = conn + .prepare( + "SELECT url, added_at, last_used FROM provider_endpoints + WHERE provider_id = ?1 AND app_type = ?2 ORDER BY url", + ) + .expect("prepare endpoint snapshot"); + let endpoints = stmt + .query_map(rusqlite::params![id, app_type], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?)) + }) + .expect("query endpoints") + .collect::, _>>() + .expect("collect endpoints"); + (row, endpoints) +} + +const ENDPOINT_REJECT_MESSAGE: &str = "certification injected endpoint failure"; + +fn install_endpoint_reject_trigger(database: &Database) { + let conn = database.conn.lock().expect("lock certification database"); + conn.execute_batch( + "CREATE TRIGGER certification_reject_endpoint_insert + BEFORE INSERT ON provider_endpoints + BEGIN SELECT RAISE(ABORT, 'certification injected endpoint failure'); END;", + ) + .expect("install endpoint reject trigger"); +} + +fn source_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("src") +} + +fn relative_source_path(root: &Path, file: &Path) -> String { + file.strip_prefix(root) + .expect("source file under root") + .to_string_lossy() + .replace('\\', "/") +} + +fn collect_rs_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_rs_files(&path, out); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { + out.push(path); + } + } +} + +// --------------------------------------------------------------------------- +// cfg 布尔语义:仅当谓词蕴含 test 才视为 test-only +// --------------------------------------------------------------------------- + +fn split_top_level(args: &str) -> Vec { + let mut parts = Vec::new(); + let mut depth: i32 = 0; + let mut current = String::new(); + for c in args.chars() { + match c { + '(' => { + depth += 1; + current.push(c); + } + ')' => { + depth -= 1; + current.push(c); + } + ',' if depth == 0 => { + parts.push(current.trim().to_string()); + current = String::new(); + } + _ => current.push(c), + } + } + if !current.trim().is_empty() { + parts.push(current.trim().to_string()); + } + parts +} + +fn strip_call<'a>(expr: &'a str, name: &str) -> Option<&'a str> { + let rest = expr.strip_prefix(name)?.trim_start(); + let rest = rest.strip_prefix('(')?; + rest.strip_suffix(')') +} + +/// `test` → true;`all(..)` 任一分支蕴含 test → true;`any(..)` 需全部分支 +/// 蕴含 test;`not(..)` 与其他谓词一律 false(保守:继续扫描)。 +fn cfg_expr_requires_test(expr: &str) -> bool { + let expr = expr.trim(); + if expr == "test" { + return true; + } + if let Some(args) = strip_call(expr, "all") { + return split_top_level(args) + .iter() + .any(|part| cfg_expr_requires_test(part)); + } + if let Some(args) = strip_call(expr, "any") { + let parts = split_top_level(args); + return !parts.is_empty() && parts.iter().all(|part| cfg_expr_requires_test(part)); + } + false +} + +fn attrs_mark_test_only(attrs: &[Attribute]) -> bool { + attrs.iter().any(|attribute| { + attribute.path().is_ident("cfg") + && matches!( + &attribute.meta, + Meta::List(list) if cfg_expr_requires_test(&list.tokens.to_string()) + ) + }) +} + +// --------------------------------------------------------------------------- +// 扫描器 v4:syn AST(含宏 token)+ 列敏感 DML 分类 +// --------------------------------------------------------------------------- + +const STATE_COLUMNS_PROVIDERS_RS: [&str; 1] = ["is_current"]; +const STATE_COLUMNS_FAILOVER_RS: [&str; 1] = ["in_failover_queue"]; +const INFRA_DEFERRED_TO_B: [(&str, &str); 3] = [ + ( + "database/schema.rs", + "f884c15ee5447f28c321a4fef0c1c50010833d7c05f2e5d9530961f5ca17ed27", + ), + ( + "database/migration.rs", + "7e9125e55ca75837242225b462707ad4cde1d0ebfb09c4d66cd4ccc950360bfa", + ), + ( + "database/backup.rs", + "31c42a61d43395e09751ffc413a822d034274e092d38c446f9ae89a5fc86f249", + ), +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Dml { + Insert { table: String }, + Delete { table: String }, + Update { table: String, columns: Vec }, +} + +/// 表 token:引号成对匹配的交替(裸形式带 `\b`)。R8 终审:引号表名在 +/// "可选闭引号 + \b" 的写法上必然失配,必须成对交替。 +const TABLE_TOKEN: &str = r#"("provider_endpoints"|"providers"|'provider_endpoints'|'providers'|`provider_endpoints`|`providers`|\[provider_endpoints\]|\[providers\]|provider_endpoints\b|providers\b)"#; +const NAME_PREFIX: &str = + r#"(?:(?:"(?:[^"]|"")*"|'(?:[^']|'')*'|`[^`]*`|\[[^\]]*\]|\w+)\s*\.\s*)?"#; +const NAME_TOKEN: &str = r#"(?:"(?:[^"]|"")*"|'(?:[^']|'')*'|`[^`]*`|\[[^\]]*\]|\w+)"#; + +fn table_from_capture(raw: &str) -> String { + raw.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_') + .to_lowercase() +} + +static INSERT_HEAD: LazyLock = LazyLock::new(|| { + Regex::new(&format!( + r#"(?is)\b(?:REPLACE|INSERT(?:\s+OR\s+(?:ABORT|FAIL|IGNORE|REPLACE|ROLLBACK))?)\s+INTO\s+{NAME_PREFIX}{TABLE_TOKEN}"# + )) + .expect("compile insert head") +}); +static DELETE_HEAD: LazyLock = LazyLock::new(|| { + Regex::new(&format!( + r#"(?is)\bDELETE\s+FROM\s+{NAME_PREFIX}{TABLE_TOKEN}"# + )) + .expect("compile delete head") +}); +static UPDATE_HEAD: LazyLock = LazyLock::new(|| { + Regex::new(&format!( + r#"(?is)\bUPDATE(?:\s+OR\s+(?:ABORT|FAIL|IGNORE|REPLACE|ROLLBACK))?\s+{NAME_PREFIX}{TABLE_TOKEN}(?:\s+(?:AS\s+{NAME_TOKEN}|NOT\s+INDEXED|INDEXED\s+BY\s+{NAME_TOKEN}|{NAME_TOKEN}))*?\s+SET\b"# + )) + .expect("compile update head") +}); +static MACRO_STRING: LazyLock = LazyLock::new(|| { + Regex::new(r#""(?:[^"\\]|\\.)*"|r"[^"]*""#).expect("compile macro string extractor") +}); +static FORBIDDEN_SYMBOL: LazyLock = LazyLock::new(|| { + Regex::new(r"\bupdate_provider_settings_config\b").expect("compile forbidden symbol") +}); + +fn contains_provider_dml_anchor(text: &str) -> bool { + INSERT_HEAD.is_match(text) || DELETE_HEAD.is_match(text) || UPDATE_HEAD.is_match(text) +} + +/// 去掉 SQL 注释;引号感知(单引号/双引号/反引号/方括号内的 `--`、`/*` +/// 不是注释)。 +fn strip_sql_comments(sql: &str) -> String { + let mut out = String::with_capacity(sql.len()); + let bytes = sql.as_bytes(); + let mut i = 0; + let mut quote: Option = None; + while i < bytes.len() { + let b = bytes[i]; + if let Some(q) = quote { + out.push(b as char); + let closing = match q { + b'[' => b']', + other => other, + }; + if b == closing { + quote = None; + } + i += 1; + continue; + } + match b { + b'\'' | b'"' | b'`' | b'[' => { + quote = Some(b); + out.push(b as char); + i += 1; + } + b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => { + i += 2; + while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(bytes.len()); + out.push(' '); + } + b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + } + _ => { + out.push(b as char); + i += 1; + } + } + } + out +} + +const UNPARSEABLE: &str = "!unparseable"; + +/// SET 列解析:括号深度 + 四类引号感知,顶层 `WHERE`(前一字符不得是 +/// `:@$?` 参数记号)或 `;` 终止;列名剥离 alias 前缀与引号;引号/括号 +/// 不闭合或列集为空时 fail-closed 产出哨兵列。 +fn parse_set_columns(tail: &str) -> Vec { + let upper = tail.to_ascii_uppercase(); + let bytes = upper.as_bytes(); + let raw = tail.as_bytes(); + let mut depth: i32 = 0; + let mut quote: Option = None; + let mut end = tail.len(); + let mut i = 0; + while i < bytes.len() { + let b = raw[i]; + if let Some(q) = quote { + let closing = match q { + b'[' => b']', + other => other, + }; + if b == closing { + quote = None; + } + i += 1; + continue; + } + match b { + b'\'' | b'"' | b'`' | b'[' => quote = Some(b), + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth < 0 { + return vec![UNPARSEABLE.to_string()]; + } + } + b'W' | b'w' if depth == 0 => { + let prev = if i == 0 { b' ' } else { raw[i - 1] }; + let boundary_before = !(prev.is_ascii_alphanumeric() + || prev == b'_' + || matches!(prev, b':' | b'@' | b'$' | b'?')); + if boundary_before && upper[i..].starts_with("WHERE") { + let after = i + 5; + let boundary_after = after >= bytes.len() + || !(bytes[after].is_ascii_alphanumeric() || bytes[after] == b'_'); + if boundary_after { + end = i; + break; + } + } + } + b';' if depth == 0 => { + end = i; + break; + } + _ => {} + } + i += 1; + } + if quote.is_some() || depth != 0 { + return vec![UNPARSEABLE.to_string()]; + } + let clause = &tail[..end]; + let mut columns = Vec::new(); + let mut segment_start = 0; + let mut depth: i32 = 0; + let mut quote: Option = None; + let clause_bytes = clause.as_bytes(); + let push_segment = |segment: &str, columns: &mut Vec| { + if let Some(identifier) = segment.split('=').next() { + let identifier = identifier + .trim() + .rsplit('.') + .next() + .unwrap_or("") + .trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_') + .to_lowercase(); + if !identifier.is_empty() { + columns.push(identifier); + } + } + }; + for (i, &b) in clause_bytes.iter().enumerate() { + if let Some(q) = quote { + let closing = match q { + b'[' => b']', + other => other, + }; + if b == closing { + quote = None; + } + continue; + } + match b { + b'\'' | b'"' | b'`' | b'[' => quote = Some(b), + b'(' => depth += 1, + b')' => depth -= 1, + b',' if depth == 0 => { + push_segment(&clause[segment_start..i], &mut columns); + segment_start = i + 1; + } + _ => {} + } + } + push_segment(&clause[segment_start..], &mut columns); + if columns.is_empty() { + return vec![UNPARSEABLE.to_string()]; + } + columns +} + +fn classify_sql(literal: &str) -> Vec { + let sql = strip_sql_comments(literal); + let mut found = Vec::new(); + for capture in INSERT_HEAD.captures_iter(&sql) { + found.push(Dml::Insert { + table: table_from_capture(&capture[1]), + }); + } + for capture in DELETE_HEAD.captures_iter(&sql) { + found.push(Dml::Delete { + table: table_from_capture(&capture[1]), + }); + } + for capture in UPDATE_HEAD.captures_iter(&sql) { + let whole = capture.get(0).expect("capture 0"); + found.push(Dml::Update { + table: table_from_capture(&capture[1]), + columns: parse_set_columns(&sql[whole.end()..]), + }); + } + found +} + +#[derive(Default)] +struct ProductionCollector { + literals: Vec, + ident_text: String, + macro_violations: Vec, +} + +impl ProductionCollector { + fn record_macro_tokens(&mut self, macro_name: &str, tokens: &str) { + self.ident_text.push_str(tokens); + self.ident_text.push(' '); + + if macro_name == "include" { + self.macro_violations + .push("include! smuggles unscanned production code".to_string()); + } + + let mut pieces = Vec::new(); + let mut stripped = String::with_capacity(tokens.len()); + let mut cursor = 0; + for matched in MACRO_STRING.find_iter(tokens) { + stripped.push_str(&tokens[cursor..matched.start()]); + cursor = matched.end(); + let raw = matched.as_str(); + // 经 syn 解码转义(覆盖 \xNN、\u{});r"..." 直接取内容。 + let value = syn::parse_str::(raw) + .map(|lit| lit.value()) + .unwrap_or_else(|_| { + raw.trim_start_matches("r\"") + .trim_start_matches('"') + .trim_end_matches('"') + .to_string() + }); + self.literals.push(value.clone()); + pieces.push(value); + } + stripped.push_str(&tokens[cursor..]); + // include_str!/include_bytes! 的 .sql 判定作用于原 token 与拼接体 + // (覆盖 concat!("query.", "sql") 拆分),大小写不敏感。 + let joined_pieces = pieces.join(""); + if matches!(macro_name, "include_str" | "include_bytes") + && (tokens.to_ascii_lowercase().contains(".sql") + || joined_pieces.to_ascii_lowercase().contains(".sql")) + { + self.macro_violations + .push(format!("{macro_name}! loads external SQL")); + } + // 纯字面量:剥离字符串后仅剩标点,且字面量内无 format 插值花括号 + // (`format!("... SET {col} = ...")` 的隐式捕获只有一个字符串 token, + // 必须按非纯字面量处理——R9 终审绕过)。 + let pure_literal = stripped + .chars() + .all(|c| c.is_whitespace() || c == ',' || c == '(' || c == ')') + && !pieces.iter().any(|piece| piece.contains('{')); + + if pure_literal { + // concat! 相邻拼接:拼接体整体参与常规分类。 + if pieces.len() > 1 { + self.literals.push(pieces.join("")); + } + } else { + // 含非字面量 token 的宏(format!/stringify! 构造):一旦出现 + // provider DML 锚点即 fail-closed,不猜插值后的语义。 + let joined = pieces.join(""); + if contains_provider_dml_anchor(tokens) + || contains_provider_dml_anchor(&joined) + || pieces.iter().any(|p| contains_provider_dml_anchor(p)) + { + self.macro_violations.push(format!( + "{macro_name}! builds provider DML from non-literal tokens" + )); + } + } + } +} + +impl<'ast> Visit<'ast> for ProductionCollector { + fn visit_item(&mut self, item: &'ast Item) { + let attrs = match item { + Item::Const(item) => Some(&item.attrs), + Item::Enum(item) => Some(&item.attrs), + Item::Fn(item) => Some(&item.attrs), + Item::Impl(item) => Some(&item.attrs), + Item::Macro(item) => Some(&item.attrs), + Item::Mod(item) => Some(&item.attrs), + Item::Static(item) => Some(&item.attrs), + Item::Struct(item) => Some(&item.attrs), + Item::Trait(item) => Some(&item.attrs), + Item::Type(item) => Some(&item.attrs), + Item::Union(item) => Some(&item.attrs), + Item::Use(item) => Some(&item.attrs), + _ => None, + }; + if attrs.is_some_and(|attrs| attrs_mark_test_only(attrs)) { + return; + } + visit::visit_item(self, item); + } + + fn visit_impl_item(&mut self, item: &'ast ImplItem) { + let attrs = match item { + ImplItem::Const(item) => Some(&item.attrs), + ImplItem::Fn(item) => Some(&item.attrs), + ImplItem::Type(item) => Some(&item.attrs), + ImplItem::Macro(item) => Some(&item.attrs), + _ => None, + }; + if attrs.is_some_and(|attrs| attrs_mark_test_only(attrs)) { + return; + } + visit::visit_impl_item(self, item); + } + + fn visit_expr_lit(&mut self, expression: &'ast ExprLit) { + if let Lit::Str(literal) = &expression.lit { + self.literals.push(literal.value()); + } + visit::visit_expr_lit(self, expression); + } + + fn visit_ident(&mut self, identifier: &'ast syn::Ident) { + self.ident_text.push_str(&identifier.to_string()); + self.ident_text.push(' '); + } + + // syn 默认不遍历宏 token:自行提取字符串与符号。 + fn visit_macro(&mut self, mac: &'ast syn::Macro) { + let name = mac + .path + .segments + .last() + .map(|segment| segment.ident.to_string()) + .unwrap_or_default(); + self.record_macro_tokens(&name, &mac.tokens.to_string()); + } +} + +fn collect_production(source: &str) -> Result { + let syntax = syn::parse_file(source).map_err(|error| error.to_string())?; + if attrs_mark_test_only(&syntax.attrs) { + return Ok(ProductionCollector::default()); + } + let mut collector = ProductionCollector::default(); + collector.visit_file(&syntax); + Ok(collector) +} + +fn is_test_convention_file(relative: &str) -> bool { + let stem = Path::new(relative) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + stem == "tests" || stem.ends_with("_tests") || stem.ends_with("_certification") +} + +fn is_infra_file(relative: &str) -> bool { + INFRA_DEFERRED_TO_B + .iter() + .any(|(path, _)| *path == relative) +} + +/// authority 判定使用精确相对路径,杜绝 `ends_with` 伪路径冒充。 +fn dml_allowed(relative: &str, dml: &Dml) -> bool { + match relative { + "database/dao/provider_write.rs" => true, + "database/dao/providers.rs" => match dml { + Dml::Delete { table } => table == "providers", + Dml::Update { table, columns } => { + table == "providers" + && !columns.is_empty() + && columns + .iter() + .all(|c| STATE_COLUMNS_PROVIDERS_RS.contains(&c.as_str())) + } + Dml::Insert { .. } => false, + }, + "database/dao/failover.rs" => match dml { + Dml::Update { table, columns } => { + table == "providers" + && !columns.is_empty() + && columns + .iter() + .all(|c| STATE_COLUMNS_FAILOVER_RS.contains(&c.as_str())) + } + _ => false, + }, + _ => false, + } +} + +#[test] +fn certify_provider_dml_column_authority() { + let root = source_root(); + let mut files = Vec::new(); + collect_rs_files(&root, &mut files); + assert!( + files.len() > 100, + "scanner must see the full source tree, found only {} files", + files.len() + ); + let mut violations = Vec::new(); + for file in &files { + let relative = relative_source_path(&root, file); + if is_infra_file(&relative) { + continue; + } + let source = fs::read_to_string(file).expect("read source file"); + let collector = match collect_production(&source) { + Ok(collector) => collector, + Err(error) => { + violations.push(format!("{relative}: syn parse error: {error}")); + continue; + } + }; + for literal in &collector.literals { + for dml in classify_sql(literal) { + if !dml_allowed(&relative, &dml) { + violations.push(format!("{relative}: {dml:?}")); + } + } + } + for violation in &collector.macro_violations { + violations.push(format!("{relative}: {violation}")); + } + } + assert!( + violations.is_empty(), + "provider DML outside the column-granular authority table:\n{}", + violations.join("\n") + ); +} + +#[test] +fn certify_forbidden_symbols_are_zero_treewide() { + // update_provider_settings_config 是 R4 认定的绕面 mutator:目标是符号 + // 全树归零(定义与调用点一并消失),不是把它搬进写面文件让扫描器沉默。 + // 宏 token 中的出现同样命中(macro_rules 隐藏)。 + let root = source_root(); + let mut files = Vec::new(); + collect_rs_files(&root, &mut files); + let mut hits = Vec::new(); + for file in &files { + let relative = relative_source_path(&root, file); + let source = fs::read_to_string(file).expect("read source file"); + if collect_production(&source) + .is_ok_and(|collector| FORBIDDEN_SYMBOL.is_match(&collector.ident_text)) + { + hits.push(relative.clone()); + } + } + assert!( + hits.is_empty(), + "forbidden mutator symbol still present in: {hits:?}" + ); +} + +#[test] +fn certify_infra_files_frozen_until_preproject_b() { + // infra 时序缺口的机械冻结:前置 B 认证前,这三个文件的任何字节变更都 + // 使前置 A 失效。变更必须回裁决方重审并更新基线。 + let root = source_root(); + for (relative, expected) in INFRA_DEFERRED_TO_B { + let bytes = fs::read(root.join(relative)).expect("read infra file"); + let actual = format!("{:x}", Sha256::digest(&bytes)); + assert_eq!( + actual, expected, + "{relative} changed while deferred to prerequisite B; \ + pre-A certification is invalidated, escalate for adjudication" + ); + } +} + +#[test] +fn certify_write_surface_symbol_inventory_is_frozen() { + // 写面的公开形状 = 契约:递归全模块,仅统计 inherent `impl Database` + // 方法与自由函数。update_provider_if_content_fingerprint 是裁决 3 要求的 + // 单事务读-比-写原语,当前缺失 → 本测试红,由实现方补齐。 + fn walk( + items: &[Item], + impl_fns: &mut BTreeSet, + free_fns: &mut BTreeSet, + structural: &mut Vec, + ) { + for item in items { + match item { + // 写面文件禁止 out-of-line 子模块与 item 级宏:二者都能在 + // inventory 视野外新增 mutator(R9 终审绕过)。 + Item::Mod(item_mod) + if item_mod.content.is_none() && !attrs_mark_test_only(&item_mod.attrs) => + { + structural.push(format!( + "out-of-line module '{}' hides code from the inventory", + item_mod.ident + )); + } + Item::Macro(item_macro) if !attrs_mark_test_only(&item_macro.attrs) => { + structural.push( + "item-level macro can generate unaudited write-surface items".to_string(), + ); + } + Item::Impl(item_impl) + if !attrs_mark_test_only(&item_impl.attrs) && item_impl.trait_.is_none() => + { + let is_database_impl = matches!( + item_impl.self_ty.as_ref(), + syn::Type::Path(type_path) + if type_path + .path + .segments + .last() + .is_some_and(|segment| segment.ident == "Database") + ); + if !is_database_impl { + continue; + } + for impl_item in &item_impl.items { + let ImplItem::Fn(function) = impl_item else { + continue; + }; + if !attrs_mark_test_only(&function.attrs) { + impl_fns.insert(function.sig.ident.to_string()); + } + } + } + Item::Fn(function) if !attrs_mark_test_only(&function.attrs) => { + free_fns.insert(function.sig.ident.to_string()); + } + Item::Mod(item_mod) if !attrs_mark_test_only(&item_mod.attrs) => { + if let Some((_, nested)) = &item_mod.content { + walk(nested, impl_fns, free_fns, structural); + } + } + _ => {} + } + } + } + let root = source_root(); + let source = fs::read_to_string(root.join("database/dao/provider_write.rs")) + .expect("read provider_write.rs"); + let syntax = syn::parse_file(&source).expect("parse provider_write.rs"); + let mut impl_fns = BTreeSet::new(); + let mut free_fns = BTreeSet::new(); + let mut structural = Vec::new(); + walk(&syntax.items, &mut impl_fns, &mut free_fns, &mut structural); + assert!( + structural.is_empty(), + "provider_write.rs structural violations: {structural:?}" + ); + let expected_impl: BTreeSet = [ + "create_provider", + "update_provider", + "update_provider_if_content_fingerprint", + "rename_db_only_additive_provider", + "add_provider_endpoint", + "remove_provider_endpoint", + "touch_provider_endpoint", + "update_provider_sort_index", + ] + .into_iter() + .map(str::to_string) + .collect(); + let expected_free: BTreeSet = [ + "encode_row", + "insert_row", + "insert_endpoint", + "update_row", + "restore_provider_aggregate_on_tx", + ] + .into_iter() + .map(str::to_string) + .collect(); + assert_eq!( + impl_fns, expected_impl, + "Database write-surface methods drifted from the frozen contract" + ); + assert_eq!( + free_fns, expected_free, + "provider_write.rs free functions drifted from the frozen contract" + ); +} + +#[test] +fn certify_update_dto_has_no_created_at() { + // 裁决 1:created_at 不可变,update DTO 不得携带该字段。 + let root = source_root(); + let source = fs::read_to_string(root.join("database/dao/provider_write.rs")) + .expect("read provider_write.rs"); + let syntax = syn::parse_file(&source).expect("parse provider_write.rs"); + for item in &syntax.items { + let Item::Struct(item_struct) = item else { + continue; + }; + if item_struct.ident != "ProviderRowUpdate" { + continue; + } + let has_created_at = item_struct.fields.iter().any(|field| { + field + .ident + .as_ref() + .is_some_and(|ident| ident == "created_at") + }); + assert!( + !has_created_at, + "ProviderRowUpdate must not carry created_at (immutability ruling)" + ); + return; + } + panic!("ProviderRowUpdate struct not found in provider_write.rs"); +} + +fn find_fn<'a>(items: &'a [Item], name: &str) -> Option<&'a syn::ItemFn> { + for item in items { + match item { + Item::Fn(function) if function.sig.ident == name => return Some(function), + Item::Mod(item_mod) => { + let nested = item_mod.content.as_ref().map(|(_, items)| items.as_slice()); + if let Some(found) = nested.and_then(|items| find_fn(items, name)) { + return Some(found); + } + } + _ => {} + } + } + None +} + +#[derive(Default)] +struct IdentProbe { + found: BTreeSet, +} + +impl<'ast> Visit<'ast> for IdentProbe { + fn visit_ident(&mut self, identifier: &'ast syn::Ident) { + self.found.insert(identifier.to_string()); + } +} + +#[test] +fn certify_reconcile_precondition_enum_shape() { + fn find_enum<'a>(items: &'a [Item], name: &str) -> Option<&'a syn::ItemEnum> { + for item in items { + match item { + Item::Enum(item_enum) if item_enum.ident == name => return Some(item_enum), + Item::Mod(item_mod) => { + let nested = item_mod.content.as_ref().map(|(_, items)| items.as_slice()); + if let Some(found) = nested.and_then(|items| find_enum(items, name)) { + return Some(found); + } + } + _ => {} + } + } + None + } + let root = source_root(); + let source = fs::read_to_string(root.join("services/provider/mod.rs")) + .expect("read services/provider/mod.rs"); + let syntax = syn::parse_file(&source).expect("parse services/provider/mod.rs"); + let precondition = + find_enum(&syntax.items, "ReconcilePrecondition").expect("ReconcilePrecondition exists"); + let variants: Vec = precondition + .variants + .iter() + .map(|variant| variant.ident.to_string()) + .collect(); + assert_eq!( + variants, + vec!["ExpectAbsent".to_string(), "ExpectPresent".to_string()], + "ReconcilePrecondition variants drifted from the adjudicated contract" + ); + let expect_present = precondition + .variants + .iter() + .find(|variant| variant.ident == "ExpectPresent") + .expect("ExpectPresent variant"); + let field_names: Vec = expect_present + .fields + .iter() + .filter_map(|field| field.ident.as_ref().map(|ident| ident.to_string())) + .collect(); + assert_eq!( + field_names, + vec!["fingerprint".to_string()], + "ExpectPresent must carry exactly a fingerprint field" + ); +} + +#[test] +fn certify_reconcile_uses_single_transaction_primitives() { + // 裁决 3:reconcile 不得在函数体内内联读取 aggregate 再分支(那是 + // check-then-act 的新形态);必须委托单事务 DAO 原语。脚手架当前委托旧 + // 函数 → 本测试红,实现方按裁决实现后转绿。 + let root = source_root(); + let source = fs::read_to_string(root.join("services/provider/mod.rs")) + .expect("read services/provider/mod.rs"); + let syntax = syn::parse_file(&source).expect("parse services/provider/mod.rs"); + let function = find_fn(&syntax.items, "reconcile_provider_record_with_precondition") + .expect("reconcile_provider_record_with_precondition exists"); + let mut probe = IdentProbe::default(); + probe.visit_block(&function.block); + for banned in [ + "get_provider_aggregate", + "get_provider_by_id", + "get_all_providers", + "get_all_provider_aggregates", + "reconcile_provider_record", + ] { + assert!( + !probe.found.contains(banned), + "reconcile body must not use '{banned}'; delegate to single-transaction DAO primitives" + ); + } + for required in ["create_provider", "update_provider_if_content_fingerprint"] { + assert!( + probe.found.contains(required), + "reconcile body must delegate to '{required}'" + ); + } +} + +#[test] +fn certify_test_convention_files_are_cfg_test_gated() { + // 命名约定的测试文件必须自带文件级 #![cfg(test)]:借用他处注册的伪测试名 + // 生产文件在此失败;带该属性的文件在任何构建里都不进入生产目标。 + let root = source_root(); + let mut files = Vec::new(); + collect_rs_files(&root, &mut files); + for file in &files { + let relative = relative_source_path(&root, file); + if !is_test_convention_file(&relative) { + continue; + } + let source = fs::read_to_string(file).expect("read source file"); + let syntax = syn::parse_file(&source).expect("parse convention file"); + assert!( + attrs_mark_test_only(&syntax.attrs), + "{relative} uses a test naming convention but lacks a file-level #![cfg(test)]" + ); + } +} + +#[test] +fn certify_scanner_negative_matrix() { + let content = |sql: &str| -> bool { + classify_sql(sql).iter().any(|dml| match dml { + Dml::Insert { table } | Dml::Delete { table } => table == "providers", + Dml::Update { table, columns } => { + table == "providers" + && columns.iter().any(|c| { + c == "settings_config" || c == "name" || c == "meta" || c == UNPARSEABLE + }) + } + }) + }; + // 大小写 + assert!(content( + "update providers set settings_config = ?1 where id = ?2" + )); + // 引号/反引号/方括号表名(R8:引号表名曾在 \b 上失配) + assert!(content(r#"UPDATE "providers" SET name = ?1 WHERE id = ?2"#)); + assert!(content("UPDATE `providers` SET name = ?1 WHERE id = ?2")); + assert!(content("UPDATE [providers] SET name = ?1 WHERE id = ?2")); + assert!(content(r#"DELETE FROM "providers" WHERE id = ?1"#)); + assert!(content(r#"INSERT INTO "providers" (id) VALUES (?1)"#)); + // schema 前缀(含引号 schema) + assert!(content("UPDATE main.providers SET meta = ?1 WHERE id = ?2")); + assert!(content( + r#"UPDATE "main".providers SET meta = ?1 WHERE id = ?2"# + )); + // 别名:AS、AS 带引号(含非 \w 字符)、裸别名、INDEXED BY / NOT INDEXED + assert!(content( + "UPDATE providers AS p SET settings_config = ?1 WHERE p.id = ?2" + )); + assert!(content( + r#"UPDATE providers AS "p-x" SET name = ?1 WHERE id = ?2"# + )); + assert!(content( + r#"UPDATE providers AS "p""x" SET name = ?1 WHERE id = ?2"# + )); + assert!(content( + r#"UPDATE providers "bare-alias" SET name = ?1 WHERE id = ?2"# + )); + assert!(content( + "UPDATE providers p SET p.settings_config = ?1 WHERE p.id = ?2" + )); + assert!(content( + "UPDATE providers INDEXED BY idx SET name = ?1 WHERE id = ?2" + )); + assert!(content( + r#"UPDATE providers INDEXED BY "i-1" SET name = ?1 WHERE id = ?2"# + )); + assert!(content( + "UPDATE providers NOT INDEXED SET name = ?1 WHERE id = ?2" + )); + // providers_seed 等相邻表名不得误报 + assert!(!content("INSERT INTO providers_seed (id) VALUES (?1)")); + assert!(!content( + "UPDATE universal_providers SET name = ?1 WHERE id = ?2" + )); + // OR 冲突子句与 REPLACE INTO + assert!(content( + "UPDATE OR REPLACE providers SET name = ?1 WHERE id = ?2" + )); + assert!(content( + "REPLACE INTO providers (id, app_type) VALUES (?1, ?2)" + )); + assert!(content("INSERT OR REPLACE INTO providers (id) VALUES (?1)")); + // 注释拆词 + assert!(content( + "UPDATE /* sneak */ providers SET settings_config = ?1 WHERE id = ?2" + )); + assert!(content( + "UPDATE providers -- x\n SET name = ?1 WHERE id = ?2" + )); + // 引号内的注释记号不是注释('--' 字符串吞列绕过) + let quoted_comment = "UPDATE providers SET is_current = '--', name = ?1 WHERE id = ?2"; + let classified = classify_sql(quoted_comment); + assert!( + classified.iter().any(|dml| matches!( + dml, + Dml::Update { columns, .. } if columns.contains(&"name".to_string()) + )), + "comment markers inside SQL strings must not swallow columns: {classified:?}" + ); + // 命名参数 :where 不得截断列解析 + let named_param = "UPDATE providers SET is_current = :where, name = ?1 WHERE id = ?2"; + let classified = classify_sql(named_param); + assert!( + classified.iter().any(|dml| matches!( + dml, + Dml::Update { columns, .. } if columns.contains(&"name".to_string()) + )), + "named parameter :where must not terminate column parsing: {classified:?}" + ); + // 双引号内容破坏深度 → fail-closed 哨兵 + let sabotage = r#"UPDATE providers SET is_current = ")", name = ?1 WHERE id = ?2"#; + assert!(content(sabotage), "quoted parens must not hide columns"); + // 子查询误导:内层 WHERE 不得截断列解析 + let subquery = "UPDATE providers SET is_current = (SELECT max(id) FROM t WHERE y = 1), settings_config = ?1 WHERE id = ?2"; + let classified = classify_sql(subquery); + assert!( + classified.iter().any(|dml| matches!( + dml, + Dml::Update { table, columns } + if table == "providers" + && columns.contains(&"is_current".to_string()) + && columns.contains(&"settings_config".to_string()) + )), + "subquery WHERE must not truncate column parsing: {classified:?}" + ); + // 多语句 + let batch = "UPDATE providers SET is_current = 1 WHERE id = 1; UPDATE providers SET settings_config = 'x' WHERE id = 2"; + assert_eq!(classify_sql(batch).len(), 2); + assert!(content(batch)); + // 不闭合引号 → fail-closed + assert!(content( + "UPDATE providers SET is_current = 'unterminated WHERE id = 1" + )); + // 状态列合法写法必须放行(防过杀) + let state_only = classify_sql("UPDATE providers SET is_current = 0 WHERE app_type = ?1"); + assert!(state_only + .iter() + .all(|dml| dml_allowed("database/dao/providers.rs", dml))); + // 内容列即使在状态 authority 内也必须拦下(R4 逃逸场景) + let escaped = classify_sql("UPDATE providers SET settings_config = ?1 WHERE id = ?2"); + assert!(escaped + .iter() + .any(|dml| !dml_allowed("database/dao/providers.rs", dml))); + // endpoints:touch-only 放行于写面,内容列到处拦 + let touch = classify_sql("UPDATE provider_endpoints SET last_used = ?1 WHERE provider_id = ?2"); + assert!(touch + .iter() + .all(|dml| dml_allowed("database/dao/provider_write.rs", dml))); + let ep_content = classify_sql("UPDATE provider_endpoints SET url = ?1 WHERE provider_id = ?2"); + assert!(ep_content + .iter() + .any(|dml| !dml_allowed("database/dao/providers.rs", dml))); + // 精确路径:伪路径不得冒充 authority + let state = Dml::Update { + table: "providers".to_string(), + columns: vec!["is_current".to_string()], + }; + assert!(dml_allowed("database/dao/providers.rs", &state)); + assert!(!dml_allowed("services/database/dao/providers.rs", &state)); + assert!(!dml_allowed( + "evil/database/dao/provider_write.rs", + &Dml::Insert { + table: "providers".to_string() + } + )); + // cfg 语义:not(test) 与 any(test, unix) 是生产代码 + assert!(!cfg_expr_requires_test("not (test)")); + assert!(!cfg_expr_requires_test("any (test , unix)")); + assert!(cfg_expr_requires_test("test")); + assert!(cfg_expr_requires_test("all (test , unix)")); + assert!(cfg_expr_requires_test("any (test , all (test , unix))")); + // 宏:concat! 纯字面量拼接 → 正常分类,不误报违规 + let mut collector = ProductionCollector::default(); + collector.record_macro_tokens( + "concat", + r#""UPDATE providers " , "SET name = ?1 WHERE id = ?2""#, + ); + assert!( + collector.literals.iter().any(|lit| content(lit)), + "concat!-joined SQL must be classified" + ); + assert!(collector.macro_violations.is_empty()); + // 宏:format! 含非字面量 + DML 锚点 → 无条件违规(即使可见列全是状态列) + let mut collector = ProductionCollector::default(); + collector.record_macro_tokens( + "format", + r#""UPDATE providers SET is_current = 0 , {} = ?1 WHERE id = ?2" , column"#, + ); + assert!( + !collector.macro_violations.is_empty(), + "format!-built provider DML must fail closed even when visible columns look like state" + ); + // 宏:stringify! 式 ident 构造 SQL → token 锚点命中 + let mut collector = ProductionCollector::default(); + collector.record_macro_tokens("stringify", "UPDATE providers SET name = x WHERE id = y"); + assert!( + !collector.macro_violations.is_empty(), + "ident-built provider DML must fail closed" + ); + // 宏:无 DML 锚点的普通 format! 不误报 + let mut collector = ProductionCollector::default(); + collector.record_macro_tokens("format", r#""hello {}" , name"#); + assert!(collector.macro_violations.is_empty()); + // 宏:include! 全禁,include_str!(.sql) 禁,普通资源不误报 + let mut collector = ProductionCollector::default(); + collector.record_macro_tokens("include", r#""../generated.rs""#); + assert!(!collector.macro_violations.is_empty()); + let mut collector = ProductionCollector::default(); + collector.record_macro_tokens("include_str", r#""queries/update.sql""#); + assert!(!collector.macro_violations.is_empty()); + let mut collector = ProductionCollector::default(); + collector.record_macro_tokens("include_bytes", r#""queries/update.SQL""#); + assert!( + !collector.macro_violations.is_empty(), + "include_bytes and case variants must be banned for SQL" + ); + let mut collector = ProductionCollector::default(); + collector.record_macro_tokens("include_str", r#"concat ! ("query." , "sql")"#); + assert!( + !collector.macro_violations.is_empty(), + "extension split via concat! must still be detected" + ); + let mut collector = ProductionCollector::default(); + collector.record_macro_tokens("include_str", r#""resources/template.json""#); + assert!(collector.macro_violations.is_empty()); + // 隐式 format 捕获:单字符串 + 花括号插值不得被当纯字面量放行(R9) + let mut collector = ProductionCollector::default(); + collector.record_macro_tokens( + "format", + r#""UPDATE providers SET {is_current} = ?1 WHERE id = ?2""#, + ); + assert!( + !collector.macro_violations.is_empty(), + "implicit format captures must fail closed" + ); + // 宏字符串转义:\x55(U)解码后仍识别 + let mut collector = ProductionCollector::default(); + collector.record_macro_tokens( + "concat", + r#""\x55PDATE providers " , "SET name = ?1 WHERE id = ?2""#, + ); + assert!( + collector.literals.iter().any(|lit| content(lit)), + "escaped SQL must be decoded via LitStr::value before classification" + ); +} + +// --------------------------------------------------------------------------- +// T3:create 冲突原子性与结构化 Conflict(裁决 2) +// --------------------------------------------------------------------------- + +#[test] +fn certify_duplicate_create_returns_structured_conflict() { + let database = db(); + let first = with_endpoints( + { + let mut input = base_input("dup", "第一次创建"); + input.sort_index = Some(5); + input.in_failover_queue = true; + input + }, + &[("https://a.example", Some(11), None)], + ); + database + .create_provider(NewProviderAggregate::from_input("claude", first).unwrap()) + .expect("first create"); + let before = snapshot(&database, "claude", "dup"); + + let second = with_endpoints( + base_input("dup", "冒名顶替"), + &[("https://b.example", Some(22), None)], + ); + let err = database + .create_provider(NewProviderAggregate::from_input("claude", second).unwrap()) + .expect_err("duplicate create must fail, not upsert"); + assert!( + matches!(err, AppError::Conflict(_)), + "duplicate create must surface a structured Conflict, got: {err:?}" + ); + assert_eq!( + snapshot(&database, "claude", "dup"), + before, + "duplicate create must leave row, endpoints and state untouched" + ); +} + +#[test] +fn certify_create_does_not_touch_current_state() { + let database = db(); + database + .create_provider( + NewProviderAggregate::from_input("claude", base_input("first", "既有")).unwrap(), + ) + .expect("create first"); + database + .set_current_provider("claude", "first") + .expect("set current"); + database + .create_provider( + NewProviderAggregate::from_input("claude", base_input("second", "新建")).unwrap(), + ) + .expect("create second"); + let (first_row, _) = snapshot(&database, "claude", "first"); + let (second_row, _) = snapshot(&database, "claude", "second"); + assert_eq!( + first_row.expect("first row").10, + 1, + "create must not clear another provider's is_current" + ); + assert_eq!( + second_row.expect("second row").10, + 0, + "create must never set is_current on the new row" + ); +} + +#[test] +fn certify_create_endpoint_failure_rolls_back_row() { + let database = db(); + install_endpoint_reject_trigger(&database); + let input = with_endpoints( + base_input("halfway", "半途失败"), + &[("https://blocked.example", Some(1), None)], + ); + database + .create_provider(NewProviderAggregate::from_input("claude", input).unwrap()) + .expect_err("endpoint insert failure must fail the create"); + let (row, endpoints) = snapshot(&database, "claude", "halfway"); + assert!( + row.is_none() && endpoints.is_empty(), + "failed create must leave no partial row" + ); +} + +// --------------------------------------------------------------------------- +// T4:update 严格单行、created_at 不可变、状态列保全、全内容往返 +// --------------------------------------------------------------------------- + +#[test] +fn certify_update_missing_provider_is_notfound_and_creates_nothing() { + let database = db(); + let key = ProviderKey::new("claude", "ghost").unwrap(); + let row = ProviderRowUpdate::from_input(&base_input("ghost", "幽灵")).unwrap(); + let err = database.update_provider(&key, &row).expect_err("must fail"); + assert!(matches!(err, AppError::NotFound(_)), "got: {err:?}"); + let (row_after, endpoints_after) = snapshot(&database, "claude", "ghost"); + assert!(row_after.is_none() && endpoints_after.is_empty()); +} + +#[test] +fn certify_update_cannot_change_created_at() { + let database = db(); + let mut created = base_input("epoch", "创建时间"); + created.created_at = Some(111); + database + .create_provider(NewProviderAggregate::from_input("claude", created).unwrap()) + .expect("create"); + let key = ProviderKey::new("claude", "epoch").unwrap(); + let mut edited = base_input("epoch", "被编辑"); + edited.created_at = Some(222); + database + .update_provider(&key, &ProviderRowUpdate::from_input(&edited).unwrap()) + .expect("update"); + let (row, _) = snapshot(&database, "claude", "epoch"); + assert_eq!( + row.expect("row").4, + Some(111), + "update must never rewrite created_at" + ); +} + +#[test] +fn certify_update_preserves_all_state_columns() { + let database = db(); + let created = { + let mut input = base_input("stately", "状态在身"); + input.sort_index = Some(9); + input.in_failover_queue = true; + input + }; + database + .create_provider(NewProviderAggregate::from_input("claude", created).unwrap()) + .expect("create"); + database + .set_current_provider("claude", "stately") + .expect("set current"); + let key = ProviderKey::new("claude", "stately").unwrap(); + database + .update_provider( + &key, + &ProviderRowUpdate::from_input(&base_input("stately", "改名")).unwrap(), + ) + .expect("update"); + let (row, _) = snapshot(&database, "claude", "stately"); + let row = row.expect("row"); + assert_eq!(row.5, Some(9), "sort_index must survive row update"); + assert_eq!(row.10, 1, "is_current must survive row update"); + assert_eq!(row.11, 1, "in_failover_queue must survive row update"); +} + +#[test] +fn certify_full_content_roundtrip_via_create_and_update() { + // 全列往返认证(含 meta):忽略任一内容列的实现都不得变绿。 + let database = db(); + let mut created = base_input("full", "全字段"); + created.settings_config = json!({"base_url": "https://one.example", "model": "m1"}); + created.website_url = Some("https://site.example".to_string()); + created.category = Some("cat-a".to_string()); + created.notes = Some("初始备注".to_string()); + created.icon = Some("icon-a".to_string()); + created.icon_color = Some("#111111".to_string()); + created.meta = Some(ProviderMeta { + common_config_enabled: Some(true), + ..Default::default() + }); + database + .create_provider(NewProviderAggregate::from_input("claude", created).unwrap()) + .expect("create"); + let (row, _) = snapshot(&database, "claude", "full"); + let row = row.expect("row"); + assert_eq!(row.0, "全字段"); + assert!(row.1.contains("https://one.example")); + assert_eq!(row.2.as_deref(), Some("https://site.example")); + assert_eq!(row.3.as_deref(), Some("cat-a")); + assert_eq!(row.6.as_deref(), Some("初始备注")); + assert_eq!(row.7.as_deref(), Some("icon-a")); + assert_eq!(row.8.as_deref(), Some("#111111")); + let meta_json: serde_json::Value = + serde_json::from_str(&row.9).expect("stored meta must be valid JSON"); + assert_eq!( + meta_json["commonConfigEnabled"], + json!(true), + "meta content must round-trip through create, got: {}", + row.9 + ); + + let key = ProviderKey::new("claude", "full").unwrap(); + let mut edited = base_input("full", "全字段二版"); + edited.settings_config = json!({"base_url": "https://two.example", "model": "m2"}); + edited.website_url = Some("https://site2.example".to_string()); + edited.category = Some("cat-b".to_string()); + edited.notes = Some("二版备注".to_string()); + edited.icon = Some("icon-b".to_string()); + edited.icon_color = Some("#222222".to_string()); + edited.meta = Some(ProviderMeta { + common_config_enabled: Some(false), + ..Default::default() + }); + database + .update_provider(&key, &ProviderRowUpdate::from_input(&edited).unwrap()) + .expect("update"); + let (row, _) = snapshot(&database, "claude", "full"); + let row = row.expect("row"); + assert_eq!(row.0, "全字段二版"); + assert!(row.1.contains("https://two.example")); + assert_eq!(row.2.as_deref(), Some("https://site2.example")); + assert_eq!(row.3.as_deref(), Some("cat-b")); + assert_eq!(row.6.as_deref(), Some("二版备注")); + assert_eq!(row.7.as_deref(), Some("icon-b")); + assert_eq!(row.8.as_deref(), Some("#222222")); + let meta_json: serde_json::Value = + serde_json::from_str(&row.9).expect("stored meta must be valid JSON"); + assert_eq!( + meta_json["commonConfigEnabled"], + json!(false), + "meta content must round-trip through update, got: {}", + row.9 + ); +} + +// --------------------------------------------------------------------------- +// T5:陈旧快照下并发 endpoint 变更存活(R3 核心场景)+ endpoint 严格性 +// --------------------------------------------------------------------------- + +#[test] +fn certify_concurrent_endpoint_changes_survive_row_update() { + let database = db(); + let created = with_endpoints( + base_input("surv", "并发存活"), + &[("https://old.example", Some(1), None)], + ); + database + .create_provider(NewProviderAggregate::from_input("claude", created).unwrap()) + .expect("create"); + let key = ProviderKey::new("claude", "surv").unwrap(); + + database + .add_provider_endpoint( + &key, + NewEndpoint::new("https://new.example", Some(2), None).unwrap(), + ) + .expect("add"); + database + .remove_provider_endpoint(&key, "https://old.example") + .expect("remove"); + database + .touch_provider_endpoint(&key, "https://new.example", 99) + .expect("touch"); + + let row = ProviderRowUpdate::from_input(&base_input("surv", "改名")).unwrap(); + database.update_provider(&key, &row).expect("row update"); + + let (_, endpoints) = snapshot(&database, "claude", "surv"); + assert_eq!( + endpoints, + vec![("https://new.example".to_string(), Some(2), Some(99))], + "all concurrent endpoint mutations must survive a row update" + ); +} + +#[test] +fn certify_update_payload_with_endpoints_is_rejected_explicitly() { + let stale = with_endpoints( + base_input("surv", "夹带"), + &[("https://smuggle.example", Some(3), None)], + ); + let err = ProviderRowUpdate::from_input(&stale).expect_err("must reject"); + assert!(matches!(err, AppError::InvalidInput(_)), "got: {err:?}"); +} + +#[test] +fn certify_endpoint_mutations_are_strict() { + let database = db(); + database + .create_provider( + NewProviderAggregate::from_input( + "claude", + with_endpoints( + base_input("strict", "严格"), + &[("https://one.example", Some(7), None)], + ), + ) + .unwrap(), + ) + .expect("create"); + let key = ProviderKey::new("claude", "strict").unwrap(); + let before = snapshot(&database, "claude", "strict"); + + database + .add_provider_endpoint( + &key, + NewEndpoint::new("https://one.example", Some(8), None).unwrap(), + ) + .expect_err("duplicate endpoint add must fail"); + assert_eq!(snapshot(&database, "claude", "strict"), before); + + assert!(matches!( + database.remove_provider_endpoint(&key, "https://none.example"), + Err(AppError::NotFound(_)) + )); + assert!(matches!( + database.touch_provider_endpoint(&key, "https://none.example", 1), + Err(AppError::NotFound(_)) + )); + + database + .touch_provider_endpoint(&key, "https://one.example", 55) + .expect("touch"); + let (_, endpoints) = snapshot(&database, "claude", "strict"); + assert_eq!( + endpoints, + vec![("https://one.example".to_string(), Some(7), Some(55))], + "touch must change last_used only" + ); +} + +// --------------------------------------------------------------------------- +// T6:added_at NULL 全链路无损 +// --------------------------------------------------------------------------- + +#[test] +fn certify_null_added_at_roundtrips_losslessly() { + let database = db(); + let created = with_endpoints( + base_input("nulls", "空值"), + &[("https://n.example", None, None)], + ); + database + .create_provider(NewProviderAggregate::from_input("claude", created).unwrap()) + .expect("create"); + + let (_, raw) = snapshot(&database, "claude", "nulls"); + assert_eq!( + raw, + vec![("https://n.example".to_string(), None, None)], + "storage must keep NULL, not 0" + ); + + let aggregate = database + .get_provider_aggregate("claude", "nulls") + .expect("hydrate") + .expect("exists"); + let endpoint = aggregate + .endpoints + .get("https://n.example") + .expect("endpoint present in hydration"); + assert_eq!( + endpoint.added_at, None, + "hydration must not coerce NULL added_at to 0" + ); + assert_eq!(endpoint.last_used, None); +} + +// --------------------------------------------------------------------------- +// T7:rename 认证矩阵 +// --------------------------------------------------------------------------- + +fn create_opencode_provider(database: &Database, id: &str) { + let created = with_endpoints( + { + let mut input = base_input(id, "opencode 源"); + input.sort_index = Some(3); + input.in_failover_queue = true; + input + }, + &[("https://keep.example", None, Some(42))], + ); + database + .create_provider(NewProviderAggregate::from_input("opencode", created).unwrap()) + .expect("create opencode provider"); +} + +#[test] +fn certify_rename_preserves_endpoints_nulls_state_and_current() { + let database = db(); + create_opencode_provider(&database, "old-key"); + database + .set_current_provider("opencode", "old-key") + .expect("set current"); + let source = ProviderKey::new("opencode", "old-key").unwrap(); + let rename = + RenameProvider::from_input(source, &base_input("new-key", "改键")).expect("build rename"); + database + .rename_db_only_additive_provider(rename) + .expect("rename"); + + let (old_row, old_eps) = snapshot(&database, "opencode", "old-key"); + assert!( + old_row.is_none() && old_eps.is_empty(), + "source must be gone" + ); + + let (new_row, new_eps) = snapshot(&database, "opencode", "new-key"); + let new_row = new_row.expect("target row"); + assert_eq!(new_row.0, "改键", "row content must come from rename input"); + assert_eq!(new_row.5, Some(3), "sort_index must carry over"); + assert_eq!(new_row.10, 1, "is_current must carry over"); + assert_eq!(new_row.11, 1, "in_failover_queue must carry over"); + assert_eq!( + new_eps, + vec![("https://keep.example".to_string(), None, Some(42))], + "endpoints must carry over with NULL timestamps intact" + ); +} + +#[test] +fn certify_rename_target_conflict_has_zero_side_effects() { + let database = db(); + create_opencode_provider(&database, "src"); + create_opencode_provider(&database, "dst"); + let before_src = snapshot(&database, "opencode", "src"); + let before_dst = snapshot(&database, "opencode", "dst"); + + let source = ProviderKey::new("opencode", "src").unwrap(); + let rename = RenameProvider::from_input(source, &base_input("dst", "撞车")).expect("build"); + database + .rename_db_only_additive_provider(rename) + .expect_err("rename onto an existing key must fail"); + + assert_eq!(snapshot(&database, "opencode", "src"), before_src); + assert_eq!(snapshot(&database, "opencode", "dst"), before_dst); +} + +#[test] +fn certify_rename_endpoint_copy_failure_is_atomic() { + let database = db(); + create_opencode_provider(&database, "guarded"); + let before = snapshot(&database, "opencode", "guarded"); + install_endpoint_reject_trigger(&database); + + let source = ProviderKey::new("opencode", "guarded").unwrap(); + let rename = RenameProvider::from_input(source, &base_input("moved", "搬家")).expect("build"); + database + .rename_db_only_additive_provider(rename) + .expect_err("endpoint copy failure must fail the rename"); + + assert_eq!( + snapshot(&database, "opencode", "guarded"), + before, + "failed rename must leave the source fully intact" + ); + let (moved_row, moved_eps) = snapshot(&database, "opencode", "moved"); + assert!( + moved_row.is_none() && moved_eps.is_empty(), + "failed rename must leave no partial target" + ); +} + +#[test] +fn certify_rename_scope_restrictions() { + let claude_source = ProviderKey::new("claude", "any").unwrap(); + assert!(matches!( + RenameProvider::from_input(claude_source, &base_input("other", "x")), + Err(AppError::InvalidInput(_)) + )); + + let database = db(); + for category in ["omo", "omo-slim"] { + let id = format!("omo-{category}"); + let mut omo = base_input(&id, "omo"); + omo.category = Some(category.to_string()); + database + .create_provider(NewProviderAggregate::from_input("opencode", omo).unwrap()) + .expect("create omo provider"); + let source = ProviderKey::new("opencode", &id).unwrap(); + let rename = + RenameProvider::from_input(source, &base_input("omo-target", "y")).expect("build"); + assert!( + matches!( + database.rename_db_only_additive_provider(rename), + Err(AppError::InvalidInput(_)) + ), + "{category} providers must not be renamable" + ); + } + + let ghost = ProviderKey::new("opencode", "ghost").unwrap(); + let rename = RenameProvider::from_input(ghost, &base_input("anywhere", "z")).expect("build"); + assert!(matches!( + database.rename_db_only_additive_provider(rename), + Err(AppError::NotFound(_)) + )); +} + +// --------------------------------------------------------------------------- +// D1:delete 补偿原语必须能重建完整 aggregate +// --------------------------------------------------------------------------- + +#[test] +fn certify_delete_compensation_recreates_exact_aggregate() { + let database = db(); + let created = with_endpoints( + { + let mut input = base_input("comp", "补偿对象"); + input.sort_index = Some(5); + input.in_failover_queue = true; + input.notes = Some("完整字段".to_string()); + input + }, + &[ + ("https://a.example", Some(11), Some(20)), + ("https://b.example", None, None), + ], + ); + database + .create_provider(NewProviderAggregate::from_input("claude", created).unwrap()) + .expect("create"); + database + .set_current_provider("claude", "comp") + .expect("set current"); + let before = snapshot(&database, "claude", "comp"); + + database + .delete_provider("claude", "comp") + .expect("delete provider"); + let (gone_row, gone_eps) = snapshot(&database, "claude", "comp"); + assert!( + gone_row.is_none() && gone_eps.is_empty(), + "delete must cascade endpoints" + ); + + // 补偿:必须能从快照原样重建已删除的 aggregate(update-first 语义在此 + // 必败——补偿原语要求 insert-or-restore 语义)。created_at 由专属参数 + // 携带(裁决 1)。 + let key = ProviderKey::new("claude", "comp").unwrap(); + let row = ProviderRowUpdate::from_input(&{ + let mut input = base_input("comp", "补偿对象"); + input.notes = Some("完整字段".to_string()); + input + }) + .unwrap(); + let endpoints = [ + NewEndpoint::new("https://a.example", Some(11), Some(20)).unwrap(), + NewEndpoint::new("https://b.example", None, None).unwrap(), + ]; + { + let mut conn = database.conn.lock().expect("lock certification database"); + let tx = conn.transaction().expect("open compensation transaction"); + provider_write::restore_provider_aggregate_on_tx( + &tx, + &key, + &row, + Some(1_700_000_000), + Some(5), + true, + true, + &endpoints, + ) + .expect("compensation must recreate a deleted aggregate"); + tx.commit().expect("commit compensation"); + } + assert_eq!( + snapshot(&database, "claude", "comp"), + before, + "restored aggregate must be byte-identical to the pre-delete snapshot" + ); +} + +#[test] +fn certify_delete_compensation_failure_leaves_no_partial_state() { + let database = db(); + let created = with_endpoints( + base_input("comp2", "补偿失败"), + &[("https://c.example", Some(1), None)], + ); + database + .create_provider(NewProviderAggregate::from_input("claude", created).unwrap()) + .expect("create"); + database + .delete_provider("claude", "comp2") + .expect("delete provider"); + install_endpoint_reject_trigger(&database); + + let key = ProviderKey::new("claude", "comp2").unwrap(); + let row = ProviderRowUpdate::from_input(&base_input("comp2", "补偿失败")).unwrap(); + let endpoints = [NewEndpoint::new("https://c.example", Some(1), None).unwrap()]; + let err = { + let mut conn = database.conn.lock().expect("lock certification database"); + let tx = conn.transaction().expect("open compensation transaction"); + provider_write::restore_provider_aggregate_on_tx( + &tx, + &key, + &row, + Some(1_700_000_000), + None, + false, + false, + &endpoints, + ) + .expect_err("endpoint restore failure must fail the compensation") + // 事务随 drop 回滚 + }; + assert!( + err.to_string().contains(ENDPOINT_REJECT_MESSAGE), + "compensation must fail at the injected endpoint restore, not before it; got: {err}" + ); + let (row_after, eps_after) = snapshot(&database, "claude", "comp2"); + assert!( + row_after.is_none() && eps_after.is_empty(), + "failed compensation must not leave a row without its endpoints" + ); +} + +// --------------------------------------------------------------------------- +// T8/T9:reconcile 前置期望(脚手架语义故意错误,T9 应红) +// --------------------------------------------------------------------------- + +#[test] +fn certify_reconcile_expect_present_preserves_endpoints_and_state() { + let database = db(); + let created = with_endpoints( + { + let mut input = base_input("recon", "用户创建"); + input.sort_index = Some(7); + input.in_failover_queue = true; + input + }, + &[("https://user.example", Some(5), None)], + ); + database + .create_provider(NewProviderAggregate::from_input("claude", created).unwrap()) + .expect("create"); + database + .set_current_provider("claude", "recon") + .expect("set current"); + let aggregate = database + .get_provider_aggregate("claude", "recon") + .expect("hydrate") + .expect("exists"); + let fingerprint = provider_row_fingerprint(&aggregate.provider); + + reconcile_provider_record_with_precondition( + &database, + "claude", + base_input("recon", "同步覆盖"), + ReconcilePrecondition::ExpectPresent { fingerprint }, + ) + .expect("reconcile existing with fresh fingerprint"); + + let (row, endpoints) = snapshot(&database, "claude", "recon"); + let row = row.expect("row"); + assert_eq!(row.0, "同步覆盖", "row content may be reconciled"); + assert_eq!( + row.5, + Some(7), + "sort_index is state, reconcile must not clear it" + ); + assert_eq!( + row.10, 1, + "is_current is state, reconcile must not clear it" + ); + assert_eq!( + row.11, 1, + "failover membership is state, reconcile must not clear it" + ); + assert_eq!( + endpoints, + vec![("https://user.example".to_string(), Some(5), None)], + "reconcile of an existing provider must never touch endpoints" + ); +} + +#[test] +fn certify_reconcile_expect_absent_creates_with_initial_endpoints() { + let database = db(); + reconcile_provider_record_with_precondition( + &database, + "claude", + with_endpoints( + base_input("fresh", "同步新建"), + &[("https://seed.example", Some(9), None)], + ), + ReconcilePrecondition::ExpectAbsent, + ) + .expect("reconcile missing"); + let (row, endpoints) = snapshot(&database, "claude", "fresh"); + assert!(row.is_some()); + assert_eq!( + endpoints, + vec![("https://seed.example".to_string(), Some(9), None)] + ); +} + +#[test] +fn certify_reconcile_expect_absent_loser_cannot_overwrite_winner() { + // T9(TOCTOU 本体):观察为 Absent 后输掉竞争,必须结构化 Conflict, + // 绝不退化为覆盖更新。 + let database = db(); + let winner = with_endpoints( + base_input("race-slot", "竞争赢家"), + &[("https://winner.example", Some(1), None)], + ); + database + .create_provider(NewProviderAggregate::from_input("claude", winner).unwrap()) + .expect("winner create"); + let before = snapshot(&database, "claude", "race-slot"); + + let err = reconcile_provider_record_with_precondition( + &database, + "claude", + base_input("race-slot", "迟到输家"), + ReconcilePrecondition::ExpectAbsent, + ) + .expect_err("losing an ExpectAbsent race must surface an error"); + assert!( + matches!(err, AppError::Conflict(_)), + "race loser must get a structured Conflict, got: {err:?}" + ); + assert_eq!( + snapshot(&database, "claude", "race-slot"), + before, + "the winner's row must remain byte-identical" + ); +} + +#[test] +fn certify_reconcile_expect_present_stale_fingerprint_conflicts() { + let database = db(); + database + .create_provider( + NewProviderAggregate::from_input("claude", base_input("staleful", "第一版")).unwrap(), + ) + .expect("create"); + let aggregate = database + .get_provider_aggregate("claude", "staleful") + .expect("hydrate") + .expect("exists"); + let stale_fingerprint = provider_row_fingerprint(&aggregate.provider); + + // 其他写者更新了行内容,持旧指纹的 reconcile 必须 Conflict。 + let key = ProviderKey::new("claude", "staleful").unwrap(); + database + .update_provider( + &key, + &ProviderRowUpdate::from_input(&base_input("staleful", "第二版")).unwrap(), + ) + .expect("interleaved update"); + + let err = reconcile_provider_record_with_precondition( + &database, + "claude", + base_input("staleful", "第三版"), + ReconcilePrecondition::ExpectPresent { + fingerprint: stale_fingerprint, + }, + ) + .expect_err("stale fingerprint must surface an error"); + assert!( + matches!(err, AppError::Conflict(_)), + "stale fingerprint must get a structured Conflict, got: {err:?}" + ); + let (row, _) = snapshot(&database, "claude", "staleful"); + assert_eq!( + row.expect("row").0, + "第二版", + "stale reconcile must not overwrite the interleaved writer" + ); +} + +#[test] +fn certify_fingerprint_is_deterministic_and_endpoint_blind() { + // preserve_order + HashMap 意味着朴素序列化指纹不稳定(伪 Conflict); + // 指纹必须走规范化排序哈希,且不受 endpoint 填充差异影响。 + let database = db(); + database + .create_provider( + NewProviderAggregate::from_input( + "claude", + with_endpoints( + { + let mut input = base_input("fp", "指纹"); + input.meta = Some(ProviderMeta { + common_config_enabled: Some(true), + ..Default::default() + }); + input + }, + &[("https://e.example", Some(1), None)], + ), + ) + .unwrap(), + ) + .expect("create"); + let via_aggregate = database + .get_provider_aggregate("claude", "fp") + .expect("hydrate") + .expect("exists"); + let fp1 = provider_row_fingerprint(&via_aggregate.provider); + let fp2 = provider_row_fingerprint(&via_aggregate.provider); + assert_eq!(fp1, fp2, "fingerprint must be deterministic"); + + // endpoint 填充差异(get_provider_by_id 会把 endpoints 合回 meta)不得 + // 改变指纹。 + let mut with_endpoints_in_meta = via_aggregate.provider.clone(); + let mut meta = with_endpoints_in_meta.meta.take().unwrap_or_default(); + meta.custom_endpoints.insert( + "https://e.example".to_string(), + CustomEndpoint { + url: "https://e.example".to_string(), + added_at: Some(1), + last_used: None, + }, + ); + with_endpoints_in_meta.meta = Some(meta); + assert_eq!( + fp1, + provider_row_fingerprint(&with_endpoints_in_meta), + "endpoint hydration differences must not change the content fingerprint" + ); + + // preserve_order 下键插入顺序不同但逻辑相等的对象必须同指纹 + // (旧的朴素序列化对同一实例稳定,骗得过"哈希两次"断言,骗不过这个)。 + let mut ordered_a = via_aggregate.provider.clone(); + ordered_a.settings_config = + serde_json::from_str(r#"{"alpha": 1, "zeta": {"x": 1, "y": 2}}"#).unwrap(); + let mut ordered_b = via_aggregate.provider.clone(); + ordered_b.settings_config = + serde_json::from_str(r#"{"zeta": {"y": 2, "x": 1}, "alpha": 1}"#).unwrap(); + assert_eq!( + provider_row_fingerprint(&ordered_a), + provider_row_fingerprint(&ordered_b), + "logically equal objects with different key insertion order must share a fingerprint" + ); + + // 长度前缀:边界粘连的不同内容必须得到不同指纹(碰撞对)。 + let mut collide_a = via_aggregate.provider.clone(); + collide_a.settings_config = json!(["a", "b"]); + let mut collide_b = via_aggregate.provider.clone(); + collide_b.settings_config = json!(["a\u{0}sb"]); + assert_ne!( + provider_row_fingerprint(&collide_a), + provider_row_fingerprint(&collide_b), + "canonical encoding must be collision-free across value boundaries" + ); +} + +// --------------------------------------------------------------------------- +// T11:并发线性化 +// --------------------------------------------------------------------------- + +#[test] +fn certify_concurrent_create_single_winner() { + let database = db(); + let barrier = std::sync::Barrier::new(2); + let contenders = [ + ("赢家甲", "https://alpha.example"), + ("赢家乙", "https://beta.example"), + ]; + let results: Vec> = std::thread::scope(|scope| { + contenders + .iter() + .map(|(name, url)| { + let database = &database; + let barrier = &barrier; + scope.spawn(move || { + let input = with_endpoints(base_input("race", name), &[(url, Some(1), None)]); + let aggregate = NewProviderAggregate::from_input("claude", input).unwrap(); + barrier.wait(); + database.create_provider(aggregate).map(|_| *url) + }) + }) + .collect::>() + .into_iter() + .map(|handle| handle.join().expect("thread join")) + .collect() + }); + let winners: Vec<&str> = results + .iter() + .filter_map(|r| r.as_ref().ok().copied()) + .collect(); + assert_eq!(winners.len(), 1, "exactly one concurrent create must win"); + let (row, endpoints) = snapshot(&database, "claude", "race"); + let row = row.expect("winner row"); + let winner_url = winners[0]; + let winner_name = contenders + .iter() + .find(|(_, url)| *url == winner_url) + .map(|(name, _)| *name) + .expect("winner name"); + assert_eq!(row.0, winner_name, "row must belong entirely to the winner"); + assert_eq!( + endpoints, + vec![(winner_url.to_string(), Some(1), None)], + "endpoints must belong entirely to the same winner" + ); +} + +#[test] +fn certify_concurrent_full_updates_do_not_tear() { + let database = db(); + database + .create_provider( + NewProviderAggregate::from_input("claude", base_input("tear", "初始")).unwrap(), + ) + .expect("create"); + let barrier = std::sync::Barrier::new(2); + std::thread::scope(|scope| { + for suffix in ["一号", "二号"] { + let database = &database; + let barrier = &barrier; + scope.spawn(move || { + let mut input = base_input("tear", &format!("名-{suffix}")); + input.website_url = Some(format!("https://site-{suffix}.example")); + input.notes = Some(format!("注-{suffix}")); + input.icon = Some(format!("icon-{suffix}")); + let key = ProviderKey::new("claude", "tear").unwrap(); + let row = ProviderRowUpdate::from_input(&input).unwrap(); + barrier.wait(); + database.update_provider(&key, &row).expect("update"); + }); + } + }); + let (row, _) = snapshot(&database, "claude", "tear"); + let row = row.expect("row"); + let suffix = row.0.strip_prefix("名-").expect("name written by a writer"); + assert_eq!( + row.2.as_deref(), + Some(format!("https://site-{suffix}.example").as_str()), + "row content must come from a single writer, not interleaved" + ); + assert_eq!(row.6.as_deref(), Some(format!("注-{suffix}").as_str())); + assert_eq!(row.7.as_deref(), Some(format!("icon-{suffix}").as_str())); +} + +#[test] +fn certify_concurrent_endpoint_interleaving_is_consistent() { + let database = db(); + database + .create_provider( + NewProviderAggregate::from_input( + "claude", + with_endpoints( + base_input("weave", "交错"), + &[("https://c.example", Some(1), None)], + ), + ) + .unwrap(), + ) + .expect("create"); + let key = ProviderKey::new("claude", "weave").unwrap(); + let barrier = std::sync::Barrier::new(2); + std::thread::scope(|scope| { + { + let database = &database; + let key = &key; + let barrier = &barrier; + scope.spawn(move || { + barrier.wait(); + database + .add_provider_endpoint( + key, + NewEndpoint::new("https://a.example", Some(2), None).unwrap(), + ) + .expect("add a"); + database + .touch_provider_endpoint(key, "https://a.example", 7) + .expect("touch a"); + }); + } + { + let database = &database; + let key = &key; + let barrier = &barrier; + scope.spawn(move || { + barrier.wait(); + database + .add_provider_endpoint( + key, + NewEndpoint::new("https://b.example", Some(3), None).unwrap(), + ) + .expect("add b"); + database + .remove_provider_endpoint(key, "https://c.example") + .expect("remove c"); + }); + } + }); + let (_, endpoints) = snapshot(&database, "claude", "weave"); + assert_eq!( + endpoints, + vec![ + ("https://a.example".to_string(), Some(2), Some(7)), + ("https://b.example".to_string(), Some(3), None), + ], + "interleaved endpoint operations must all land exactly once" + ); +} + +// --------------------------------------------------------------------------- +// T10:服务入口认证绑定(syn 级:真实 #[test] 函数且真的触达 ProviderService) +// --------------------------------------------------------------------------- + +#[test] +fn certify_service_entry_tests_present() { + let source = fs::read_to_string(source_root().join("services/provider/mod.rs")) + .expect("read services/provider/mod.rs"); + let syntax = syn::parse_file(&source).expect("parse services/provider/mod.rs"); + for required in [ + "provider_service_create_owns_initial_endpoints_and_duplicate_is_atomic", + "provider_service_stale_edit_payload_cannot_overwrite_endpoint_operations", + "provider_service_db_only_rename_matrix_is_atomic_and_lossless", + ] { + let function = find_fn(&syntax.items, required) + .unwrap_or_else(|| panic!("bound service-entry test '{required}' is missing")); + assert!( + function + .attrs + .iter() + .any(|attribute| attribute.path().is_ident("test")), + "'{required}' must be a #[test] function" + ); + let mut probe = IdentProbe::default(); + probe.visit_block(&function.block); + assert!( + probe.found.contains("ProviderService"), + "'{required}' must exercise ProviderService (empty stubs cannot pass)" + ); + } +} diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index e42d8d893..8953c2fc7 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -6,7 +6,7 @@ use indexmap::IndexMap; use rusqlite::{params, OptionalExtension, Row}; use std::collections::{HashMap, HashSet}; -struct StoredProviderRow { +pub(super) struct StoredProviderRow { id: String, name: String, settings_config: String, @@ -22,7 +22,7 @@ struct StoredProviderRow { } impl StoredProviderRow { - fn from_row(row: &Row<'_>) -> rusqlite::Result { + pub(super) fn from_row(row: &Row<'_>) -> rusqlite::Result { Ok(Self { id: row.get(0)?, name: row.get(1)?, @@ -39,7 +39,7 @@ impl StoredProviderRow { }) } - fn decode(self, app_type: &str) -> Result { + pub(super) fn decode(self, app_type: &str) -> Result { let (settings_config, mut meta) = decode_provider_json(app_type, &self.id, &self.settings_config, &self.meta)?; // Child rows are the sole endpoint authority. Do not expose a stale @@ -96,7 +96,7 @@ pub(crate) fn validate_provider_storage_json( decode_provider_json(app_type, provider_id, settings_config, meta).map(|_| ()) } -const PROVIDER_SELECT: &str = +pub(super) const PROVIDER_SELECT: &str = "SELECT id, name, settings_config, website_url, category, created_at, sort_index, notes, icon, icon_color, meta, in_failover_queue FROM providers"; @@ -306,27 +306,6 @@ impl Database { Ok(()) } - pub fn update_provider_settings_config( - &self, - app_type: &str, - provider_id: &str, - settings_config: &serde_json::Value, - ) -> Result<(), AppError> { - let conn = lock_conn!(self.conn); - conn.execute( - "UPDATE providers SET settings_config = ?1 WHERE id = ?2 AND app_type = ?3", - params![ - serde_json::to_string(settings_config).map_err(|e| AppError::Database(format!( - "Failed to serialize settings_config: {e}" - )))?, - provider_id, - app_type - ], - ) - .map_err(|e| AppError::Database(e.to_string()))?; - Ok(()) - } - pub fn set_omo_provider_current( &self, app_type: &str, @@ -1034,6 +1013,7 @@ mod aggregate_tests { &tx, &key, &row, + snapshot.provider.created_at, snapshot.provider.sort_index, false, snapshot.provider.in_failover_queue, diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index e6c39dc9b..2c895dfae 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -1,3 +1,5 @@ +#![cfg(test)] + //! 数据库模块测试 //! //! 包含 Schema 迁移和基本功能的测试。 diff --git a/src-tauri/src/deeplink/tests.rs b/src-tauri/src/deeplink/tests.rs index 010d5bcae..49fcea1d1 100644 --- a/src-tauri/src/deeplink/tests.rs +++ b/src-tauri/src/deeplink/tests.rs @@ -1,3 +1,5 @@ +#![cfg(test)] + //! Deep link module tests use super::mcp::parse_mcp_apps; diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 4cf39e8ff..00bcc6aff 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -11,6 +11,11 @@ pub enum AppError { InvalidInput(String), #[error("未找到: {0}")] NotFound(String), + /// 结构化冲突:并发前置期望失败(如 reconcile 的 ExpectAbsent 撞上竞争 + /// 创建、ExpectPresent 的指纹过期)。调用方据此重读重试或上浮,不得解析 + /// Database(String) 文本。由前置工程 A 认证契约引入(T9)。 + #[error("并发冲突: {0}")] + Conflict(String), #[error("IO 错误: {path}: {source}")] Io { path: String, diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 32852f5ad..71e006169 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -145,6 +145,72 @@ impl Provider { } } + pub(crate) fn row_content_fingerprint(&self) -> String { + use sha2::{Digest, Sha256}; + + fn hash_canonical(value: &serde_json::Value, hasher: &mut Sha256) { + match value { + serde_json::Value::Null => hasher.update(b"n"), + serde_json::Value::Bool(value) => { + hasher.update(b"b"); + hasher.update([*value as u8]); + } + serde_json::Value::Number(value) => { + let text = value.to_string(); + hasher.update(b"#"); + hasher.update((text.len() as u64).to_le_bytes()); + hasher.update(text.as_bytes()); + } + serde_json::Value::String(value) => { + hasher.update(b"s"); + hasher.update((value.len() as u64).to_le_bytes()); + hasher.update(value.as_bytes()); + } + serde_json::Value::Array(items) => { + hasher.update(b"["); + hasher.update((items.len() as u64).to_le_bytes()); + for item in items { + hash_canonical(item, hasher); + } + hasher.update(b"]"); + } + serde_json::Value::Object(map) => { + hasher.update(b"{"); + hasher.update((map.len() as u64).to_le_bytes()); + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + for key in keys { + hasher.update((key.len() as u64).to_le_bytes()); + hasher.update(key.as_bytes()); + hash_canonical(&map[key.as_str()], hasher); + } + hasher.update(b"}"); + } + } + } + + let mut meta = serde_json::to_value(&self.meta).unwrap_or(serde_json::Value::Null); + if let serde_json::Value::Object(map) = &mut meta { + map.remove("custom_endpoints"); + map.remove("customEndpoints"); + } + let mut hasher = Sha256::new(); + for part in [ + serde_json::Value::String(self.name.clone()), + self.settings_config.clone(), + serde_json::to_value(&self.website_url).unwrap_or(serde_json::Value::Null), + serde_json::to_value(&self.category).unwrap_or(serde_json::Value::Null), + serde_json::to_value(&self.notes).unwrap_or(serde_json::Value::Null), + serde_json::to_value(&self.icon).unwrap_or(serde_json::Value::Null), + serde_json::to_value(&self.icon_color).unwrap_or(serde_json::Value::Null), + meta, + ] { + hash_canonical(&part, &mut hasher); + hasher.update([0u8]); + } + format!("{:x}", hasher.finalize()) + } + pub fn is_codex_oauth(&self) -> bool { self.provider_type() == Some("codex_oauth") } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 381d271d1..02379d057 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -20,7 +20,8 @@ use super::gemini_auth::{ detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType, }; use super::{ - normalize_claude_models_in_value, provider_to_mutation_input, reconcile_provider_record, + normalize_claude_models_in_value, provider_row_fingerprint, provider_to_mutation_input, + reconcile_provider_record_with_precondition, ReconcilePrecondition, }; /// ChatGPT Codex catalogs gpt-5.6 at a 372K context window with a ~353K @@ -1566,10 +1567,11 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result Result Result Result { let existing = existing.provider; if existing.settings_config != settings_config { + let fingerprint = provider_row_fingerprint(&existing); let mut provider = existing; provider.settings_config = settings_config; - if let Err(e) = reconcile_provider_record( + if let Some(meta) = provider.meta.as_mut() { + meta.custom_endpoints.clear(); + } + if let Err(e) = reconcile_provider_record_with_precondition( &state.db, "openclaw", provider_to_mutation_input(provider), + ReconcilePrecondition::ExpectPresent { fingerprint }, ) { log::warn!( "Failed to update OpenClaw provider '{id}' from live config: {e}" @@ -1873,9 +1888,12 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result Result { let existing = existing.provider; if existing.settings_config != config { + let fingerprint = provider_row_fingerprint(&existing); let mut provider = existing; provider.settings_config = config; - if let Err(e) = reconcile_provider_record( + if let Some(meta) = provider.meta.as_mut() { + meta.custom_endpoints.clear(); + } + if let Err(e) = reconcile_provider_record_with_precondition( &state.db, "hermes", provider_to_mutation_input(provider), + ReconcilePrecondition::ExpectPresent { fingerprint }, ) { log::warn!( "Failed to update Hermes provider '{name}' from live config: {e}" @@ -1948,9 +1971,12 @@ pub fn import_hermes_providers_from_live(state: &AppState) -> Result String { + provider.row_content_fingerprint() +} + +/// Reconcile paths must carry the caller's observed state into the write. +/// Creation is strict, while updates compare the observed row fingerprint and +/// write under one database lock and transaction. +pub(crate) fn reconcile_provider_record_with_precondition( db: &crate::database::Database, app_type: &str, input: ProviderMutationInput, + precondition: ReconcilePrecondition, ) -> Result<(), AppError> { - let key = ProviderKey::new(app_type, input.id.clone())?; - if db.get_provider_aggregate(app_type, key.id())?.is_some() { - let row = ProviderRowUpdate::from_input(&input)?; - db.update_provider(&key, &row) - } else { - db.create_provider(NewProviderAggregate::from_input(app_type, input)?) + match precondition { + ReconcilePrecondition::ExpectAbsent => { + db.create_provider(NewProviderAggregate::from_input(app_type, input)?) + } + ReconcilePrecondition::ExpectPresent { fingerprint } => { + let key = ProviderKey::new(app_type, input.id.clone())?; + let row = ProviderRowUpdate::from_input(&input)?; + db.update_provider_if_content_fingerprint(&key, &fingerprint, &row) + } } } @@ -5149,15 +5176,22 @@ impl ProviderService { // 同步到 Claude if let Some(mut claude_provider) = provider.to_claude_provider() { // 合并已有配置 - if let Some(existing) = state.db.get_provider_by_id(&claude_provider.id, "claude")? { + let precondition = if let Some(existing) = + state.db.get_provider_by_id(&claude_provider.id, "claude")? + { + let fingerprint = provider_row_fingerprint(&existing); let mut merged = existing.settings_config.clone(); Self::merge_json(&mut merged, &claude_provider.settings_config); claude_provider.settings_config = merged; - } - reconcile_provider_record( + ReconcilePrecondition::ExpectPresent { fingerprint } + } else { + ReconcilePrecondition::ExpectAbsent + }; + reconcile_provider_record_with_precondition( &state.db, "claude", provider_to_mutation_input(claude_provider), + precondition, )?; } else { // 如果禁用了 Claude,删除对应的子供应商 @@ -5168,15 +5202,21 @@ impl ProviderService { // 同步到 Codex if let Some(mut codex_provider) = provider.to_codex_provider() { // 合并已有配置 - if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, "codex")? { - let mut merged = existing.settings_config.clone(); - Self::merge_json(&mut merged, &codex_provider.settings_config); - codex_provider.settings_config = merged; - } - reconcile_provider_record( + let precondition = + if let Some(existing) = state.db.get_provider_by_id(&codex_provider.id, "codex")? { + let fingerprint = provider_row_fingerprint(&existing); + let mut merged = existing.settings_config.clone(); + Self::merge_json(&mut merged, &codex_provider.settings_config); + codex_provider.settings_config = merged; + ReconcilePrecondition::ExpectPresent { fingerprint } + } else { + ReconcilePrecondition::ExpectAbsent + }; + reconcile_provider_record_with_precondition( &state.db, "codex", provider_to_mutation_input(codex_provider), + precondition, )?; } else { let codex_id = format!("universal-codex-{id}"); @@ -5186,15 +5226,22 @@ impl ProviderService { // 同步到 Gemini if let Some(mut gemini_provider) = provider.to_gemini_provider() { // 合并已有配置 - if let Some(existing) = state.db.get_provider_by_id(&gemini_provider.id, "gemini")? { + let precondition = if let Some(existing) = + state.db.get_provider_by_id(&gemini_provider.id, "gemini")? + { + let fingerprint = provider_row_fingerprint(&existing); let mut merged = existing.settings_config.clone(); Self::merge_json(&mut merged, &gemini_provider.settings_config); gemini_provider.settings_config = merged; - } - reconcile_provider_record( + ReconcilePrecondition::ExpectPresent { fingerprint } + } else { + ReconcilePrecondition::ExpectAbsent + }; + reconcile_provider_record_with_precondition( &state.db, "gemini", provider_to_mutation_input(gemini_provider), + precondition, )?; } else { let gemini_id = format!("universal-gemini-{id}"); diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 104bbdc59..7c651585f 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -4,13 +4,14 @@ use crate::app_config::AppType; use crate::config::{get_claude_settings_path, read_json_file, write_json_file}; -use crate::database::Database; +use crate::database::{Database, ProviderKey, ProviderRowUpdate}; use crate::provider::Provider; use crate::proxy::server::ProxyServer; use crate::proxy::switch_lock::SwitchLockManager; use crate::proxy::types::*; use crate::services::provider::{ - build_effective_settings_with_common_config, write_live_with_common_config, + build_effective_settings_with_common_config, provider_to_mutation_input, + write_live_with_common_config, }; use serde_json::{json, Map, Value}; use std::str::FromStr; @@ -1055,11 +1056,16 @@ impl ProxyService { } } - if let Err(e) = self.db.update_provider_settings_config( - "claude", - &provider_id, - &provider.settings_config, - ) { + if let Some(meta) = provider.meta.as_mut() { + meta.custom_endpoints.clear(); + } + let input = provider_to_mutation_input(provider); + let result = + ProviderKey::new("claude", &provider_id).and_then(|key| { + let row = ProviderRowUpdate::from_input(&input)?; + self.db.update_provider(&key, &row) + }); + if let Err(e) = result { log::warn!("同步 Claude Token 到数据库失败: {e}"); } else { log::info!( @@ -1116,11 +1122,15 @@ impl ProxyService { } } - if let Err(e) = self.db.update_provider_settings_config( - "codex", - &provider_id, - &provider.settings_config, - ) { + if let Some(meta) = provider.meta.as_mut() { + meta.custom_endpoints.clear(); + } + let input = provider_to_mutation_input(provider); + let result = ProviderKey::new("codex", &provider_id).and_then(|key| { + let row = ProviderRowUpdate::from_input(&input)?; + self.db.update_provider(&key, &row) + }); + if let Err(e) = result { log::warn!("同步 Codex Token 到数据库失败: {e}"); } else { log::info!("已同步 Codex Token 到数据库 (provider: {provider_id})"); @@ -1168,11 +1178,15 @@ impl ProxyService { } } - if let Err(e) = self.db.update_provider_settings_config( - "gemini", - &provider_id, - &provider.settings_config, - ) { + if let Some(meta) = provider.meta.as_mut() { + meta.custom_endpoints.clear(); + } + let input = provider_to_mutation_input(provider); + let result = ProviderKey::new("gemini", &provider_id).and_then(|key| { + let row = ProviderRowUpdate::from_input(&input)?; + self.db.update_provider(&key, &row) + }); + if let Err(e) = result { log::warn!("同步 Gemini Token 到数据库失败: {e}"); } else { log::info!( @@ -1211,15 +1225,20 @@ impl ProxyService { format!("更新 Grok Build API Key 失败: {e}") })?; provider.settings_config["config"] = json!(updated); - self.db - .update_provider_settings_config( - "grokbuild", - &provider_id, - &provider.settings_config, - ) - .map_err(|e| { + if let Some(meta) = provider.meta.as_mut() { + meta.custom_endpoints.clear(); + } + let input = provider_to_mutation_input(provider); + let key = ProviderKey::new("grokbuild", &provider_id).map_err( + |e| format!("同步 Grok Build Token 到数据库失败: {e}"), + )?; + let row = + ProviderRowUpdate::from_input(&input).map_err(|e| { format!("同步 Grok Build Token 到数据库失败: {e}") })?; + self.db.update_provider(&key, &row).map_err(|e| { + format!("同步 Grok Build Token 到数据库失败: {e}") + })?; } } } diff --git a/tests/fixtures/pi/provider-write-api-v1.json b/tests/fixtures/pi/provider-write-api-v1.json index 787b22e71..c8776f3bd 100644 --- a/tests/fixtures/pi/provider-write-api-v1.json +++ b/tests/fixtures/pi/provider-write-api-v1.json @@ -3,12 +3,12 @@ "codeAuthority": "src-tauri/src/database/dao/provider_write.rs", "types": { "ProviderKey": ["app_type", "id"], + "ProviderRowCreate": ["content", "created_at"], "ProviderRowUpdate": [ "name", "settings_config", "website_url", "category", - "created_at", "notes", "meta", "icon",