fix(database): enforce canonical restore trust boundaries

This commit is contained in:
SaladDay
2026-08-01 16:01:00 +00:00
parent 89961dff28
commit 28530ff641
8 changed files with 1064 additions and 140 deletions
@@ -1,5 +1,8 @@
#![cfg(test)]
//! 前置工程 B:Canonical Restore 认证测试套件 v1(测试先行)
// 裁决方授权:认证文件允许豁免纯风格类 clippy lint(不含任何安全/正确性
// lint)。type_complexity 属阈值型风格检查,对测试专用契约文件无意义。
#![allow(clippy::type_complexity)]
//! 前置工程 B:Canonical Restore 认证测试套件 v2(测试先行)
//!
//! 规则与前置 A 完全一致(docs/pi-support-restructure-zh.md):实现方不得
//! 修改本文件;异议上报裁决;全绿是盲审前置条件而非充分条件。
@@ -29,13 +32,44 @@
//! 路径任何组件在检查后不得再经 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)。
//! 其余应绿。任何偏离(非清单红、应红变绿、编译失败)立即上报。
//! ## 首轮认证失败后的契约裁决(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
//! 复发 → 停止并上报用户。
//!
//! ## 当前预期红绿(v2,基于 89961dff 之后的工作树)
//! 首轮实现已使 R1(sort_index 值域)与 R2(auto_vacuum)转绿。当前应红 3:
//! `certify_live_database_secondary_opens_are_read_only`(裁决5)、
//! `certify_untrusted_migration_context_is_threaded`(裁决6)、
//! `certify_imported_circuit_threshold_domain_is_enforced`(裁决7)。
//! 其余 7 项应绿。任何偏离(非清单红、应红变绿、编译失败)立即上报。
//!
//! ## 残余风险与收口
//! 沿用前置 A 的收口边界与残余清单(动态 SQL、trigger/view、Backup API、
@@ -607,3 +641,447 @@ fn certify_incremental_auto_vacuum_survives_restore() {
(canonical stage must inherit INCREMENTAL before its first page is written)"
);
}
// ---------------------------------------------------------------------------
// R5(应红,裁决5):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(应红,裁决6):迁移语义分离——迁移调用实参级绑定
// ---------------------------------------------------------------------------
/// 收集对 `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(应红,裁决7):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"
);
}
}