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
+328 -17
View File
@@ -2,7 +2,7 @@
//!
//! 提供 SQL 导出/导入和二进制快照备份功能。
use super::schema::CanonicalStage;
use super::schema::{CanonicalStage, MigrationRunContext};
use super::{lock_conn, Database, SCHEMA_VERSION};
use crate::config::get_app_config_dir;
use crate::error::AppError;
@@ -208,12 +208,19 @@ enum IntegerDomain {
InputTokenSemantics,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RealDomain {
NotReal,
FiniteUnitInterval,
}
#[derive(Debug, Clone, Copy)]
struct RestoreColumnSpec {
name: &'static str,
storage: StorageKind,
nullable: bool,
integer_domain: IntegerDomain,
real_domain: RealDomain,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -244,6 +251,7 @@ macro_rules! text_col {
storage: StorageKind::Text,
nullable: false,
integer_domain: IntegerDomain::Unrestricted,
real_domain: RealDomain::NotReal,
}
};
}
@@ -255,6 +263,7 @@ macro_rules! nullable_text_col {
storage: StorageKind::Text,
nullable: true,
integer_domain: IntegerDomain::Unrestricted,
real_domain: RealDomain::NotReal,
}
};
}
@@ -266,6 +275,7 @@ macro_rules! integer_col {
storage: StorageKind::Integer,
nullable: false,
integer_domain: IntegerDomain::$domain,
real_domain: RealDomain::NotReal,
}
};
}
@@ -277,17 +287,19 @@ macro_rules! nullable_integer_col {
storage: StorageKind::Integer,
nullable: true,
integer_domain: IntegerDomain::$domain,
real_domain: RealDomain::NotReal,
}
};
}
macro_rules! real_col {
($name:literal) => {
($name:literal, $domain:ident) => {
RestoreColumnSpec {
name: $name,
storage: StorageKind::Real,
nullable: false,
integer_domain: IntegerDomain::Unrestricted,
real_domain: RealDomain::$domain,
}
};
}
@@ -398,7 +410,7 @@ const PROXY_CONFIG_RESTORE_COLUMNS: &[RestoreColumnSpec] = &[
integer_col!("circuit_failure_threshold", NonNegativeI32),
integer_col!("circuit_success_threshold", NonNegativeI32),
integer_col!("circuit_timeout_seconds", NonNegativeI32),
real_col!("circuit_error_rate_threshold"),
real_col!("circuit_error_rate_threshold", FiniteUnitInterval),
integer_col!("circuit_min_requests", NonNegativeI32),
text_col!("default_cost_multiplier"),
text_col!("pricing_model_source"),
@@ -761,6 +773,19 @@ fn same_open_file_identity(opened: &File, current: &File) -> std::io::Result<boo
Ok(windows_file_identity(opened)? == windows_file_identity(current)?)
}
#[cfg(windows)]
fn metadata_is_reparse_point(metadata: &Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
fn metadata_is_reparse_point(_metadata: &Metadata) -> bool {
false
}
#[cfg(all(not(unix), not(windows)))]
fn same_open_file_identity(opened: &File, current: &File) -> std::io::Result<bool> {
let _ = (opened, current);
@@ -836,11 +861,89 @@ fn open_backup_child(
#[cfg(windows)]
fn open_backup_child(
_directory: &File,
directory_path: &Path,
directory: &File,
_directory_path: &Path,
filename: &std::ffi::OsStr,
) -> std::io::Result<File> {
open_nofollow(&directory_path.join(filename))
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::{AsRawHandle, FromRawHandle};
use windows_sys::Wdk::Foundation::OBJECT_ATTRIBUTES;
use windows_sys::Wdk::Storage::FileSystem::{
NtCreateFile, FILE_NON_DIRECTORY_FILE, FILE_OPEN, FILE_OPEN_REPARSE_POINT,
FILE_SYNCHRONOUS_IO_NONALERT,
};
use windows_sys::Win32::Foundation::{
CloseHandle, RtlNtStatusToDosError, INVALID_HANDLE_VALUE, OBJ_CASE_INSENSITIVE,
UNICODE_STRING,
};
use windows_sys::Win32::Storage::FileSystem::{
FILE_ATTRIBUTE_NORMAL, FILE_READ_ATTRIBUTES, FILE_READ_DATA, FILE_SHARE_READ,
FILE_SHARE_WRITE, SYNCHRONIZE,
};
use windows_sys::Win32::System::IO::IO_STATUS_BLOCK;
let mut wide = filename.encode_wide().collect::<Vec<_>>();
if wide.contains(&0) {
return Err(std::io::Error::from(std::io::ErrorKind::InvalidInput));
}
let byte_length = wide
.len()
.checked_mul(std::mem::size_of::<u16>())
.and_then(|length| u16::try_from(length).ok())
.ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
let object_name = UNICODE_STRING {
Length: byte_length,
MaximumLength: byte_length,
Buffer: wide.as_mut_ptr(),
};
let object_attributes = OBJECT_ATTRIBUTES {
Length: std::mem::size_of::<OBJECT_ATTRIBUTES>() as u32,
RootDirectory: directory.as_raw_handle(),
ObjectName: std::ptr::addr_of!(object_name),
Attributes: OBJ_CASE_INSENSITIVE,
SecurityDescriptor: std::ptr::null(),
SecurityQualityOfService: std::ptr::null(),
};
let mut io_status = IO_STATUS_BLOCK::default();
let mut handle = INVALID_HANDLE_VALUE;
// SAFETY: `directory` remains live for the call and is installed as
// RootDirectory; `object_name` points to `wide` for the same duration.
// NtCreateFile writes only the handle and IO status output buffers.
let status = unsafe {
NtCreateFile(
std::ptr::addr_of_mut!(handle),
FILE_READ_DATA | FILE_READ_ATTRIBUTES | SYNCHRONIZE,
std::ptr::addr_of!(object_attributes),
std::ptr::addr_of_mut!(io_status),
std::ptr::null(),
FILE_ATTRIBUTE_NORMAL,
FILE_SHARE_READ | FILE_SHARE_WRITE,
FILE_OPEN,
FILE_NON_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT,
std::ptr::null(),
0,
)
};
if status < 0 {
if handle != INVALID_HANDLE_VALUE && !handle.is_null() {
// SAFETY: a non-invalid handle written on the failure path is
// still owned by this function and must not leak.
unsafe {
CloseHandle(handle);
}
}
return Err(std::io::Error::from_raw_os_error(
unsafe { RtlNtStatusToDosError(status) } as i32,
));
}
if handle == INVALID_HANDLE_VALUE || handle.is_null() {
return Err(std::io::Error::other(
"NtCreateFile succeeded without returning a file handle",
));
}
// SAFETY: NtCreateFile returned a new owned file handle; this transfers its
// sole ownership to File.
Ok(unsafe { File::from_raw_handle(handle) })
}
#[cfg(all(not(unix), not(windows)))]
@@ -918,7 +1021,7 @@ fn verify_open_file_still_current(
fn validate_regular_file(path: &Path, max_bytes: u64) -> Result<Metadata, AppError> {
let metadata = fs::symlink_metadata(path).map_err(|error| AppError::io(path, error))?;
if !metadata.file_type().is_file() {
if !metadata.file_type().is_file() || metadata_is_reparse_point(&metadata) {
return Err(AppError::InvalidInput(format!(
"restore source must be a regular non-symlink file: {}",
path.display()
@@ -1009,6 +1112,9 @@ fn read_restore_file(path: &Path, max_bytes: u64) -> Result<Vec<u8>, AppError> {
/// the only path SQLite ever opens, so replacing any component of the original
/// path cannot redirect the bytes consumed by the parser.
fn snapshot_binary_restore_file(path: &Path, max_bytes: u64) -> Result<NamedTempFile, AppError> {
// File-level preflight rejects links/reparse points and oversized inputs
// before the authoritative directory-handle-relative open below.
let initial = validate_regular_file(path, max_bytes)?;
let directory_path = path.parent().ok_or_else(|| {
AppError::InvalidInput(format!(
"binary restore source has no parent directory: {}",
@@ -1027,7 +1133,18 @@ fn snapshot_binary_restore_file(path: &Path, max_bytes: u64) -> Result<NamedTemp
let opened = source
.metadata()
.map_err(|error| AppError::io(path, error))?;
if !opened.file_type().is_file() || opened.len() > max_bytes {
#[cfg(unix)]
let changed_before_open = !same_file_identity(&initial, &opened);
#[cfg(not(unix))]
let changed_before_open = {
let _shape_only = initial;
false
};
if !opened.file_type().is_file()
|| metadata_is_reparse_point(&opened)
|| opened.len() > max_bytes
|| changed_before_open
{
return Err(AppError::InvalidInput(format!(
"binary restore source must be a bounded regular file: {}",
path.display()
@@ -1073,6 +1190,8 @@ fn snapshot_binary_restore_file(path: &Path, max_bytes: u64) -> Result<NamedTemp
.map_err(|error| AppError::io(directory_path, error))?;
if !same_source
|| !same_directory
|| metadata_is_reparse_point(&completed)
|| metadata_is_reparse_point(&current_metadata)
|| opened.len() != copied
|| completed.len() != copied
|| current_metadata.len() != copied
@@ -1240,8 +1359,11 @@ impl UntrustedScratch {
self.connection
.set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_TRIGGER, true)
.map_err(|error| AppError::Database(error.to_string()))?;
Database::create_tables_on_conn(&self.connection)?;
Database::apply_schema_migrations_on_conn(&self.connection)?;
Database::create_tables_on_conn(&self.connection, MigrationRunContext::UntrustedRestore)?;
Database::apply_schema_migrations_on_conn(
&self.connection,
MigrationRunContext::UntrustedRestore,
)?;
self.connection
.execute_batch("PRAGMA foreign_keys = ON;")
.map_err(|error| AppError::Database(error.to_string()))?;
@@ -1412,6 +1534,28 @@ fn validate_integer_domain(
}
}
fn validate_real_domain(
table: &str,
column: &RestoreColumnSpec,
value: &Value,
) -> Result<(), AppError> {
let Value::Real(value) = value else {
return Ok(());
};
let valid = match column.real_domain {
RealDomain::NotReal => false,
RealDomain::FiniteUnitInterval => value.is_finite() && (0.0..=1.0).contains(value),
};
if valid {
Ok(())
} else {
Err(AppError::InvalidInput(format!(
"restore row has out-of-domain real {value} at {table}.{} ({:?})",
column.name, column.real_domain
)))
}
}
fn text_value<'a>(table: &str, column: &str, value: &'a Value) -> Result<&'a str, AppError> {
match value {
Value::Text(value) => Ok(value),
@@ -1457,6 +1601,7 @@ fn validate_restore_row(spec: &RestoreTableSpec, values: &[Value]) -> Result<(),
for (column, value) in spec.columns.iter().zip(values) {
validate_storage(spec.name, column, value)?;
validate_integer_domain(spec.name, column, value)?;
validate_real_domain(spec.name, column, value)?;
}
match spec.validator {
RestoreRowValidator::OpaqueStorage => {}
@@ -1642,6 +1787,18 @@ fn assert_restore_policy_topology() -> Result<(), AppError> {
spec.name, column.name
)));
}
if column.storage == StorageKind::Real && column.real_domain == RealDomain::NotReal {
return Err(AppError::Database(format!(
"real restore column '{}.{}' has no finite domain",
spec.name, column.name
)));
}
if column.storage != StorageKind::Real && column.real_domain != RealDomain::NotReal {
return Err(AppError::Database(format!(
"non-real restore column '{}.{}' declares a real domain",
spec.name, column.name
)));
}
}
for parent in spec.parents {
if !seen.contains(parent) {
@@ -2527,9 +2684,9 @@ impl Database {
mod tests {
use super::{
assert_restore_policy_coverage, validate_canonical_behaviors, validate_regular_file,
validate_stage_rows, Database, RestoreFlavor, RestorePolicy, RestoreRowValidator,
StorageKind, UntrustedScratch, MAX_BINARY_RESTORE_BYTES, MAX_SCRATCH_BYTES,
MAX_SQL_IMPORT_BYTES, RESTORE_TABLE_SPECS, SCHEMA_VERSION,
validate_stage_rows, Database, MigrationRunContext, RestoreFlavor, RestorePolicy,
RestoreRowValidator, StorageKind, UntrustedScratch, MAX_BINARY_RESTORE_BYTES,
MAX_SCRATCH_BYTES, MAX_SQL_IMPORT_BYTES, RESTORE_TABLE_SPECS, SCHEMA_VERSION,
TEST_MAX_BACKUP_TRANSIENT_RETRIES, TEST_MAX_PAGE_COUNT, TEST_MAX_VM_STEPS,
};
use crate::error::AppError;
@@ -2666,11 +2823,16 @@ mod tests {
"input_token_semantics"
}
};
let real_domain = match column.real_domain {
super::RealDomain::NotReal => "not_real",
super::RealDomain::FiniteUnitInterval => "finite_unit_interval",
};
serde_json::json!([
column.name,
storage,
column.nullable,
integer_domain
integer_domain,
real_domain
])
}).collect::<Vec<_>>(),
"validator": validator,
@@ -2742,6 +2904,33 @@ mod tests {
source.snapshot_to_memory()
}
fn actual_v16_duplicate_endpoint_source() -> Result<Connection, AppError> {
let source = canonical_restore_source()?;
source.execute_batch(
"PRAGMA foreign_keys = OFF;
DROP TABLE provider_endpoints;
CREATE TABLE provider_endpoints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider_id TEXT NOT NULL,
app_type TEXT NOT NULL,
url TEXT NOT NULL,
added_at INTEGER,
FOREIGN KEY (provider_id, app_type)
REFERENCES providers(id, app_type) ON DELETE CASCADE
);
INSERT INTO provider_endpoints
(id, provider_id, app_type, url, added_at)
VALUES
(1601, 'remote-provider', 'pi', 'https://duplicate-v16.invalid', 20),
(1602, 'remote-provider', 'pi', 'https://duplicate-v16.invalid', 10);
DROP TABLE pi_provider_projections;
DROP TABLE skill_deployments;
ALTER TABLE skills DROP COLUMN enabled_pi;
PRAGMA user_version = 16;",
)?;
Ok(source)
}
fn weak_ledger_source() -> Result<Connection, AppError> {
let source = canonical_restore_source()?;
source.execute_batch(
@@ -2997,6 +3186,9 @@ mod tests {
NonNegativeI32Domain,
Unsigned32Domain,
InputTokenSemanticsDomain,
NegativeCircuitThreshold,
OutOfRangeCircuitThreshold,
NonFiniteCircuitThreshold,
NegativeProviderMultiplier,
NegativeProviderMetaLimit,
InvalidProviderPricingSource,
@@ -3010,7 +3202,7 @@ mod tests {
}
impl InvalidRestoreCase {
const ALL: [Self; 22] = [
const ALL: [Self; 25] = [
Self::ProviderJson,
Self::McpTagsShape,
Self::ProfilePayloadShape,
@@ -3023,6 +3215,9 @@ mod tests {
Self::NonNegativeI32Domain,
Self::Unsigned32Domain,
Self::InputTokenSemanticsDomain,
Self::NegativeCircuitThreshold,
Self::OutOfRangeCircuitThreshold,
Self::NonFiniteCircuitThreshold,
Self::NegativeProviderMultiplier,
Self::NegativeProviderMetaLimit,
Self::InvalidProviderPricingSource,
@@ -3049,6 +3244,9 @@ mod tests {
Self::NonNegativeI32Domain => "non-negative-i32-domain",
Self::Unsigned32Domain => "unsigned-32-domain",
Self::InputTokenSemanticsDomain => "input-token-semantics-domain",
Self::NegativeCircuitThreshold => "negative-circuit-threshold",
Self::OutOfRangeCircuitThreshold => "out-of-range-circuit-threshold",
Self::NonFiniteCircuitThreshold => "non-finite-circuit-threshold",
Self::NegativeProviderMultiplier => "negative-provider-multiplier",
Self::NegativeProviderMetaLimit => "negative-provider-meta-limit",
Self::InvalidProviderPricingSource => "invalid-provider-pricing-source",
@@ -3169,6 +3367,27 @@ mod tests {
[],
)?;
}
InvalidRestoreCase::NegativeCircuitThreshold => {
source.execute(
"UPDATE proxy_config SET circuit_error_rate_threshold = -0.5
WHERE app_type = 'claude'",
[],
)?;
}
InvalidRestoreCase::OutOfRangeCircuitThreshold => {
source.execute(
"UPDATE proxy_config SET circuit_error_rate_threshold = 6.5
WHERE app_type = 'claude'",
[],
)?;
}
InvalidRestoreCase::NonFiniteCircuitThreshold => {
source.execute(
"UPDATE proxy_config SET circuit_error_rate_threshold = ?1
WHERE app_type = 'claude'",
[f64::INFINITY],
)?;
}
InvalidRestoreCase::NegativeProviderMultiplier => {
source.execute(
"UPDATE providers SET cost_multiplier = '-1'
@@ -3622,6 +3841,60 @@ mod tests {
Ok(())
}
#[test]
#[serial]
fn public_restore_entries_reject_v16_endpoint_repair_without_merging() -> Result<(), AppError> {
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
context: "create v16 duplicate endpoint restore home".to_string(),
source: error,
})?;
let _home_guard = TestHomeGuard::set(test_home.path());
for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary]
.into_iter()
.enumerate()
{
let source = actual_v16_duplicate_endpoint_source()?;
let target = Database::memory()?;
seed_live_restore_state(&target)?;
let before = logical_snapshot(&target)?;
let error = run_restore_entry(
&target,
&source,
entry_point,
&format!("duplicate-v16-{entry_index}.db"),
)
.expect_err("an untrusted v16 duplicate endpoint must not be repaired");
assert!(
matches!(error, AppError::InvalidInput(_)),
"migration repair rejection must remain structured: {error:?}"
);
assert!(
error.to_string().contains("migration repair is forbidden"),
"restore must fail at the untrusted migration boundary: {error}"
);
assert_eq!(
logical_snapshot(&target)?,
before,
"failed v16 repair changed live state via entry {entry_index}"
);
let duplicate_count: i64 = source.query_row(
"SELECT COUNT(*) FROM provider_endpoints
WHERE provider_id = 'remote-provider'
AND app_type = 'pi'
AND url = 'https://duplicate-v16.invalid'",
[],
|row| row.get(0),
)?;
assert_eq!(
duplicate_count, 2,
"untrusted migration must not merge the source rows"
);
}
Ok(())
}
#[test]
#[serial]
fn every_supported_user_version_has_a_public_migration_sentinel() -> Result<(), AppError> {
@@ -3991,6 +4264,41 @@ mod tests {
Ok(())
}
#[test]
#[serial]
fn repeated_live_database_init_is_rejected_until_primary_drops() -> Result<(), AppError> {
let test_home = tempfile::tempdir().map_err(|error| AppError::IoContext {
context: "create single-writer ownership home".to_string(),
source: error,
})?;
let _home_guard = TestHomeGuard::set(test_home.path());
let primary = Database::init()?;
let live_path = crate::config::get_app_config_dir().join("cc-switch.db");
assert_eq!(
Database::stored_user_version_exceeds_supported(&live_path)?,
None,
"the read-only version probe must coexist with the primary writer"
);
let duplicate = match Database::init() {
Ok(_) => {
return Err(AppError::Message(
"a second writable live Database unexpectedly initialized".to_string(),
));
}
Err(error) => error,
};
assert!(
matches!(duplicate, AppError::Conflict(_)),
"duplicate live init must be a structured conflict, got {duplicate:?}"
);
drop(primary);
let reopened = Database::init()?;
drop(reopened);
Ok(())
}
#[test]
#[serial]
fn restore_safety_backup_and_publish_hold_one_live_write_boundary() -> Result<(), AppError> {
@@ -4446,8 +4754,11 @@ mod tests {
let large_page_path = backup_dir.join("page-size-64k.db");
let large_page_source = Connection::open(&large_page_path)?;
large_page_source.execute_batch("PRAGMA page_size = 65536; VACUUM;")?;
Database::create_tables_on_conn(&large_page_source)?;
Database::apply_schema_migrations_on_conn(&large_page_source)?;
Database::create_tables_on_conn(&large_page_source, MigrationRunContext::LocalUpgrade)?;
Database::apply_schema_migrations_on_conn(
&large_page_source,
MigrationRunContext::LocalUpgrade,
)?;
large_page_source.execute(
"INSERT INTO providers (id, app_type, name, settings_config, meta)
VALUES ('large-page-source', 'pi', 'Large Page', '{}', '{}')",