mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
fix(database): enforce canonical restore trust boundaries
This commit is contained in:
@@ -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(¤t_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', '{}', '{}')",
|
||||
|
||||
Reference in New Issue
Block a user