mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 03:32:25 +08:00
a8246da3d6
The suites were written as executable contracts for the implementation process. Three of their checks only policed that process and would fail on any legitimate future change: - schema.rs/migration.rs/backup.rs pinned by SHA-256 - the impl Database write-surface symbol inventory pinned by name - specific restore test function names asserted to exist The behavioural assertions, the DML column-authority scanner, the type barriers and the pinned oracle fixture hashes are kept: those catch real regressions. Headers are rewritten as contract documentation; the stale red/green ledgers no longer described reality.
1081 lines
42 KiB
Rust
1081 lines
42 KiB
Rust
#![cfg(test)]
|
||
// 裁决方授权:认证文件允许豁免纯风格类 clippy lint(不含任何安全/正确性
|
||
// lint)。type_complexity 属阈值型风格检查,对测试专用契约文件无意义。
|
||
#![allow(clippy::type_complexity)]
|
||
//! 前置工程 B:Canonical Restore 认证测试套件 v2(测试先行)
|
||
//!
|
||
//! 规则与前置 A 完全一致:实现方不得
|
||
//!
|
||
//! ## 与前置 A 的衔接(解冻声明)
|
||
//! 前置 A 套件以 SHA-256 冻结了 schema.rs/migration.rs/backup.rs。本前置
|
||
//! 工程的实现必然修改它们,因此:**pre-B 实现期间(已于 2026-08-02
|
||
//! 终止关账,该窗口已闭合),pre-A 的
|
||
//! `certify_infra_files_stay_frozen_after_restore_closeout` 预期为红**,这是裁决内
|
||
//! 的解冻,不是违规;pre-B 认证通过后由裁决方以终态哈希重冻基线。除此之外
|
||
//! pre-A 的其余 34 项必须全程保持绿——restore 改动破坏写面契约同样是失败。
|
||
//!
|
||
//! ## 范围界定
|
||
//! 既有 backup.rs 测试模块已实现修正案 2 E2 的大部分义务(恶意 schema 双
|
||
//! 入口、user_version 哨兵矩阵、VM/页预算、符号链接/FIFO/大小边界、NULL 与
|
||
//! 显式 ID 保真、canonical 预发布契约)。本套件不重复它们,而是:
|
||
//! 1. 绑定冻结这些既有测试(删除/改名/空壳化即红);
|
||
//! 2. 固化 R4 盲审确认、至今未修的缺口为可执行红灯;
|
||
//! 3. 以结构断言钉死类型屏障与搬运禁令。
|
||
//!
|
||
//! ## 义务清单(实现方交付,非本文件可执行部分)
|
||
//! O1【safety 窗口】`restore_from_backup` 必须自 safety backup 创建前起持有
|
||
//! live 写边界直至 publish 完成——期间任何并发写都不得既错过 safety
|
||
//! backup 又被 publish 覆盖。实现后补 seam 级确定性测试并上报并入。
|
||
//! **盲审重点核查项**:逐行核查 restore_from_backup 的锁范围。
|
||
//! O2【binary TOCTOU】文件解析链(backups 目录文件名解析 → 预检
|
||
//! symlink_metadata → O_NOFOLLOW 打开 → 打开后 fstat 身份复核)必须闭合,
|
||
//! 路径任何组件在检查后不得再经 symlink 重解析。**盲审重点核查项**。
|
||
//! O3 pre-B 认证通过后,裁决方重冻 infra 基线并将本套件红灯清单归零存档。
|
||
//!
|
||
//! ## 首轮认证失败后的契约裁决(v2,2026-08-01)
|
||
//! 首轮组件盲审(止于 89961dff)确认两 High 两 Medium,均属契约真空,
|
||
//! 现裁决如下:
|
||
//! 裁决5【写入所有权边界,High】进程内对 live 数据库文件的**可写连接必须
|
||
//! 唯一**(AppState Database 主连接);对 live 路径的任何其他打开必须
|
||
//! 携带 SQLITE_OPEN_READ_ONLY(database/mod.rs 的版本预检连接是现存违
|
||
//! 例)。restore 在 Database mutex 内自 safety backup 前持锁至 publish
|
||
//! 完成——唯一可写连接使该 mutex 成为真实的全局写边界,堵死"第二连接
|
||
//! 在 safety backup 后提交、被 publish 覆盖"的窗口。结构红灯 R5;
|
||
//! **盲审重点**:全树审计 Connection::open* 调用点与其目标路径。
|
||
//! 裁决6【迁移语义分离,High】迁移链必须携带
|
||
//! `MigrationRunContext { LocalUpgrade, UntrustedRestore }`;一切修复/
|
||
//! 合并/去重步骤(含 v16→v17 的 endpoint 去重)在 UntrustedRestore 下
|
||
//! 必须整体 abort 并给结构化错误,LocalUpgrade 保留既有容错。结构红灯
|
||
//! R6(调用点级绑定:enum 形状/迁移入口签名/不可信入口传
|
||
//! UntrustedRestore/本地入口传 LocalUpgrade);行为义务 O4:用既有
|
||
//! user_version 哨兵机器构造 v16 重复 endpoint 输入,断言 import 整体
|
||
//! Err、零合并——**O4 交付并经裁决并入是首轮重认证的前置条件**,
|
||
//! 不得以结构绿替代语义覆盖。语义注记:该枚举表达"修复策略",
|
||
//! fresh/canonical bootstrap 映射 LocalUpgrade。
|
||
//! 裁决7【REAL 值域,Medium】浮点域列必须声明有限区间;
|
||
//! `circuit_error_rate_threshold` ∈ [0,1] 且有限。行为红灯 R7。
|
||
//! O2 扩展【Windows TOCTOU,Medium】Windows 二进制路径必须基于已验证目录
|
||
//! 句柄相对打开(参考 NtCreateFile RootDirectory 语义)或在打开后按句柄
|
||
//! 身份复核,且补文件级预检;**盲审重点**,Linux 宿主无法执行,逐行核查。
|
||
//! 裁决5 边界补记:重复 Database::init()、其他连接 ATTACH live 库均属
|
||
//! 进程内旁路,纳入全树审计(盲审);**进程外写者**(第二进程、外部
|
||
//! SQLite 客户端、直接替换文件)不受进程内边界约束,single-instance
|
||
//! 插件不是数据库锁——显式列为残余风险,由发布说明与盲审知悉。
|
||
//! 重认证预算:修复后 2 轮 fresh 双审;写入所有权或迁移语义同 invariant
|
||
//! 复发 → 停止并上报用户。
|
||
//!
|
||
//! ## 残余风险与收口
|
||
//! 沿用前置 A 的收口边界与残余清单(动态 SQL、trigger/view、Backup API、
|
||
//! `#[path]` 等);本套件新增接受项:导出路径(`dump_sql`)的 `SELECT *`
|
||
//! 属导出语义,不在搬运禁令内;转移函数命名若脱离
|
||
//! restore/stage/scratch/publish/transfer/canonical 词根,搬运禁令扫描不到,
|
||
//! 由盲审兜底。清单外新绕过按盲审 finding 处理,不再扩充扫描器。
|
||
|
||
use crate::database::Database;
|
||
use std::collections::BTreeSet;
|
||
use std::fs;
|
||
use std::path::{Path, PathBuf};
|
||
use syn::visit::{self, Visit};
|
||
use syn::{Attribute, ImplItem, Item, Lit, Meta};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 基建(与前置 A 同构;各套件自持,保持契约文件独立)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
fn source_root() -> PathBuf {
|
||
Path::new(env!("CARGO_MANIFEST_DIR")).join("src")
|
||
}
|
||
|
||
fn backup_source() -> String {
|
||
fs::read_to_string(source_root().join("database/backup.rs")).expect("read backup.rs")
|
||
}
|
||
|
||
fn schema_source() -> String {
|
||
fs::read_to_string(source_root().join("database/schema.rs")).expect("read schema.rs")
|
||
}
|
||
|
||
fn cfg_expr_requires_test(expr: &str) -> bool {
|
||
let expr = expr.trim();
|
||
if expr == "test" {
|
||
return true;
|
||
}
|
||
let strip = |name: &str| -> Option<&str> {
|
||
expr.strip_prefix(name)
|
||
.and_then(|rest| rest.trim_start().strip_prefix('('))
|
||
.and_then(|rest| rest.strip_suffix(')'))
|
||
};
|
||
if let Some(args) = strip("all") {
|
||
return args.split(',').any(cfg_expr_requires_test);
|
||
}
|
||
if let Some(args) = strip("any") {
|
||
let parts: Vec<&str> = args.split(',').collect();
|
||
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())
|
||
)
|
||
})
|
||
}
|
||
|
||
/// 按函数收集生产字符串字面量(cfg-aware),供搬运禁令做函数级作用域判定。
|
||
#[derive(Default)]
|
||
struct FnLiteralCollector {
|
||
current: Vec<String>,
|
||
per_fn: Vec<(String, Vec<String>)>,
|
||
}
|
||
|
||
impl FnLiteralCollector {
|
||
fn enter_fn(&mut self, name: String, block: &syn::Block) {
|
||
let saved = std::mem::take(&mut self.current);
|
||
self.visit_block(block);
|
||
let literals = std::mem::replace(&mut self.current, saved);
|
||
self.per_fn.push((name, literals));
|
||
}
|
||
}
|
||
|
||
impl<'ast> Visit<'ast> for FnLiteralCollector {
|
||
fn visit_item(&mut self, item: &'ast Item) {
|
||
match item {
|
||
Item::Fn(function) if !attrs_mark_test_only(&function.attrs) => {
|
||
self.enter_fn(function.sig.ident.to_string(), &function.block);
|
||
}
|
||
Item::Impl(item_impl) if !attrs_mark_test_only(&item_impl.attrs) => {
|
||
for impl_item in &item_impl.items {
|
||
let ImplItem::Fn(function) = impl_item else {
|
||
continue;
|
||
};
|
||
if !attrs_mark_test_only(&function.attrs) {
|
||
self.enter_fn(function.sig.ident.to_string(), &function.block);
|
||
}
|
||
}
|
||
}
|
||
Item::Mod(item_mod) if !attrs_mark_test_only(&item_mod.attrs) => {
|
||
if let Some((_, nested)) = &item_mod.content {
|
||
for nested_item in nested {
|
||
self.visit_item(nested_item);
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
fn visit_expr_lit(&mut self, expression: &'ast syn::ExprLit) {
|
||
if let Lit::Str(literal) = &expression.lit {
|
||
self.current.push(literal.value());
|
||
}
|
||
visit::visit_expr_lit(self, expression);
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
fn find_impl_fn_signature(source: &syn::File, name: &str) -> Option<String> {
|
||
for item in &source.items {
|
||
let Item::Impl(item_impl) = item else {
|
||
continue;
|
||
};
|
||
for impl_item in &item_impl.items {
|
||
let ImplItem::Fn(function) = impl_item else {
|
||
continue;
|
||
};
|
||
if function.sig.ident == name {
|
||
let mut signature = String::new();
|
||
for input in &function.sig.inputs {
|
||
if let syn::FnArg::Typed(typed) = input {
|
||
let mut probe = IdentProbe::default();
|
||
probe.visit_type(&typed.ty);
|
||
signature.push_str(&probe.found.into_iter().collect::<Vec<_>>().join(","));
|
||
signature.push(';');
|
||
}
|
||
}
|
||
return Some(signature);
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
#[derive(Default)]
|
||
struct IdentProbe {
|
||
found: BTreeSet<String>,
|
||
}
|
||
|
||
impl<'ast> Visit<'ast> for IdentProbe {
|
||
fn visit_ident(&mut self, identifier: &'ast syn::Ident) {
|
||
self.found.insert(identifier.to_string());
|
||
}
|
||
|
||
// syn 的默认遍历不将方法名作为 ident 访问;绑定探针必须能看见
|
||
// `database.restore_from_backup(...)` 这类方法调用(裁决修正)。
|
||
fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
|
||
self.found.insert(node.method.to_string());
|
||
visit::visit_expr_method_call(self, node);
|
||
}
|
||
|
||
// syn 同样不遍历宏 token,而测试体大量使用 assert!(...) 包裹调用
|
||
// (backup.rs:2828 的 restore_from_backup 即在其中)。与前置 A 扫描器
|
||
// 的宏提取同源:把宏 token 按标识符切词纳入探针(裁决修正,第三层)。
|
||
fn visit_macro(&mut self, mac: &'ast syn::Macro) {
|
||
for word in mac
|
||
.tokens
|
||
.to_string()
|
||
.split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
|
||
{
|
||
if !word.is_empty() {
|
||
self.found.insert(word.to_string());
|
||
}
|
||
}
|
||
visit::visit_macro(self, mac);
|
||
}
|
||
}
|
||
|
||
struct TestHomeGuard(Option<std::ffi::OsString>);
|
||
|
||
impl TestHomeGuard {
|
||
fn set(path: &Path) -> Self {
|
||
let previous = std::env::var_os("CC_SWITCH_TEST_HOME");
|
||
std::env::set_var("CC_SWITCH_TEST_HOME", path);
|
||
Self(previous)
|
||
}
|
||
}
|
||
|
||
impl Drop for TestHomeGuard {
|
||
fn drop(&mut self) {
|
||
match self.0.take() {
|
||
Some(previous) => std::env::set_var("CC_SWITCH_TEST_HOME", previous),
|
||
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// S1:类型屏障——publish 只接受 CanonicalStage,工厂外不可构造
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn certify_publish_consumes_only_canonical_stage() {
|
||
let syntax = syn::parse_file(&backup_source()).expect("parse backup.rs");
|
||
let signature = find_impl_fn_signature(&syntax, "publish_canonical_stage")
|
||
.expect("publish_canonical_stage exists");
|
||
assert!(
|
||
signature.contains("CanonicalStage"),
|
||
"publish must consume CanonicalStage, got param types: {signature}"
|
||
);
|
||
assert!(
|
||
!signature.contains("UntrustedScratch") && !signature.contains("Connection"),
|
||
"publish must not accept raw connections or untrusted scratch: {signature}"
|
||
);
|
||
|
||
// CanonicalStage 字段必须全私有:只有 schema.rs 的 current-schema 工厂
|
||
// 能构造,输入派生的 schema 在类型上无路可发布。
|
||
let schema = syn::parse_file(&schema_source()).expect("parse schema.rs");
|
||
fn find_struct<'a>(items: &'a [Item], name: &str) -> Option<&'a syn::ItemStruct> {
|
||
for item in items {
|
||
match item {
|
||
Item::Struct(item_struct) if item_struct.ident == name => return Some(item_struct),
|
||
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_struct(items, name)) {
|
||
return Some(found);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
let stage = find_struct(&schema.items, "CanonicalStage").expect("CanonicalStage in schema.rs");
|
||
for field in &stage.fields {
|
||
assert!(
|
||
matches!(field.vis, syn::Visibility::Inherited),
|
||
"CanonicalStage fields must stay private to the schema factory"
|
||
);
|
||
}
|
||
// 禁止旁路转换:backup.rs/schema.rs 不得存在 UntrustedScratch →
|
||
// CanonicalStage 的 From/Into 实现。
|
||
for source in [backup_source(), schema_source()] {
|
||
let parsed = syn::parse_file(&source).expect("parse restore sources");
|
||
for item in &parsed.items {
|
||
let Item::Impl(item_impl) = item else {
|
||
continue;
|
||
};
|
||
let Some((_, trait_path, _)) = &item_impl.trait_ else {
|
||
continue;
|
||
};
|
||
let is_conversion = trait_path
|
||
.segments
|
||
.last()
|
||
.is_some_and(|segment| segment.ident == "From" || segment.ident == "Into");
|
||
if !is_conversion {
|
||
continue;
|
||
}
|
||
let mut probe = IdentProbe::default();
|
||
probe.visit_item_impl(item_impl);
|
||
assert!(
|
||
!(probe.found.contains("UntrustedScratch")
|
||
&& probe.found.contains("CanonicalStage")),
|
||
"no conversion between UntrustedScratch and CanonicalStage may exist"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// S2:搬运禁令——restore 侧函数不得使用通配/冲突改写动词
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn certify_restore_transfer_forbids_wildcard_and_conflict_verbs() {
|
||
let syntax = syn::parse_file(&backup_source()).expect("parse backup.rs");
|
||
let mut collector = FnLiteralCollector::default();
|
||
for item in &syntax.items {
|
||
collector.visit_item(item);
|
||
}
|
||
let restore_scoped = |name: &str| {
|
||
[
|
||
"restore",
|
||
"stage",
|
||
"scratch",
|
||
"publish",
|
||
"transfer",
|
||
"canonical",
|
||
"copy_",
|
||
]
|
||
.iter()
|
||
.any(|root| name.contains(root))
|
||
};
|
||
// 核心搬运函数必须在扫描视野内(改名脱离词根即红,防止禁令被绕空)。
|
||
assert!(
|
||
collector
|
||
.per_fn
|
||
.iter()
|
||
.any(|(name, _)| name == "copy_fixed_table"),
|
||
"the fixed-column transfer function 'copy_fixed_table' must exist and be scanned"
|
||
);
|
||
let mut violations = Vec::new();
|
||
for (name, literals) in &collector.per_fn {
|
||
if !restore_scoped(name) {
|
||
continue;
|
||
}
|
||
for literal in literals {
|
||
let upper = literal.to_ascii_uppercase();
|
||
for banned in [
|
||
"SELECT *",
|
||
"INSERT OR IGNORE",
|
||
"INSERT OR REPLACE",
|
||
"ON CONFLICT DO UPDATE",
|
||
"REPLACE INTO",
|
||
] {
|
||
if upper.contains(banned) {
|
||
violations.push(format!("{name}: {banned}"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
assert!(
|
||
violations.is_empty(),
|
||
"restore-side transfer must use fixed column lists and plain INSERT:\n{}",
|
||
violations.join("\n")
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn certify_no_wal_sidecar_manipulation() {
|
||
let syntax = syn::parse_file(&backup_source()).expect("parse backup.rs");
|
||
let mut collector = FnLiteralCollector::default();
|
||
for item in &syntax.items {
|
||
collector.visit_item(item);
|
||
}
|
||
for (name, literals) in &collector.per_fn {
|
||
for literal in literals {
|
||
assert!(
|
||
!literal.contains("-wal") && !literal.contains("-shm"),
|
||
"production fn '{name}' must not touch WAL/SHM sidecar files; \
|
||
all publication goes through the SQLite Backup API"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn certify_untrusted_scratch_hardening_idents_present() {
|
||
// 存在性绑定:NOFOLLOW 打开与打开后身份复核的关键符号不得被移除。
|
||
let syntax = syn::parse_file(&backup_source()).expect("parse backup.rs");
|
||
let mut probe = IdentProbe::default();
|
||
for item in &syntax.items {
|
||
if let Item::Fn(function) = item {
|
||
probe.visit_item_fn(function);
|
||
}
|
||
if let Item::Impl(item_impl) = item {
|
||
probe.visit_item_impl(item_impl);
|
||
}
|
||
if let Item::Struct(item_struct) = item {
|
||
probe.visit_item_struct(item_struct);
|
||
}
|
||
}
|
||
for required in ["O_NOFOLLOW", "SQLITE_OPEN_NOFOLLOW", "symlink_metadata"] {
|
||
assert!(
|
||
probe.found.contains(required),
|
||
"scratch hardening symbol '{required}' disappeared from backup.rs"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// S3:绑定冻结既有认证级测试(删除/改名/空壳化即红)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn certify_bound_restore_tests_present() {
|
||
let syntax = syn::parse_file(&backup_source()).expect("parse backup.rs");
|
||
for (required, must_reference) in [
|
||
(
|
||
"restore_policy_snapshot_is_exhaustive_and_detects_missing_table_fixture",
|
||
"RESTORE_TABLE_SPECS",
|
||
),
|
||
(
|
||
"sql_and_binary_restore_share_canonical_prepublication_contracts",
|
||
"assert_weak_ledgers_are_rebuilt",
|
||
),
|
||
(
|
||
"public_restore_entries_discard_hostile_schema_and_publish_only_canonical_objects",
|
||
"run_restore_entry",
|
||
),
|
||
(
|
||
"public_restore_entries_abort_invalid_rows_without_live_or_ledger_mutation",
|
||
"run_restore_entry",
|
||
),
|
||
(
|
||
"public_restore_entries_preserve_nulls_unknown_json_and_explicit_ids",
|
||
"run_restore_entry",
|
||
),
|
||
(
|
||
"every_supported_user_version_has_a_public_migration_sentinel",
|
||
"SCHEMA_VERSION",
|
||
),
|
||
(
|
||
"import_rejects_cross_file_statements_and_leaves_no_file_behind",
|
||
"import_sql_string",
|
||
),
|
||
(
|
||
"public_sql_restore_discards_input_trigger_before_local_copy",
|
||
"import_sql_string",
|
||
),
|
||
(
|
||
"public_file_restore_entries_reject_symlink_directory_and_fifo",
|
||
"restore_from_backup",
|
||
),
|
||
(
|
||
"restore_file_size_limits_accept_n_and_publicly_reject_n_plus_one",
|
||
"MAX_BINARY_RESTORE_BYTES",
|
||
),
|
||
(
|
||
"public_restore_entries_enforce_vm_and_page_budgets",
|
||
"RestoreLimitGuard",
|
||
),
|
||
("import_still_accepts_a_genuine_export", "export_sql_string"),
|
||
(
|
||
"publish_copies_device_local_ledgers_at_the_commit_boundary",
|
||
"UntrustedScratch",
|
||
),
|
||
] {
|
||
let function = find_fn(&syntax.items, required)
|
||
.unwrap_or_else(|| panic!("bound restore 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(must_reference),
|
||
"'{required}' must exercise '{must_reference}' (empty stubs cannot pass)"
|
||
);
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// R1:导入值域——负 sort_index 不得发布
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn certify_imported_sort_index_domain_is_enforced() {
|
||
use crate::database::NewProviderAggregate;
|
||
use crate::provider::ProviderMutationInput;
|
||
use serde_json::json;
|
||
|
||
let database = Database::memory().expect("memory db");
|
||
let input = ProviderMutationInput {
|
||
id: "domain-probe".to_string(),
|
||
name: "值域探针".to_string(),
|
||
settings_config: json!({"env": {}}),
|
||
website_url: None,
|
||
category: None,
|
||
created_at: Some(1_700_000_000),
|
||
sort_index: Some(7777),
|
||
notes: None,
|
||
meta: None,
|
||
icon: None,
|
||
icon_color: None,
|
||
in_failover_queue: false,
|
||
};
|
||
database
|
||
.create_provider(NewProviderAggregate::from_input("claude", input).expect("build"))
|
||
.expect("create");
|
||
let exported = database.export_sql_string().expect("export");
|
||
assert!(
|
||
exported.contains("7777"),
|
||
"export must contain the sort_index sentinel"
|
||
);
|
||
let hostile = exported.replace("7777", "-1");
|
||
|
||
let target = Database::memory().expect("target db");
|
||
let err = target
|
||
.import_sql_string(&hostile)
|
||
.expect_err("negative sort_index must abort the import: production reads Option<usize>");
|
||
let message = err.to_string();
|
||
assert!(
|
||
!message.is_empty(),
|
||
"domain violation must surface a real error"
|
||
);
|
||
// 值域违规必须整体中止:目标库不得出现该 provider 的任何残留。
|
||
let conn = target.conn.lock().expect("lock target");
|
||
let count: i64 = conn
|
||
.query_row(
|
||
"SELECT COUNT(*) FROM providers WHERE id = 'domain-probe'",
|
||
[],
|
||
|row| row.get(0),
|
||
)
|
||
.expect("count probe");
|
||
assert_eq!(
|
||
count, 0,
|
||
"aborted import must leave no partial provider row"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// R2:publish 后 live 文件必须保持 INCREMENTAL auto-vacuum
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
#[serial_test::serial]
|
||
fn certify_incremental_auto_vacuum_survives_restore() {
|
||
let home = tempfile::tempdir().expect("temp home");
|
||
let _guard = TestHomeGuard::set(home.path());
|
||
let database = Database::init().expect("file-backed db");
|
||
// 空库导出会被 import 的基本状态校验拒绝("未包含有效的供应商或 MCP
|
||
// 数据"),导致本测试在错误的位置变红;必须先播种最小数据,使红灯
|
||
// 精确落在 auto_vacuum 断言上、并在实现修复后能够转绿(裁决修正)。
|
||
{
|
||
use crate::database::NewProviderAggregate;
|
||
use crate::provider::ProviderMutationInput;
|
||
use serde_json::json;
|
||
let input = ProviderMutationInput {
|
||
id: "vacuum-probe".to_string(),
|
||
name: "auto-vacuum 探针".to_string(),
|
||
settings_config: json!({"env": {}}),
|
||
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,
|
||
};
|
||
database
|
||
.create_provider(NewProviderAggregate::from_input("claude", input).expect("build"))
|
||
.expect("seed provider");
|
||
}
|
||
let auto_vacuum_of = |db: &Database| -> i64 {
|
||
let conn = db.conn.lock().expect("lock");
|
||
conn.query_row("PRAGMA auto_vacuum", [], |row| row.get(0))
|
||
.expect("read auto_vacuum")
|
||
};
|
||
assert_eq!(
|
||
auto_vacuum_of(&database),
|
||
2,
|
||
"baseline: production file DB is created with INCREMENTAL auto-vacuum"
|
||
);
|
||
let exported = database.export_sql_string().expect("export");
|
||
database
|
||
.import_sql_string(&exported)
|
||
.expect("roundtrip import of a genuine export");
|
||
assert_eq!(
|
||
auto_vacuum_of(&database),
|
||
2,
|
||
"publish must not silently downgrade the live file to auto_vacuum=NONE \
|
||
(canonical stage must inherit INCREMENTAL before its first page is written)"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// R5:live 库的次级连接必须只读——调用级检查
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 每个函数收集连接打开调用:(调用名, 该调用实参中的 ident 集)。
|
||
#[derive(Default)]
|
||
struct OpenCallCollector {
|
||
calls: Vec<(String, BTreeSet<String>)>,
|
||
}
|
||
|
||
impl<'ast> Visit<'ast> for OpenCallCollector {
|
||
fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
|
||
if let syn::Expr::Path(path) = node.func.as_ref() {
|
||
if let Some(segment) = path.path.segments.last() {
|
||
let name = segment.ident.to_string();
|
||
if matches!(name.as_str(), "open" | "open_with_flags" | "open_in_memory") {
|
||
let mut args = IdentProbe::default();
|
||
for arg in &node.args {
|
||
args.visit_expr(arg);
|
||
}
|
||
self.calls.push((name, args.found));
|
||
}
|
||
}
|
||
}
|
||
visit::visit_expr_call(self, node);
|
||
}
|
||
|
||
fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
|
||
let name = node.method.to_string();
|
||
if matches!(name.as_str(), "open" | "open_with_flags" | "open_in_memory") {
|
||
let mut args = IdentProbe::default();
|
||
for arg in &node.args {
|
||
args.visit_expr(arg);
|
||
}
|
||
self.calls.push((name, args.found));
|
||
}
|
||
visit::visit_expr_method_call(self, node);
|
||
}
|
||
}
|
||
|
||
/// 裁决5 的调用级规则。返回违规描述;空即合规。
|
||
fn live_open_violations(fn_name: &str, calls: &[(String, BTreeSet<String>)]) -> Vec<String> {
|
||
let mut violations = Vec::new();
|
||
let mut primary_opens = 0usize;
|
||
for (call, args) in calls {
|
||
match call.as_str() {
|
||
"open_in_memory" => {}
|
||
"open" => {
|
||
if fn_name == "init" {
|
||
primary_opens += 1;
|
||
} else {
|
||
violations.push(format!(
|
||
"{fn_name}: bare Connection::open is read-write; secondary \
|
||
connections must use open_with_flags + SQLITE_OPEN_READ_ONLY"
|
||
));
|
||
}
|
||
}
|
||
"open_with_flags" => {
|
||
if fn_name == "init" {
|
||
primary_opens += 1;
|
||
} else if !args.contains("SQLITE_OPEN_READ_ONLY") {
|
||
violations.push(format!(
|
||
"{fn_name}: open_with_flags without SQLITE_OPEN_READ_ONLY"
|
||
));
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
if fn_name == "init" && primary_opens > 1 {
|
||
violations.push(
|
||
"init: more than one primary open; the writable live connection must be unique"
|
||
.to_string(),
|
||
);
|
||
}
|
||
violations
|
||
}
|
||
|
||
fn collect_open_calls(items: &[Item], out: &mut Vec<(String, Vec<(String, BTreeSet<String>)>)>) {
|
||
for item in items {
|
||
match item {
|
||
Item::Fn(function) if !attrs_mark_test_only(&function.attrs) => {
|
||
let mut collector = OpenCallCollector::default();
|
||
collector.visit_block(&function.block);
|
||
out.push((function.sig.ident.to_string(), collector.calls));
|
||
}
|
||
Item::Impl(item_impl) if !attrs_mark_test_only(&item_impl.attrs) => {
|
||
for impl_item in &item_impl.items {
|
||
let ImplItem::Fn(function) = impl_item else {
|
||
continue;
|
||
};
|
||
if !attrs_mark_test_only(&function.attrs) {
|
||
let mut collector = OpenCallCollector::default();
|
||
collector.visit_block(&function.block);
|
||
out.push((function.sig.ident.to_string(), collector.calls));
|
||
}
|
||
}
|
||
}
|
||
Item::Mod(item_mod) if !attrs_mark_test_only(&item_mod.attrs) => {
|
||
if let Some((_, nested)) = &item_mod.content {
|
||
collect_open_calls(nested, out);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn certify_live_database_secondary_opens_are_read_only() {
|
||
// 负向 fixture 先证明检查器会响(修复后假绿是 R9 终审确认的 P0)。
|
||
let fixture = r#"
|
||
impl Database {
|
||
fn bad_bare(path: &Path) { let _ = Connection::open(path); }
|
||
fn bad_flags(path: &Path) {
|
||
let _ = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE);
|
||
}
|
||
fn good_flags(path: &Path) {
|
||
let _ = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY);
|
||
}
|
||
fn good_memory() { let _ = Connection::open_in_memory(); }
|
||
fn init(path: &Path) {
|
||
let _ = Connection::open(path);
|
||
let _ = Connection::open(path);
|
||
}
|
||
}
|
||
"#;
|
||
let parsed = syn::parse_file(fixture).expect("parse fixture");
|
||
let mut fixture_fns = Vec::new();
|
||
collect_open_calls(&parsed.items, &mut fixture_fns);
|
||
let violations_of = |name: &str, fns: &[(String, Vec<(String, BTreeSet<String>)>)]| {
|
||
fns.iter()
|
||
.filter(|(fn_name, _)| fn_name == name)
|
||
.flat_map(|(fn_name, calls)| live_open_violations(fn_name, calls))
|
||
.collect::<Vec<_>>()
|
||
};
|
||
assert!(!violations_of("bad_bare", &fixture_fns).is_empty());
|
||
assert!(!violations_of("bad_flags", &fixture_fns).is_empty());
|
||
assert!(violations_of("good_flags", &fixture_fns).is_empty());
|
||
assert!(violations_of("good_memory", &fixture_fns).is_empty());
|
||
assert!(
|
||
!violations_of("init", &fixture_fns).is_empty(),
|
||
"a second primary open inside init must be flagged"
|
||
);
|
||
|
||
// 真实检查:database/mod.rs 全部生产函数。
|
||
let source =
|
||
fs::read_to_string(source_root().join("database/mod.rs")).expect("read database/mod.rs");
|
||
let syntax = syn::parse_file(&source).expect("parse database/mod.rs");
|
||
let mut fns = Vec::new();
|
||
collect_open_calls(&syntax.items, &mut fns);
|
||
let mut violations = Vec::new();
|
||
for (name, calls) in &fns {
|
||
if name == "memory" {
|
||
continue; // 测试内存库工厂
|
||
}
|
||
violations.extend(live_open_violations(name, calls));
|
||
}
|
||
assert!(
|
||
violations.is_empty(),
|
||
"writable live-database connections must be unique to init: {violations:?}"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// R6:迁移语义分离——迁移调用实参级绑定
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// 收集对 `apply_schema_migrations_on_conn` 的每次调用及其实参 ident 集。
|
||
#[derive(Default)]
|
||
struct MigrationCallCollector {
|
||
calls: Vec<BTreeSet<String>>,
|
||
}
|
||
|
||
impl<'ast> Visit<'ast> for MigrationCallCollector {
|
||
fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
|
||
if let syn::Expr::Path(path) = node.func.as_ref() {
|
||
if path
|
||
.path
|
||
.segments
|
||
.last()
|
||
.is_some_and(|segment| segment.ident == "apply_schema_migrations_on_conn")
|
||
{
|
||
let mut args = IdentProbe::default();
|
||
for arg in &node.args {
|
||
args.visit_expr(arg);
|
||
}
|
||
self.calls.push(args.found);
|
||
}
|
||
}
|
||
visit::visit_expr_call(self, node);
|
||
}
|
||
|
||
fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
|
||
if node.method == "apply_schema_migrations_on_conn" {
|
||
let mut args = IdentProbe::default();
|
||
for arg in &node.args {
|
||
args.visit_expr(arg);
|
||
}
|
||
self.calls.push(args.found);
|
||
}
|
||
visit::visit_expr_method_call(self, node);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn certify_untrusted_migration_context_is_threaded() {
|
||
// 函数体 ident 聚合会被迁移函数内部的 `match context` 分支假绿
|
||
// (R10 终审 P0),因此绑定到调用实参:
|
||
// 1) enum 精确形状;2) 确切入口 apply_schema_migrations_on_conn 的签名
|
||
// 携带 context;3) 不可信区域的迁移调用实参含 UntrustedRestore 且绝无
|
||
// LocalUpgrade;4) 本地区域的迁移调用实参含 LocalUpgrade 且绝无
|
||
// UntrustedRestore。v16 去重 fail-closed 的行为覆盖由义务 O4 交付
|
||
// (首轮重认证前置条件)。语义注记:枚举表达"修复策略",
|
||
// fresh/canonical bootstrap 映射 LocalUpgrade。
|
||
let schema = syn::parse_file(&schema_source()).expect("parse schema.rs");
|
||
let migration_source =
|
||
fs::read_to_string(source_root().join("database/migration.rs")).expect("read migration.rs");
|
||
let migration = syn::parse_file(&migration_source).expect("parse migration.rs");
|
||
|
||
// 1) enum 精确形状
|
||
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 context_enum = find_enum(&schema.items, "MigrationRunContext")
|
||
.or_else(|| find_enum(&migration.items, "MigrationRunContext"))
|
||
.expect("MigrationRunContext enum must exist in the migration chain");
|
||
let variants: Vec<String> = context_enum
|
||
.variants
|
||
.iter()
|
||
.map(|variant| variant.ident.to_string())
|
||
.collect();
|
||
assert_eq!(
|
||
variants,
|
||
vec!["LocalUpgrade".to_string(), "UntrustedRestore".to_string()],
|
||
"MigrationRunContext variants drifted from the adjudicated contract"
|
||
);
|
||
|
||
// 2) 确切入口签名携带 context
|
||
fn entry_takes_context(items: &[Item]) -> bool {
|
||
for item in items {
|
||
match item {
|
||
Item::Fn(function) if function.sig.ident == "apply_schema_migrations_on_conn" => {
|
||
let mut probe = IdentProbe::default();
|
||
for input in &function.sig.inputs {
|
||
if let syn::FnArg::Typed(typed) = input {
|
||
probe.visit_type(&typed.ty);
|
||
}
|
||
}
|
||
return probe.found.contains("MigrationRunContext");
|
||
}
|
||
Item::Impl(item_impl) => {
|
||
for impl_item in &item_impl.items {
|
||
let ImplItem::Fn(function) = impl_item else {
|
||
continue;
|
||
};
|
||
if function.sig.ident != "apply_schema_migrations_on_conn" {
|
||
continue;
|
||
}
|
||
let mut probe = IdentProbe::default();
|
||
for input in &function.sig.inputs {
|
||
if let syn::FnArg::Typed(typed) = input {
|
||
probe.visit_type(&typed.ty);
|
||
}
|
||
}
|
||
return probe.found.contains("MigrationRunContext");
|
||
}
|
||
}
|
||
Item::Mod(item_mod) => {
|
||
if let Some((_, nested)) = &item_mod.content {
|
||
if entry_takes_context(nested) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
assert!(
|
||
entry_takes_context(&schema.items) || entry_takes_context(&migration.items),
|
||
"apply_schema_migrations_on_conn must take MigrationRunContext in its signature"
|
||
);
|
||
|
||
// 3) 不可信区域:UntrustedScratch impl 内的迁移调用
|
||
let backup = syn::parse_file(&backup_source()).expect("parse backup.rs");
|
||
let mut untrusted_calls = MigrationCallCollector::default();
|
||
for item in &backup.items {
|
||
let Item::Impl(item_impl) = item else {
|
||
continue;
|
||
};
|
||
let matches_type = matches!(
|
||
item_impl.self_ty.as_ref(),
|
||
syn::Type::Path(type_path)
|
||
if type_path
|
||
.path
|
||
.segments
|
||
.last()
|
||
.is_some_and(|segment| segment.ident == "UntrustedScratch")
|
||
);
|
||
if !matches_type {
|
||
continue;
|
||
}
|
||
for impl_item in &item_impl.items {
|
||
if let ImplItem::Fn(function) = impl_item {
|
||
untrusted_calls.visit_block(&function.block);
|
||
}
|
||
}
|
||
}
|
||
assert!(
|
||
untrusted_calls
|
||
.calls
|
||
.iter()
|
||
.any(|args| args.contains("UntrustedRestore")),
|
||
"the untrusted scratch pipeline must call migrations with UntrustedRestore"
|
||
);
|
||
assert!(
|
||
untrusted_calls
|
||
.calls
|
||
.iter()
|
||
.all(|args| !args.contains("LocalUpgrade")),
|
||
"the untrusted pipeline must never request tolerant LocalUpgrade repairs"
|
||
);
|
||
|
||
// 4) 本地区域:database/mod.rs ∪ schema.rs ∪ migration.rs 生产函数内的
|
||
// 迁移调用(排除 backup.rs;调用实参而非函数体,match 分支无法假绿)
|
||
let mod_source =
|
||
fs::read_to_string(source_root().join("database/mod.rs")).expect("read database/mod.rs");
|
||
let mod_parsed = syn::parse_file(&mod_source).expect("parse database/mod.rs");
|
||
let mut local_calls = MigrationCallCollector::default();
|
||
fn visit_production_fns(items: &[Item], collector: &mut MigrationCallCollector) {
|
||
for item in items {
|
||
match item {
|
||
Item::Fn(function) if !attrs_mark_test_only(&function.attrs) => {
|
||
collector.visit_block(&function.block);
|
||
}
|
||
Item::Impl(item_impl) if !attrs_mark_test_only(&item_impl.attrs) => {
|
||
for impl_item in &item_impl.items {
|
||
if let ImplItem::Fn(function) = impl_item {
|
||
if !attrs_mark_test_only(&function.attrs) {
|
||
collector.visit_block(&function.block);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Item::Mod(item_mod) if !attrs_mark_test_only(&item_mod.attrs) => {
|
||
if let Some((_, nested)) = &item_mod.content {
|
||
visit_production_fns(nested, collector);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
visit_production_fns(&mod_parsed.items, &mut local_calls);
|
||
visit_production_fns(&schema.items, &mut local_calls);
|
||
visit_production_fns(&migration.items, &mut local_calls);
|
||
assert!(
|
||
local_calls
|
||
.calls
|
||
.iter()
|
||
.any(|args| args.contains("LocalUpgrade")),
|
||
"a production local-upgrade call site must pass LocalUpgrade"
|
||
);
|
||
assert!(
|
||
local_calls
|
||
.calls
|
||
.iter()
|
||
.all(|args| !args.contains("UntrustedRestore")),
|
||
"local upgrade paths must never run under UntrustedRestore semantics"
|
||
);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// R7:REAL 值域——熔断阈值必须限于有限 [0,1](定向篡改)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn certify_imported_circuit_threshold_domain_is_enforced() {
|
||
let database = Database::memory().expect("memory db");
|
||
let exported = database.export_sql_string().expect("export");
|
||
// 定向替换:只动 proxy_config 表插入行里的阈值,不碰 DDL 默认值与
|
||
// model_pricing 的 0.60/0.65 子串(R9 终审确认全局替换会误伤)。
|
||
for hostile_value in ["6.5", "-0.5", "1e999"] {
|
||
let mut replaced = 0usize;
|
||
let hostile: String = exported
|
||
.lines()
|
||
.map(|line| {
|
||
if replaced == 0
|
||
&& line.contains("proxy_config")
|
||
&& !line.to_ascii_uppercase().contains("CREATE TABLE")
|
||
&& line.contains(", 0.6,")
|
||
{
|
||
replaced += 1;
|
||
let mut tampered = line.replacen(", 0.6,", &format!(", {hostile_value},"), 1);
|
||
tampered.push('\n');
|
||
tampered
|
||
} else {
|
||
let mut kept = line.to_string();
|
||
kept.push('\n');
|
||
kept
|
||
}
|
||
})
|
||
.collect();
|
||
assert_eq!(
|
||
replaced, 1,
|
||
"exactly one proxy_config insert row must carry the 0.6 sentinel \
|
||
(export format drifted; escalate for adjudication)"
|
||
);
|
||
|
||
let target = Database::memory().expect("target db");
|
||
target
|
||
.import_sql_string(&hostile)
|
||
.expect_err("circuit_error_rate_threshold outside finite [0,1] must abort the import");
|
||
let conn = target.conn.lock().expect("lock target");
|
||
let out_of_domain: i64 = conn
|
||
.query_row(
|
||
"SELECT COUNT(*) FROM proxy_config
|
||
WHERE circuit_error_rate_threshold < 0.0
|
||
OR circuit_error_rate_threshold > 1.0",
|
||
[],
|
||
|row| row.get(0),
|
||
)
|
||
.expect("count out-of-domain thresholds");
|
||
assert_eq!(
|
||
out_of_domain, 0,
|
||
"aborted import must leave no out-of-domain breaker thresholds behind"
|
||
);
|
||
}
|
||
}
|