refactor(database): complete prerequisite B

This commit is contained in:
SaladDay
2026-08-01 11:44:50 +00:00
parent 22c010079a
commit 2841811700
5 changed files with 1258 additions and 139 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,609 @@
#![cfg(test)]
//! 前置工程 B:Canonical Restore 认证测试套件 v1(测试先行)
//!
//! 规则与前置 A 完全一致(docs/pi-support-restructure-zh.md):实现方不得
//! 修改本文件;异议上报裁决;全绿是盲审前置条件而非充分条件。
//!
//! ## 与前置 A 的衔接(解冻声明)
//! 前置 A 套件以 SHA-256 冻结了 schema.rs/migration.rs/backup.rs。本前置
//! 工程的实现必然修改它们,因此:**pre-B 实现期间,pre-A 的
//! `certify_infra_files_frozen_until_preproject_b` 预期为红**,这是裁决内
//! 的解冻,不是违规;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 基线并将本套件红灯清单归零存档。
//!
//! ## 交接时的预期红绿
//! 应红 2:`certify_imported_sort_index_domain_is_enforced`(值域缺口:
//! RestoreColumnSpec 只查 storage/nullable,负数 sort_index 可发布,生产端
//! Option<usize> 读取即败——R4 finding)、
//! `certify_incremental_auto_vacuum_survives_restore`(canonical stage 未
//! 继承 INCREMENTAL,publish 后 live 文件退化为 NONE——R4 finding)。
//! 其余应绿。任何偏离(非清单红、应红变绿、编译失败)立即上报。
//!
//! ## 残余风险与收口
//! 沿用前置 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)"
);
}
+2
View File
@@ -24,6 +24,8 @@
//! ```
pub(crate) mod backup;
#[cfg(test)]
mod backup_restore_certification;
mod dao;
mod migration;
mod schema;
+2 -1
View File
@@ -517,7 +517,8 @@ impl Database {
Connection::open(file.path()).map_err(|error| AppError::Database(error.to_string()))?;
connection
.execute_batch(
"PRAGMA foreign_keys = ON;
"PRAGMA auto_vacuum = INCREMENTAL;
PRAGMA foreign_keys = ON;
PRAGMA trusted_schema = OFF;",
)
.map_err(|error| AppError::Database(error.to_string()))?;
+1 -1
View File
@@ -7,7 +7,7 @@
"binaryRestoreBytes": 2147483648,
"scratchBytes": 2147483648
},
"specSha256": "c0a680de97b4d3eb291114ebfdf50fdfd65ce7f63819a0bc84f97cec6466bf07",
"specSha256": "c0bf1a34172de0b4d63ca597973c6126d8d5cbba42e73c0b7a7ff043f2a58cdb",
"tables": [
{"name": "providers", "policy": "portable_incoming"},
{"name": "provider_endpoints", "policy": "portable_incoming"},