refactor(database): scope untrusted restore to N/N-1

This commit is contained in:
SaladDay
2026-08-02 05:29:48 +00:00
parent ce6edf68a0
commit 3fa6b1f158
6 changed files with 536 additions and 1831 deletions
+9
View File
@@ -50,6 +50,15 @@
> 文档考据说明:修正案 1/2(`pi-support-contracts-amendment-*.md`)的条款已按其自身要求**合并**进 `pi-support-contracts-zh.md` 与 `pi-support-review-contract-zh.md`,独立文件已随合并删除,这是预期状态而非丢失;本文引用的"修正案 2 §F/E1"以合并后的规范文档对应章节为准。
## 4.5 范围修订(用户批准,2026-08-02)
前置 B 的不可信恢复(SQL/binary 双入口)缩围为**仅接受 user_version N 与 N1**
(当前 16/17):迁移语义 invariant 家族四次复发均位于历史迁移链,其对抗深度与
"导入远古备份"的产品价值长尾严重不匹配。v1–v15 输入结构化拒绝;**本地升级链
v1→17 完全不受影响**(LocalUpgrade 就地迁移照旧);升级前备份(N−1)回滚路径
保留并专测。v1–v15 恢复立项为"历史备份导入"独立未来工程,已建成的
MigrationSourceSpec 架构留作其地基。
## 5. 冻结事实(2026-08-01)
- 分支 `feat/pi-native-support`,HEAD = 10f2dacb(R4 检查点),工作树干净;
+25 -21
View File
@@ -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
+23 -9
View File
@@ -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(
+16 -11
View File
@@ -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. |