mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
refactor(database): scope untrusted restore to N/N-1
This commit is contained in:
@@ -1322,7 +1322,9 @@ impl UntrustedScratch {
|
||||
scratch.connection.authorizer(
|
||||
None::<fn(rusqlite::hooks::AuthContext<'_>) -> rusqlite::hooks::Authorization>,
|
||||
);
|
||||
result.map_err(|error| AppError::Database(format!("execute SQL import: {error}")))?;
|
||||
result.map_err(|error| {
|
||||
AppError::InvalidInput(format!("execute untrusted SQL import: {error}"))
|
||||
})?;
|
||||
scratch.finish_input()
|
||||
}
|
||||
|
||||
@@ -1355,6 +1357,10 @@ impl UntrustedScratch {
|
||||
"restore schema version {version} is newer than supported {SCHEMA_VERSION}"
|
||||
)));
|
||||
}
|
||||
// Gate obsolete backups before schema inspection, sanitizing DDL, or
|
||||
// migration dispatch. LocalUpgrade never enters this scratch path and
|
||||
// retains the complete historical in-place migration chain.
|
||||
super::migration_source::require_supported_untrusted_restore_version(version)?;
|
||||
self.drop_untrusted_executable_objects()?;
|
||||
self.connection
|
||||
.set_db_config(DbConfig::SQLITE_DBCONFIG_ENABLE_TRIGGER, true)
|
||||
@@ -1829,16 +1835,19 @@ fn canonical_user_tables(
|
||||
let mut statement = conn
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_schema
|
||||
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
||||
WHERE type = 'table'
|
||||
ORDER BY name",
|
||||
)
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
let tables = statement
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.map_err(|error| AppError::Database(error.to_string()))?
|
||||
.collect::<Result<std::collections::BTreeSet<_>, _>>()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| AppError::Database(error.to_string()))?;
|
||||
Ok(tables)
|
||||
Ok(tables
|
||||
.into_iter()
|
||||
.filter(|name| !super::is_sqlite_internal_table_name(name))
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn assert_restore_policy_coverage(
|
||||
@@ -2444,15 +2453,16 @@ impl Database {
|
||||
let name: String = row.get(1).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
let sql: String = row.get(3).map_err(|e| AppError::Database(e.to_string()))?;
|
||||
|
||||
// 跳过 SQLite 内部对象(如 sqlite_sequence)
|
||||
if name.starts_with("sqlite_") {
|
||||
// Skip only the exact internal objects owned by this SQLite build.
|
||||
// Prefix matching would misclassify names such as `sqliteX`.
|
||||
if super::is_sqlite_internal_table_name(&name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push_str(&sql);
|
||||
output.push_str(";\n");
|
||||
|
||||
if obj_type == "table" && !name.starts_with("sqlite_") {
|
||||
if obj_type == "table" && !super::is_sqlite_internal_table_name(&name) {
|
||||
tables.push(name);
|
||||
}
|
||||
}
|
||||
@@ -4008,9 +4018,8 @@ mod tests {
|
||||
})?;
|
||||
let _home_guard = TestHomeGuard::set(test_home.path());
|
||||
|
||||
// The source-spec authority supports exactly v1..v17. A legacy v0
|
||||
// label must fail before migration DDL instead of being interpreted
|
||||
// through the permissive local-upgrade path.
|
||||
// A legacy v0 label must fail at the N/N-1 gate before migration DDL
|
||||
// instead of being interpreted through the local-upgrade path.
|
||||
for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
@@ -4042,9 +4051,8 @@ mod tests {
|
||||
.expect_err("untrusted v0 is outside the declared source-spec range");
|
||||
assert!(
|
||||
matches!(error, AppError::InvalidInput(_))
|
||||
&& error
|
||||
.to_string()
|
||||
.contains("unsupported restore user_version 0"),
|
||||
&& error.to_string().contains("user_version=0")
|
||||
&& error.to_string().contains("备份版本过旧"),
|
||||
"v0 must fail at source recognition via entry {entry_index}: {error:?}"
|
||||
);
|
||||
}
|
||||
@@ -4095,10 +4103,10 @@ mod tests {
|
||||
assert_eq!(sentinel, (None, 0, 0, 0));
|
||||
}
|
||||
|
||||
// Every supported version is materialized from its exact source spec.
|
||||
// Both supported versions are materialized from their exact source specs.
|
||||
// Each public entry must preserve a real Pi provider and its endpoint;
|
||||
// this cannot pass by stamping a current database with an old version.
|
||||
for version in 1..=SCHEMA_VERSION {
|
||||
// this cannot pass by stamping a v17 database with a v16 label.
|
||||
for version in (SCHEMA_VERSION - 1)..=SCHEMA_VERSION {
|
||||
for (entry_index, entry_point) in [RestoreEntryPoint::Sql, RestoreEntryPoint::Binary]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
@@ -4128,11 +4136,7 @@ mod tests {
|
||||
.contains(&format!("\"migrationVersion\":{version}")),
|
||||
"Pi settings payload was not preserved for v{version}"
|
||||
);
|
||||
assert_eq!(
|
||||
restored.1,
|
||||
if version == 1 { "1.0" } else { "1.25" },
|
||||
"provider migration sentinel v{version}"
|
||||
);
|
||||
assert_eq!(restored.1, "1.25", "provider migration sentinel v{version}");
|
||||
assert_eq!(
|
||||
restored.2,
|
||||
format!("https://pi-endpoint-v{version}.example/v1"),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,12 @@ pub struct Database {
|
||||
|
||||
static LIVE_DATABASE_WRITERS: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
|
||||
|
||||
const SQLITE_INTERNAL_TABLE_NAMES: &[&str] = &["sqlite_sequence", "sqlite_stat1", "sqlite_stat4"];
|
||||
|
||||
pub(crate) fn is_sqlite_internal_table_name(name: &str) -> bool {
|
||||
SQLITE_INTERNAL_TABLE_NAMES.contains(&name)
|
||||
}
|
||||
|
||||
struct LiveDatabaseWriteLease {
|
||||
identity: PathBuf,
|
||||
}
|
||||
@@ -207,9 +213,11 @@ impl Database {
|
||||
conn: Mutex::new(conn),
|
||||
_live_write_lease: Some(live_write_lease),
|
||||
};
|
||||
db.create_tables()?;
|
||||
|
||||
// Pre-migration backup: only when upgrading from an existing database
|
||||
// Pre-migration backup: only when upgrading from an existing database.
|
||||
// This must precede the current-schema factory. Otherwise a v16 file
|
||||
// would be backed up after v17-only tables had been synthesized, making
|
||||
// the nominal v16 rollback image fail its exact source specification.
|
||||
{
|
||||
let conn = lock_conn!(db.conn);
|
||||
let version = Self::get_user_version(&conn)?;
|
||||
@@ -224,6 +232,7 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
db.create_tables()?;
|
||||
db.apply_schema_migrations()?;
|
||||
if let Err(e) = db.ensure_incremental_auto_vacuum() {
|
||||
log::warn!("Failed to ensure incremental auto-vacuum: {e}");
|
||||
@@ -300,14 +309,19 @@ impl Database {
|
||||
}
|
||||
|
||||
fn has_user_tables(conn: &Connection) -> Result<bool, AppError> {
|
||||
let count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
let mut statement = conn
|
||||
.prepare("SELECT name FROM sqlite_schema WHERE type = 'table'")
|
||||
.map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?;
|
||||
Ok(count > 0)
|
||||
let names = statement
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?;
|
||||
for name in names {
|
||||
let name = name.map_err(|e| AppError::Database(format!("读取表数量失败: {e}")))?;
|
||||
if !is_sqlite_internal_table_name(&name) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_incremental_auto_vacuum_on_conn(
|
||||
|
||||
@@ -26,20 +26,25 @@ impl CanonicalStage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Selects the trusted local-upgrade path or the construction-only untrusted
|
||||
/// Selects the trusted local-upgrade path or the construction-only N/N-1
|
||||
/// restore path.
|
||||
///
|
||||
/// # Historical migration classification (blind-review authority)
|
||||
///
|
||||
/// Before this table is consulted, the untrusted database must exactly match
|
||||
/// the `MigrationSourceSpec` for its declared `user_version`: table set,
|
||||
/// column set, and every populated SQLite storage class. No current-schema
|
||||
/// table is created during recognition. Each step is then checked mechanically
|
||||
/// by `validate_migration_mapping_completeness`: every source column is either
|
||||
/// an identity mapping with the same storage class or an explicit typed
|
||||
/// disposition. The complete target source-spec is revalidated after every
|
||||
/// step. Only after the chain reaches v17 may a separately constructed
|
||||
/// canonical stage be created and populated.
|
||||
/// LocalUpgrade retains the complete v1→v17 in-place chain below. The untrusted
|
||||
/// SQL/binary entries are narrower: `UntrustedScratch` rejects v1..v15 before
|
||||
/// this dispatcher, so only v16→v17 is reachable in `UntrustedRestore`.
|
||||
///
|
||||
/// A supported untrusted database must exactly match the v16 or v17
|
||||
/// `MigrationSourceSpec`: table set, column set, and every populated SQLite
|
||||
/// storage class. No current-schema table is created during recognition. The
|
||||
/// v16→v17 step is checked mechanically by
|
||||
/// `validate_migration_mapping_completeness`, and its complete v17 target spec
|
||||
/// is revalidated before a separately constructed canonical stage is populated.
|
||||
///
|
||||
/// Rows v1→v2 through v15→v16 document and protect LocalUpgrade behavior and
|
||||
/// preserve design groundwork for a future “historical backup import” project;
|
||||
/// they are not accepted untrusted restore paths.
|
||||
///
|
||||
/// “Total shape map” means all source values are preserved under that declared
|
||||
/// mapping and all added values are version-owned structural sentinels.
|
||||
@@ -48,7 +53,7 @@ impl CanonicalStage {
|
||||
/// `InvalidInput`. Local repair behavior is never reachable from
|
||||
/// `UntrustedRestore`.
|
||||
///
|
||||
/// | Step | Class | `UntrustedRestore` construction and basis |
|
||||
/// | Step | Class | Transform and preservation basis |
|
||||
/// | --- | --- | --- |
|
||||
/// | v1→v2 | Typed transform + total shape map | The singleton proxy and circuit rows require `id=1`; every field fans out unchanged to the three app rows owned by v2, six ownership settings remain byte-preserved while also producing explicit app flags, and prefixed skill keys map to typed `(directory, app_type)` rows. Missing/malformed rows, unsupported keys, booleans, or numeric domains abort. All settings and identity columns survive; new timestamps use a fixed structural sentinel and v2-owned empty tables are created without canonical seed data. |
|
||||
/// | v2→v3 | Typed transform | Every skill row decodes as `(directory, app_type, installed, installed_at)` and deterministically produces id/name/directory/app enablement; empty directories, unsupported apps, invalid booleans, or collisions abort. No row is deferred to a filesystem rescan. |
|
||||
|
||||
Reference in New Issue
Block a user