lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 1c585556179cc36df79ff5c289ef097d1029429b
parent f3d4425bd41b266bf5bd6b882d387283009a414c
Author: triesap <tyson@radroots.org>
Date:   Mon,  3 Aug 2026 04:30:30 +0000

storage-sqlite: classify exact legacy schemas

- reverify finalized evidence before opening predecessor snapshots
- pin event-store histories and every supported schema fingerprint
- reject unknown mixed newer tampered and cross-target inputs
- route Studio state to explicit host handoff outside SDK storage

Diffstat:
Mcontracts/storage/legacy_import_backup_policy_v1.toml | 2+-
Acontracts/storage/legacy_schema_classification_v1.toml | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/storage_sqlite/README.md | 9+++++++++
Mcrates/storage_sqlite/src/legacy.rs | 914+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/storage_sqlite/src/open.rs | 25+++++++++++++++++++++++++
5 files changed, 987 insertions(+), 15 deletions(-)

diff --git a/contracts/storage/legacy_import_backup_policy_v1.toml b/contracts/storage/legacy_import_backup_policy_v1.toml @@ -9,7 +9,7 @@ capture = "sqlite_vacuum_into" staging = ".radroots-legacy-import-{import_id_hex}.staging" finalized = "radroots-legacy-import-{import_id_hex}" member_mode = "0600" -manifest = "manifest.v1_exact_identity_timestamp_source_provenance_inventory_lengths_sha256" +manifest = "manifest.v1_exact_identity_target_generation_timestamp_source_provenance_inventory_lengths_sha256" verification = ["sqlite_quick_check", "foreign_key_check", "exact_length", "sha256"] finalization = "same_root_atomic_directory_rename" mutation_before_finalized_backup = false diff --git a/contracts/storage/legacy_schema_classification_v1.toml b/contracts/storage/legacy_schema_classification_v1.toml @@ -0,0 +1,52 @@ +schema_version = 1 +catalog_algorithm = "sqlite_schema_non_internal_type_name_table_sql_nul_sha256_v1" +unknown_objects = "reject" +mixed_source_families = "reject" +newer_versions = "reject" +target_mutation = false + +[event_store] +versions = [1, 2, 3, 4] +unledgered_version = 1 +ledger = "radroots_event_store_schema_migrations_exact_catalog_and_contiguous_rows" +schema_sha256 = [ + "5b1f92779640f1a2dbd75e37a96996bda6c8be58883190f69eb3eced22a48f03", + "1fee6b2bb8cdc4602d9c89fecd97c3f51312b9a4339dbf5049b04c692ba50b12", + "dd12467e04addcbddb5ea0f386c12a8ac05ef5ebaaf949f24dd2c62745f5aaac", + "074f85b663444ac150239ecd8441ea4a96ad83a798a55e22d2e5e2f7ee943a8c", +] +names = ["event_store", "nip09", "food_availability_projection", "source_maintenance"] +up_sha256 = [ + "4c03906a1cffd418a48d40907aa9a1ca51bb41766cff7250c4dfc7c2fd6eddde", + "0c1730ff36eaebd285f9c0c94b9b7346af60266afa55c24a18e30446d369581a", + "4e7edfb981b25f76055efc7802ec30b4034eeae9b9c0809ea4ea7c574678748a", + "ab2724188f8d08c897eebea2533a635e7c74282a25e84e4c0c37e78b08837a43", +] +down_sha256 = [ + "fa84d587f657f601947eaeb9cd239c962a48f6fcdce723588476e8d22f3c1f53", + "c51a099d9501f1e692c13d2226296a68ed9e6bfa5e8e46b2f12c6574dbe59e31", + "29d663320109d9dd0df6a00b6a53d8d988438d01f7a66960a9d4ba3482ffffb8", + "fe44fd53c51545c08ea479b385e6781079dab70fc63da2a3c205d727a00ce860", +] + +[outbox] +version = 1 +user_version = 0 +catalog_sha256 = "e7eeba00de78ec6d990c620e7c056018166e8a00bb703e472ef6f67a00870293" +source = "crates/outbox/migrations/0001_outbox.up.sql" +schema_sql_sha256 = "a7ee775d32c2b9f845961425362e1b1e558ce0d025f7d22dd58f118ba4dab4fa" + +[private] +version = 1 +user_version = 1 +catalog_sha256 = "5aa3664e3ecb4461bde0589c3e8f73be041b715a83a67add366af975f827614e" +source = "oss/sdk/crates/sdk/src/private_store.rs::PRIVATE_STORE_MIGRATION_UP" +schema_sql_sha256 = "f7e71d2cf4347f9b78bafd37980901441b09d111f15d94d260eb7133b626fbe9" + +[studio] +version = 1 +user_version = 0 +catalog_sha256 = "3e13518dba056db82090a336833618ca1bc3a44ba49067967ad9bf4c22768193" +source = "oss/sdk/crates/sdk/src/studio_store.rs::STUDIO_STORE_MIGRATION_UP" +schema_sql_sha256 = "9c5b33810ad9746421dc843651eb02c77d6fc8f00fd630cb835c15b4e36d0590" +disposition = "host_handoff_not_sdk_import" diff --git a/crates/storage_sqlite/README.md b/crates/storage_sqlite/README.md @@ -39,6 +39,15 @@ finalizes the immutable bundle. Import identities and timestamps are supplied by the host; collisions, owned-database aliases, and unsupported paths fail closed. +Classification revalidates the finalized manifest, member inventory, hashes, +SQLite integrity, and foreign keys before inspecting any schema. Event-store +versions 1 through 4 require the exact governed catalog and, when present, the +exact contiguous migration ledger; outbox, private, and Studio predecessors +require their exact catalog and application version. Unknown objects, mixed +source families, checksum drift, and newer schemas fail closed. Studio records +are explicitly classified for host handoff and are never imported into SDK +storage. + ```rust,no_run use radroots_storage::event::SourceGeneration; use radroots_storage_sqlite::{OpenMode, OpenOptions, Paths, SqliteStorage}; diff --git a/crates/storage_sqlite/src/legacy.rs b/crates/storage_sqlite/src/legacy.rs @@ -7,14 +7,68 @@ use std::{ path::{Component, Path, PathBuf}, }; -use radroots_storage::{backup::MemberDigest, status::EventStoreMode}; +use radroots_storage::{backup::MemberDigest, event::SourceGeneration, status::EventStoreMode}; use sha2::{Digest, Sha256}; -use sqlx::{Connection, SqliteConnection, sqlite::SqliteConnectOptions}; +use sqlx::{Connection, Row, SqliteConnection, sqlite::SqliteConnectOptions}; use crate::{Error, SqliteStorage}; const LEGACY_SOURCE_MAX: usize = 4; const LEGACY_MANIFEST: &str = "manifest.v1"; +const EVENT_STORE_LEDGER: &str = "radroots_event_store_schema_migrations"; +const EVENT_STORE_LEDGER_DDL: &str = "CREATE TABLE radroots_event_store_schema_migrations ( + version INTEGER PRIMARY KEY NOT NULL CHECK (version > 0), + name TEXT NOT NULL UNIQUE CHECK (length(name) > 0), + up_sha256 TEXT NOT NULL CHECK (length(up_sha256) = 64 AND up_sha256 NOT GLOB '*[^0-9a-f]*'), + down_sha256 TEXT NOT NULL CHECK (length(down_sha256) = 64 AND down_sha256 NOT GLOB '*[^0-9a-f]*'), + schema_sha256 TEXT NOT NULL CHECK (length(schema_sha256) = 64 AND schema_sha256 NOT GLOB '*[^0-9a-f]*') +) STRICT, WITHOUT ROWID"; + +const EVENT_STORE_MIGRATIONS: [LegacyEventMigration; 4] = [ + LegacyEventMigration { + version: 1, + name: "event_store", + up_sha256: "4c03906a1cffd418a48d40907aa9a1ca51bb41766cff7250c4dfc7c2fd6eddde", + down_sha256: "fa84d587f657f601947eaeb9cd239c962a48f6fcdce723588476e8d22f3c1f53", + schema_sha256: "5b1f92779640f1a2dbd75e37a96996bda6c8be58883190f69eb3eced22a48f03", + }, + LegacyEventMigration { + version: 2, + name: "nip09", + up_sha256: "0c1730ff36eaebd285f9c0c94b9b7346af60266afa55c24a18e30446d369581a", + down_sha256: "c51a099d9501f1e692c13d2226296a68ed9e6bfa5e8e46b2f12c6574dbe59e31", + schema_sha256: "1fee6b2bb8cdc4602d9c89fecd97c3f51312b9a4339dbf5049b04c692ba50b12", + }, + LegacyEventMigration { + version: 3, + name: "food_availability_projection", + up_sha256: "4e7edfb981b25f76055efc7802ec30b4034eeae9b9c0809ea4ea7c574678748a", + down_sha256: "29d663320109d9dd0df6a00b6a53d8d988438d01f7a66960a9d4ba3482ffffb8", + schema_sha256: "dd12467e04addcbddb5ea0f386c12a8ac05ef5ebaaf949f24dd2c62745f5aaac", + }, + LegacyEventMigration { + version: 4, + name: "source_maintenance", + up_sha256: "ab2724188f8d08c897eebea2533a635e7c74282a25e84e4c0c37e78b08837a43", + down_sha256: "fe44fd53c51545c08ea479b385e6781079dab70fc63da2a3c205d727a00ce860", + schema_sha256: "074f85b663444ac150239ecd8441ea4a96ad83a798a55e22d2e5e2f7ee943a8c", + }, +]; +const OUTBOX_CATALOG_SHA256: &str = + "e7eeba00de78ec6d990c620e7c056018166e8a00bb703e472ef6f67a00870293"; +const PRIVATE_CATALOG_SHA256: &str = + "5aa3664e3ecb4461bde0589c3e8f73be041b715a83a67add366af975f827614e"; +const STUDIO_CATALOG_SHA256: &str = + "3e13518dba056db82090a336833618ca1bc3a44ba49067967ad9bf4c22768193"; + +#[derive(Clone, Copy)] +struct LegacyEventMigration { + version: u32, + name: &'static str, + up_sha256: &'static str, + down_sha256: &'static str, + schema_sha256: &'static str, +} /// Stable identity for one forward-only legacy import attempt. #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] @@ -67,6 +121,117 @@ impl LegacySourceKind { } } +/// Exact supported predecessor schema selected by fail-closed classification. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[non_exhaustive] +pub enum LegacySchema { + EventStoreV1, + EventStoreV2, + EventStoreV3, + EventStoreV4, + OutboxV1, + PrivateV1, + StudioV1HostHandoff, +} + +impl LegacySchema { + /// Returns the stable schema identifier recorded by the importer. + pub const fn as_str(self) -> &'static str { + match self { + Self::EventStoreV1 => "event_store_v1", + Self::EventStoreV2 => "event_store_v2", + Self::EventStoreV3 => "event_store_v3", + Self::EventStoreV4 => "event_store_v4", + Self::OutboxV1 => "outbox_v1", + Self::PrivateV1 => "private_v1", + Self::StudioV1HostHandoff => "studio_v1_host_handoff", + } + } + + /// Returns whether this source is converted into owned storage or handed to its host. + pub const fn disposition(self) -> LegacyImportDisposition { + match self { + Self::StudioV1HostHandoff => LegacyImportDisposition::HostHandoff, + Self::EventStoreV1 + | Self::EventStoreV2 + | Self::EventStoreV3 + | Self::EventStoreV4 + | Self::OutboxV1 + | Self::PrivateV1 => LegacyImportDisposition::Import, + } + } +} + +/// Required destination behavior for one classified predecessor source. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[non_exhaustive] +pub enum LegacyImportDisposition { + Import, + HostHandoff, +} + +/// Exact schema evidence for one classified predecessor snapshot. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LegacySourceClassification { + kind: LegacySourceKind, + schema: LegacySchema, + user_version: u32, + catalog_sha256: MemberDigest, +} + +impl LegacySourceClassification { + /// Returns the predecessor source family. + pub const fn kind(&self) -> LegacySourceKind { + self.kind + } + + /// Returns the exact supported predecessor schema. + pub const fn schema(&self) -> LegacySchema { + self.schema + } + + /// Returns the observed SQLite application user version. + pub const fn user_version(&self) -> u32 { + self.user_version + } + + /// Returns the exact governed SQLite schema-catalog fingerprint. + pub const fn catalog_sha256(&self) -> MemberDigest { + self.catalog_sha256 + } +} + +/// Fully reverified classification of a prepared import evidence bundle. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClassifiedLegacyImport { + import_id: LegacyImportId, + target_generation: SourceGeneration, + bundle_path: PathBuf, + sources: Vec<LegacySourceClassification>, +} + +impl ClassifiedLegacyImport { + /// Returns the stable import-attempt identity. + pub const fn import_id(&self) -> LegacyImportId { + self.import_id + } + + /// Returns the exact destination storage generation. + pub const fn target_generation(&self) -> SourceGeneration { + self.target_generation + } + + /// Returns the reverified finalized evidence bundle. + pub fn bundle_path(&self) -> &Path { + &self.bundle_path + } + + /// Returns exact classifications in stable source-family order. + pub fn sources(&self) -> &[LegacySourceClassification] { + &self.sources + } +} + /// One explicitly typed existing predecessor database. #[derive(Clone, Debug, Eq, PartialEq)] pub struct LegacySource { @@ -188,7 +353,10 @@ impl LegacySourceSnapshot { #[derive(Clone, Debug, Eq, PartialEq)] pub struct PreparedLegacyImport { import_id: LegacyImportId, + target_generation: SourceGeneration, bundle_path: PathBuf, + manifest_byte_length: u64, + manifest_sha256: MemberDigest, snapshots: Vec<LegacySourceSnapshot>, } @@ -198,11 +366,26 @@ impl PreparedLegacyImport { self.import_id } + /// Returns the exact destination storage generation. + pub const fn target_generation(&self) -> SourceGeneration { + self.target_generation + } + /// Returns the finalized immutable evidence bundle. pub fn bundle_path(&self) -> &Path { &self.bundle_path } + /// Returns the exact manifest length. + pub const fn manifest_byte_length(&self) -> u64 { + self.manifest_byte_length + } + + /// Returns the exact manifest SHA-256 digest. + pub const fn manifest_sha256(&self) -> MemberDigest { + self.manifest_sha256 + } + /// Returns the exact evidence inventory in stable source-family order. pub fn snapshots(&self) -> &[LegacySourceSnapshot] { &self.snapshots @@ -240,7 +423,9 @@ impl SqliteStorage { capture_legacy_source(source, &destination).await?; snapshots.push(snapshot(source.kind(), &destination)?); } - write_manifest(plan, &snapshots, &layout.staging.join(LEGACY_MANIFEST))?; + let manifest_path = layout.staging.join(LEGACY_MANIFEST); + write_manifest(plan, self.generation, &snapshots, &manifest_path)?; + let (manifest_byte_length, manifest_sha256) = file_digest(&manifest_path)?; sync_directory(&layout.staging, "sync legacy import staging bundle")?; fs::rename(&layout.staging, &layout.finalized).map_err(|source| { Error::LegacyImportFilesystem { @@ -251,10 +436,46 @@ impl SqliteStorage { sync_directory(plan.backup_root(), "sync legacy import backup root")?; Ok(PreparedLegacyImport { import_id: plan.import_id(), + target_generation: self.generation, bundle_path: layout.finalized, + manifest_byte_length, + manifest_sha256, snapshots, }) } + + /// Revalidates a prepared bundle and classifies every exact predecessor schema. + pub async fn classify_legacy_import( + &self, + prepared: &PreparedLegacyImport, + ) -> Result<ClassifiedLegacyImport, Error> { + self.lifecycle + .require_open() + .map_err(|_| Error::BackupBackendUnavailable)?; + if self.mode != EventStoreMode::ReadWrite { + return Err(Error::RestoreRequiresWritableStorage); + } + if prepared.target_generation() != self.generation { + return Err(Error::LegacyImportTargetMismatch); + } + verify_prepared_evidence(prepared).await?; + let mut sources = Vec::with_capacity(prepared.snapshots().len()); + for snapshot in prepared.snapshots() { + sources.push( + classify_snapshot( + snapshot.kind(), + &prepared.bundle_path().join(snapshot.relative_path()), + ) + .await?, + ); + } + Ok(ClassifiedLegacyImport { + import_id: prepared.import_id(), + target_generation: prepared.target_generation(), + bundle_path: prepared.bundle_path().to_path_buf(), + sources, + }) + } } struct LegacyBackupLayout { @@ -382,19 +603,29 @@ async fn verify_legacy_snapshot(kind: LegacySourceKind, path: &Path) -> Result<( } fn snapshot(kind: LegacySourceKind, path: &Path) -> Result<LegacySourceSnapshot, Error> { + let (byte_length, sha256) = file_digest(path)?; + Ok(LegacySourceSnapshot { + kind, + relative_path: kind.backup_file_name().to_owned(), + byte_length, + sha256, + }) +} + +fn file_digest(path: &Path) -> Result<(u64, MemberDigest), Error> { let mut file = File::open(path).map_err(|source| Error::LegacyImportFilesystem { - operation: "open legacy import backup member", + operation: "open legacy import evidence member", source, })?; file.sync_all() .map_err(|source| Error::LegacyImportFilesystem { - operation: "sync legacy import backup member", + operation: "sync legacy import evidence member", source, })?; let byte_length = file .metadata() .map_err(|source| Error::LegacyImportFilesystem { - operation: "inspect legacy import backup member", + operation: "inspect legacy import evidence member", source, })? .len(); @@ -404,7 +635,7 @@ fn snapshot(kind: LegacySourceKind, path: &Path) -> Result<LegacySourceSnapshot, let read = file .read(&mut buffer) .map_err(|source| Error::LegacyImportFilesystem { - operation: "hash legacy import backup member", + operation: "hash legacy import evidence member", source, })?; if read == 0 { @@ -412,22 +643,338 @@ fn snapshot(kind: LegacySourceKind, path: &Path) -> Result<LegacySourceSnapshot, } digest.update(&buffer[..read]); } - Ok(LegacySourceSnapshot { + Ok((byte_length, MemberDigest::new(digest.finalize().into()))) +} + +async fn verify_prepared_evidence(prepared: &PreparedLegacyImport) -> Result<(), Error> { + let bundle_metadata = fs::symlink_metadata(prepared.bundle_path()) + .map_err(|_| Error::LegacyImportEvidenceInvalid)?; + if !bundle_metadata.is_dir() || bundle_metadata.file_type().is_symlink() { + return Err(Error::LegacyImportEvidenceInvalid); + } + let mut expected = BTreeSet::from([LEGACY_MANIFEST.to_owned()]); + expected.extend( + prepared + .snapshots() + .iter() + .map(|snapshot| snapshot.relative_path().to_owned()), + ); + let mut actual = BTreeSet::new(); + for entry in + fs::read_dir(prepared.bundle_path()).map_err(|source| Error::LegacyImportFilesystem { + operation: "read legacy import evidence bundle", + source, + })? + { + let entry = entry.map_err(|source| Error::LegacyImportFilesystem { + operation: "read legacy import evidence entry", + source, + })?; + let name = entry + .file_name() + .into_string() + .map_err(|_| Error::LegacyImportEvidenceInvalid)?; + let metadata = + fs::symlink_metadata(entry.path()).map_err(|_| Error::LegacyImportEvidenceInvalid)?; + if !metadata.is_file() || metadata.file_type().is_symlink() || !actual.insert(name) { + return Err(Error::LegacyImportEvidenceInvalid); + } + } + if actual != expected { + return Err(Error::LegacyImportEvidenceInvalid); + } + let (manifest_length, manifest_digest) = + file_digest(&prepared.bundle_path().join(LEGACY_MANIFEST))?; + if manifest_length != prepared.manifest_byte_length() + || manifest_digest != prepared.manifest_sha256() + { + return Err(Error::LegacyImportEvidenceInvalid); + } + for evidence in prepared.snapshots() { + let path = prepared.bundle_path().join(evidence.relative_path()); + if snapshot(evidence.kind(), &path)? != *evidence { + return Err(Error::LegacyImportEvidenceInvalid); + } + verify_legacy_snapshot(evidence.kind(), &path).await?; + } + Ok(()) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct CatalogRow { + object_type: String, + name: String, + table_name: String, + sql: Option<String>, +} + +async fn classify_snapshot( + kind: LegacySourceKind, + path: &Path, +) -> Result<LegacySourceClassification, Error> { + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(path) + .read_only(true) + .foreign_keys(true), + ) + .await + .map_err(|_| Error::LegacyImportSourceInvalid { + source_kind: kind.as_str(), + })?; + let raw_user_version = sqlx::query_scalar::<_, i64>("PRAGMA user_version") + .fetch_one(&mut connection) + .await + .map_err(|_| Error::LegacyImportSourceInvalid { + source_kind: kind.as_str(), + })?; + let catalog = read_catalog(&mut connection, kind).await?; + let (schema, governed_catalog) = match kind { + LegacySourceKind::EventStore => { + classify_event_store(&mut connection, raw_user_version, &catalog).await? + } + LegacySourceKind::Outbox => ( + classify_fixed_catalog(kind, raw_user_version, &catalog, 0, OUTBOX_CATALOG_SHA256)?, + catalog, + ), + LegacySourceKind::Private => ( + classify_fixed_catalog(kind, raw_user_version, &catalog, 1, PRIVATE_CATALOG_SHA256)?, + catalog, + ), + LegacySourceKind::Studio => ( + classify_fixed_catalog(kind, raw_user_version, &catalog, 0, STUDIO_CATALOG_SHA256)?, + catalog, + ), + }; + let catalog_sha256 = catalog_fingerprint(&governed_catalog); + connection + .close() + .await + .map_err(|_| Error::LegacyImportSourceInvalid { + source_kind: kind.as_str(), + })?; + let user_version = u32::try_from(raw_user_version) + .map_err(|_| unsupported_schema(kind, raw_user_version, catalog_sha256))?; + Ok(LegacySourceClassification { kind, - relative_path: kind.backup_file_name().to_owned(), - byte_length, - sha256: MemberDigest::new(digest.finalize().into()), + schema, + user_version, + catalog_sha256, }) } +async fn read_catalog( + connection: &mut SqliteConnection, + kind: LegacySourceKind, +) -> Result<Vec<CatalogRow>, Error> { + sqlx::query("SELECT type, name, tbl_name, sql FROM main.sqlite_schema") + .fetch_all(connection) + .await + .map_err(|_| Error::LegacyImportSourceInvalid { + source_kind: kind.as_str(), + })? + .into_iter() + .map(|row| { + let name = + row.try_get::<String, _>("name") + .map_err(|_| Error::LegacyImportSourceInvalid { + source_kind: kind.as_str(), + })?; + Ok(CatalogRow { + object_type: row + .try_get("type") + .map_err(|_| Error::LegacyImportSourceInvalid { + source_kind: kind.as_str(), + })?, + table_name: row.try_get("tbl_name").map_err(|_| { + Error::LegacyImportSourceInvalid { + source_kind: kind.as_str(), + } + })?, + sql: row + .try_get("sql") + .map_err(|_| Error::LegacyImportSourceInvalid { + source_kind: kind.as_str(), + })?, + name, + }) + }) + .collect::<Result<Vec<_>, Error>>() + .map(|catalog| { + catalog + .into_iter() + .filter(|row| !row.name.to_ascii_lowercase().starts_with("sqlite_")) + .collect() + }) +} + +fn classify_fixed_catalog( + kind: LegacySourceKind, + user_version: i64, + catalog: &[CatalogRow], + expected_user_version: i64, + expected_catalog_sha256: &str, +) -> Result<LegacySchema, Error> { + let fingerprint = catalog_fingerprint(catalog); + if user_version != expected_user_version + || encode_digest(fingerprint.as_bytes()) != expected_catalog_sha256 + { + return Err(unsupported_schema(kind, user_version, fingerprint)); + } + Ok(match kind { + LegacySourceKind::Outbox => LegacySchema::OutboxV1, + LegacySourceKind::Private => LegacySchema::PrivateV1, + LegacySourceKind::Studio => LegacySchema::StudioV1HostHandoff, + LegacySourceKind::EventStore => return Err(Error::LegacyImportMigrationHistoryInvalid), + }) +} + +async fn classify_event_store( + connection: &mut SqliteConnection, + user_version: i64, + catalog: &[CatalogRow], +) -> Result<(LegacySchema, Vec<CatalogRow>), Error> { + if user_version != 0 { + return Err(unsupported_schema( + LegacySourceKind::EventStore, + user_version, + catalog_fingerprint(catalog), + )); + } + let ledger_rows = catalog + .iter() + .filter(|row| { + row.name.eq_ignore_ascii_case(EVENT_STORE_LEDGER) + || row.table_name.eq_ignore_ascii_case(EVENT_STORE_LEDGER) + }) + .collect::<Vec<_>>(); + let governed = catalog + .iter() + .filter(|row| !row.name.eq_ignore_ascii_case(EVENT_STORE_LEDGER)) + .cloned() + .collect::<Vec<_>>(); + let fingerprint = catalog_fingerprint(&governed); + let version = if ledger_rows.is_empty() { + if encode_digest(fingerprint.as_bytes()) != EVENT_STORE_MIGRATIONS[0].schema_sha256 { + return Err(unsupported_schema( + LegacySourceKind::EventStore, + user_version, + fingerprint, + )); + } + 1 + } else { + if ledger_rows.len() != 1 { + return Err(Error::LegacyImportMigrationHistoryInvalid); + } + let ledger = ledger_rows[0]; + if ledger.object_type != "table" + || ledger.name != EVENT_STORE_LEDGER + || ledger.table_name != EVENT_STORE_LEDGER + || ledger.sql.as_deref() != Some(EVENT_STORE_LEDGER_DDL) + { + return Err(Error::LegacyImportMigrationHistoryInvalid); + } + validate_event_history(connection).await? + }; + let expected = EVENT_STORE_MIGRATIONS + .get(usize::try_from(version - 1).map_err(|_| Error::LegacyImportMigrationHistoryInvalid)?) + .ok_or(Error::LegacyImportMigrationHistoryInvalid)?; + if encode_digest(fingerprint.as_bytes()) != expected.schema_sha256 { + return Err(unsupported_schema( + LegacySourceKind::EventStore, + user_version, + fingerprint, + )); + } + let schema = match version { + 1 => LegacySchema::EventStoreV1, + 2 => LegacySchema::EventStoreV2, + 3 => LegacySchema::EventStoreV3, + 4 => LegacySchema::EventStoreV4, + _ => return Err(Error::LegacyImportMigrationHistoryInvalid), + }; + Ok((schema, governed)) +} + +async fn validate_event_history(connection: &mut SqliteConnection) -> Result<u32, Error> { + let rows = sqlx::query( + "SELECT version, name, up_sha256, down_sha256, schema_sha256 FROM main.radroots_event_store_schema_migrations ORDER BY version", + ) + .fetch_all(connection) + .await + .map_err(|_| Error::LegacyImportMigrationHistoryInvalid)?; + if rows.is_empty() || rows.len() > EVENT_STORE_MIGRATIONS.len() { + return Err(Error::LegacyImportMigrationHistoryInvalid); + } + for (index, row) in rows.iter().enumerate() { + let expected = &EVENT_STORE_MIGRATIONS[index]; + if row.try_get::<i64, _>("version").ok() != Some(i64::from(expected.version)) + || row.try_get::<String, _>("name").ok().as_deref() != Some(expected.name) + || row.try_get::<String, _>("up_sha256").ok().as_deref() != Some(expected.up_sha256) + || row.try_get::<String, _>("down_sha256").ok().as_deref() != Some(expected.down_sha256) + || row.try_get::<String, _>("schema_sha256").ok().as_deref() + != Some(expected.schema_sha256) + { + return Err(Error::LegacyImportMigrationHistoryInvalid); + } + } + u32::try_from(rows.len()).map_err(|_| Error::LegacyImportMigrationHistoryInvalid) +} + +fn catalog_fingerprint(catalog: &[CatalogRow]) -> MemberDigest { + let mut rows = catalog.to_vec(); + rows.sort_by(|left, right| { + ( + left.object_type.as_bytes(), + left.name.as_bytes(), + left.table_name.as_bytes(), + left.sql.as_deref().unwrap_or("").as_bytes(), + ) + .cmp(&( + right.object_type.as_bytes(), + right.name.as_bytes(), + right.table_name.as_bytes(), + right.sql.as_deref().unwrap_or("").as_bytes(), + )) + }); + let mut digest = Sha256::new(); + for row in rows { + for field in [ + row.object_type.as_str(), + row.name.as_str(), + row.table_name.as_str(), + row.sql.as_deref().unwrap_or(""), + ] { + digest.update(field.as_bytes()); + digest.update([0]); + } + } + MemberDigest::new(digest.finalize().into()) +} + +fn unsupported_schema( + kind: LegacySourceKind, + user_version: i64, + catalog_sha256: MemberDigest, +) -> Error { + Error::UnsupportedLegacySchema { + source_kind: kind.as_str(), + user_version, + catalog_sha256: encode_digest(catalog_sha256.as_bytes()), + } +} + fn write_manifest( plan: &LegacyImportPlan, + target_generation: SourceGeneration, snapshots: &[LegacySourceSnapshot], path: &Path, ) -> Result<(), Error> { let mut body = format!( - "schema_version=1\nimport_id={}\nrequested_at_unix_ms={}\n", + "schema_version=1\nimport_id={}\ntarget_generation={}\nrequested_at_unix_ms={}\n", encode_id(plan.import_id().as_bytes()), + encode_digest(target_generation.as_bytes()), plan.requested_at_unix_ms() ); for (source, evidence) in plan.sources().iter().zip(snapshots) { @@ -553,7 +1100,6 @@ const fn bytes_are_zero<const N: usize>(bytes: &[u8; N]) -> bool { mod tests { use radroots_storage::event::SourceGeneration; use serde::Deserialize; - use sqlx::Row; use crate::{OpenMode, OpenOptions, Paths}; @@ -561,6 +1107,8 @@ mod tests { const POLICY: &str = include_str!("../../../contracts/storage/legacy_import_backup_policy_v1.toml"); + const CLASSIFICATION_POLICY: &str = + include_str!("../../../contracts/storage/legacy_schema_classification_v1.toml"); #[derive(Deserialize)] struct Policy { @@ -583,6 +1131,50 @@ mod tests { hidden_entropy_or_clock: bool, } + #[derive(Deserialize)] + struct ClassificationPolicy { + schema_version: u32, + catalog_algorithm: String, + unknown_objects: String, + mixed_source_families: String, + newer_versions: String, + target_mutation: bool, + event_store: EventStorePolicy, + outbox: FixedSchemaPolicy, + private: FixedSchemaPolicy, + studio: StudioSchemaPolicy, + } + + #[derive(Deserialize)] + struct EventStorePolicy { + versions: Vec<u32>, + unledgered_version: u32, + ledger: String, + schema_sha256: Vec<String>, + names: Vec<String>, + up_sha256: Vec<String>, + down_sha256: Vec<String>, + } + + #[derive(Deserialize)] + struct FixedSchemaPolicy { + version: u32, + user_version: i64, + catalog_sha256: String, + source: String, + schema_sql_sha256: String, + } + + #[derive(Deserialize)] + struct StudioSchemaPolicy { + version: u32, + user_version: i64, + catalog_sha256: String, + source: String, + schema_sql_sha256: String, + disposition: String, + } + fn generation(byte: u8) -> SourceGeneration { SourceGeneration::new([byte; 32]).expect("source generation") } @@ -633,6 +1225,33 @@ mod tests { connection } + async fn supported_studio_database(path: &Path) -> SqliteConnection { + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true), + ) + .await + .expect("supported Studio database"); + sqlx::query( + "CREATE TABLE sdk_studio_state ( + key TEXT PRIMARY KEY NOT NULL, + value_json TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL +)", + ) + .execute(&mut connection) + .await + .expect("supported Studio schema"); + sqlx::query( + "INSERT INTO sdk_studio_state(key, value_json, updated_at_ms) VALUES ('theme', '{}', 1)", + ) + .execute(&mut connection) + .await + .expect("supported Studio row"); + connection + } + #[test] fn implementation_matches_the_governed_legacy_backup_policy() { let policy = toml::from_str::<Policy>(POLICY).expect("legacy backup policy"); @@ -664,7 +1283,7 @@ mod tests { assert_eq!(policy.member_mode, "0600"); assert_eq!( policy.manifest, - "manifest.v1_exact_identity_timestamp_source_provenance_inventory_lengths_sha256" + "manifest.v1_exact_identity_target_generation_timestamp_source_provenance_inventory_lengths_sha256" ); assert_eq!( policy.verification, @@ -681,6 +1300,95 @@ mod tests { assert!(!policy.hidden_entropy_or_clock); } + #[test] + fn implementation_matches_the_governed_schema_classification_policy() { + let policy = toml::from_str::<ClassificationPolicy>(CLASSIFICATION_POLICY) + .expect("legacy classification policy"); + assert_eq!(policy.schema_version, 1); + assert_eq!( + policy.catalog_algorithm, + "sqlite_schema_non_internal_type_name_table_sql_nul_sha256_v1" + ); + assert_eq!(policy.unknown_objects, "reject"); + assert_eq!(policy.mixed_source_families, "reject"); + assert_eq!(policy.newer_versions, "reject"); + assert!(!policy.target_mutation); + assert_eq!(policy.event_store.versions, [1, 2, 3, 4]); + assert_eq!(policy.event_store.unledgered_version, 1); + assert_eq!( + policy.event_store.ledger, + "radroots_event_store_schema_migrations_exact_catalog_and_contiguous_rows" + ); + assert_eq!( + policy.event_store.schema_sha256, + EVENT_STORE_MIGRATIONS + .iter() + .map(|migration| migration.schema_sha256) + .collect::<Vec<_>>() + ); + assert_eq!( + policy.event_store.names, + EVENT_STORE_MIGRATIONS + .iter() + .map(|migration| migration.name) + .collect::<Vec<_>>() + ); + assert_eq!( + policy.event_store.up_sha256, + EVENT_STORE_MIGRATIONS + .iter() + .map(|migration| migration.up_sha256) + .collect::<Vec<_>>() + ); + assert_eq!( + policy.event_store.down_sha256, + EVENT_STORE_MIGRATIONS + .iter() + .map(|migration| migration.down_sha256) + .collect::<Vec<_>>() + ); + assert_fixed_schema_policy( + &policy.outbox, + 0, + OUTBOX_CATALOG_SHA256, + "crates/outbox/migrations/0001_outbox.up.sql", + "a7ee775d32c2b9f845961425362e1b1e558ce0d025f7d22dd58f118ba4dab4fa", + ); + assert_fixed_schema_policy( + &policy.private, + 1, + PRIVATE_CATALOG_SHA256, + "oss/sdk/crates/sdk/src/private_store.rs::PRIVATE_STORE_MIGRATION_UP", + "f7e71d2cf4347f9b78bafd37980901441b09d111f15d94d260eb7133b626fbe9", + ); + assert_eq!(policy.studio.version, 1); + assert_eq!(policy.studio.user_version, 0); + assert_eq!(policy.studio.catalog_sha256, STUDIO_CATALOG_SHA256); + assert_eq!( + policy.studio.source, + "oss/sdk/crates/sdk/src/studio_store.rs::STUDIO_STORE_MIGRATION_UP" + ); + assert_eq!( + policy.studio.schema_sql_sha256, + "9c5b33810ad9746421dc843651eb02c77d6fc8f00fd630cb835c15b4e36d0590" + ); + assert_eq!(policy.studio.disposition, "host_handoff_not_sdk_import"); + } + + fn assert_fixed_schema_policy( + policy: &FixedSchemaPolicy, + user_version: i64, + catalog_sha256: &str, + source: &str, + schema_sql_sha256: &str, + ) { + assert_eq!(policy.version, 1); + assert_eq!(policy.user_version, user_version); + assert_eq!(policy.catalog_sha256, catalog_sha256); + assert_eq!(policy.source, source); + assert_eq!(policy.schema_sql_sha256, schema_sql_sha256); + } + #[tokio::test] async fn preparation_captures_wal_state_and_finalizes_exact_immutable_evidence() { let target_root = tempfile::tempdir().expect("target root"); @@ -723,20 +1431,27 @@ mod tests { .await .expect("prepare import"); assert_eq!(prepared.import_id(), plan.import_id()); + assert_eq!(prepared.target_generation(), generation(121)); assert!(prepared.bundle_path().is_dir()); assert_eq!(prepared.snapshots().len(), 2); let manifest_path = prepared.bundle_path().join(LEGACY_MANIFEST); let manifest = fs::read_to_string(&manifest_path).expect("legacy import manifest"); assert_eq!( + file_digest(&manifest_path).expect("manifest digest"), + (prepared.manifest_byte_length(), prepared.manifest_sha256()) + ); + assert_eq!( manifest, format!( concat!( "schema_version=1\n", "import_id=7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a\n", + "target_generation={}\n", "requested_at_unix_ms=12200\n", "member=event_store|{}|event_store.sqlite|{}|{}\n", "member=studio|{}|studio.sqlite|{}|{}\n" ), + encode_digest(generation(121).as_bytes()), encode_hex(plan.sources()[0].path().as_os_str().as_encoded_bytes()), prepared.snapshots()[0].byte_length(), encode_digest(prepared.snapshots()[0].sha256().as_bytes()), @@ -804,6 +1519,177 @@ mod tests { store.close().await.expect("close target"); } + #[tokio::test] + async fn classification_accepts_only_exact_studio_handoff_and_reverifies_evidence() { + let target_root = tempfile::tempdir().expect("target root"); + let legacy_root = tempfile::tempdir().expect("legacy root"); + let backup_root = tempfile::tempdir().expect("backup root"); + let studio_path = legacy_root.path().join("studio.sqlite"); + let studio_connection = supported_studio_database(&studio_path).await; + let plan = LegacyImportPlan::new( + LegacyImportId::new([125; 16]).expect("import id"), + vec![LegacySource::new(LegacySourceKind::Studio, studio_path).expect("Studio source")], + backup_root.path(), + 12_500, + ) + .expect("Studio import plan"); + let (_, store) = target(target_root.path()).await; + let prepared = store + .prepare_legacy_import(&plan) + .await + .expect("prepared Studio import"); + let classified = store + .classify_legacy_import(&prepared) + .await + .expect("classified Studio import"); + assert_eq!(classified.import_id(), plan.import_id()); + assert_eq!(classified.target_generation(), generation(121)); + assert_eq!(classified.bundle_path(), prepared.bundle_path()); + assert_eq!(classified.sources().len(), 1); + let source = &classified.sources()[0]; + assert_eq!(source.kind(), LegacySourceKind::Studio); + assert_eq!(source.schema(), LegacySchema::StudioV1HostHandoff); + assert_eq!( + source.schema().disposition(), + LegacyImportDisposition::HostHandoff + ); + assert_eq!(source.user_version(), 0); + assert_eq!( + encode_digest(source.catalog_sha256().as_bytes()), + STUDIO_CATALOG_SHA256 + ); + + let other_root = tempfile::tempdir().expect("other target root"); + let other_paths = Paths::from_directory(other_root.path()).expect("other target paths"); + let other_store = SqliteStorage::open( + OpenOptions::new(other_paths, OpenMode::Create) + .with_source_generation(generation(126), 12_600) + .expect("other source generation"), + ) + .await + .expect("other target storage"); + assert!(matches!( + other_store.classify_legacy_import(&prepared).await, + Err(Error::LegacyImportTargetMismatch) + )); + other_store.close().await.expect("close other target"); + + fs::write(prepared.bundle_path().join("unexpected"), b"unsupported") + .expect("unexpected evidence member"); + assert!(matches!( + store.classify_legacy_import(&prepared).await, + Err(Error::LegacyImportEvidenceInvalid) + )); + studio_connection + .close() + .await + .expect("close Studio source"); + store.close().await.expect("close target"); + } + + #[test] + fn pure_catalog_classification_rejects_mixed_unknown_and_newer_schemas() { + let studio = vec![CatalogRow { + object_type: "table".to_owned(), + name: "sdk_studio_state".to_owned(), + table_name: "sdk_studio_state".to_owned(), + sql: Some( + "CREATE TABLE sdk_studio_state (\n key TEXT PRIMARY KEY NOT NULL,\n value_json TEXT NOT NULL,\n updated_at_ms INTEGER NOT NULL\n)" + .to_owned(), + ), + }]; + assert_eq!( + classify_fixed_catalog( + LegacySourceKind::Studio, + 0, + &studio, + 0, + STUDIO_CATALOG_SHA256, + ) + .expect("exact Studio schema"), + LegacySchema::StudioV1HostHandoff + ); + assert!(matches!( + classify_fixed_catalog( + LegacySourceKind::Outbox, + 0, + &studio, + 0, + OUTBOX_CATALOG_SHA256, + ), + Err(Error::UnsupportedLegacySchema { .. }) + )); + let mut unknown = studio.clone(); + unknown.push(CatalogRow { + object_type: "table".to_owned(), + name: "unknown".to_owned(), + table_name: "unknown".to_owned(), + sql: Some("CREATE TABLE unknown(value INTEGER)".to_owned()), + }); + assert!(matches!( + classify_fixed_catalog( + LegacySourceKind::Studio, + 0, + &unknown, + 0, + STUDIO_CATALOG_SHA256, + ), + Err(Error::UnsupportedLegacySchema { .. }) + )); + assert!(matches!( + classify_fixed_catalog( + LegacySourceKind::Studio, + 2, + &studio, + 0, + STUDIO_CATALOG_SHA256, + ), + Err(Error::UnsupportedLegacySchema { .. }) + )); + } + + #[tokio::test] + async fn event_store_history_requires_the_exact_contiguous_governed_ledger() { + let mut connection = SqliteConnection::connect("sqlite::memory:") + .await + .expect("event-store history database"); + sqlx::query(EVENT_STORE_LEDGER_DDL) + .execute(&mut connection) + .await + .expect("event-store ledger"); + for migration in EVENT_STORE_MIGRATIONS { + sqlx::query( + "INSERT INTO radroots_event_store_schema_migrations(version, name, up_sha256, down_sha256, schema_sha256) VALUES (?, ?, ?, ?, ?)", + ) + .bind(i64::from(migration.version)) + .bind(migration.name) + .bind(migration.up_sha256) + .bind(migration.down_sha256) + .bind(migration.schema_sha256) + .execute(&mut connection) + .await + .expect("event-store history row"); + } + assert_eq!( + validate_event_history(&mut connection) + .await + .expect("exact event-store history"), + 4 + ); + sqlx::query( + "UPDATE radroots_event_store_schema_migrations SET up_sha256 = ? WHERE version = 4", + ) + .bind("0".repeat(64)) + .execute(&mut connection) + .await + .expect("tamper history"); + assert!(matches!( + validate_event_history(&mut connection).await, + Err(Error::LegacyImportMigrationHistoryInvalid) + )); + connection.close().await.expect("close history database"); + } + #[test] fn plans_reject_zero_identity_duplicates_missing_paths_and_symlinks() { let root = tempfile::tempdir().expect("root"); diff --git a/crates/storage_sqlite/src/open.rs b/crates/storage_sqlite/src/open.rs @@ -275,6 +275,14 @@ pub enum Error { LegacyImportSourceInvalid { source_kind: &'static str, }, + LegacyImportEvidenceInvalid, + LegacyImportTargetMismatch, + LegacyImportMigrationHistoryInvalid, + UnsupportedLegacySchema { + source_kind: &'static str, + user_version: i64, + catalog_sha256: String, + }, LegacyImportFilesystem { operation: &'static str, source: std::io::Error, @@ -530,6 +538,23 @@ impl fmt::Display for Error { formatter, "SQLite legacy {source_kind} source failed integrity validation" ), + Self::LegacyImportEvidenceInvalid => { + formatter.write_str("SQLite legacy import evidence is invalid") + } + Self::LegacyImportTargetMismatch => { + formatter.write_str("SQLite legacy import target generation does not match") + } + Self::LegacyImportMigrationHistoryInvalid => { + formatter.write_str("SQLite legacy event-store migration history is invalid") + } + Self::UnsupportedLegacySchema { + source_kind, + user_version, + catalog_sha256, + } => write!( + formatter, + "unsupported SQLite legacy {source_kind} schema at user_version {user_version} with catalog SHA-256 {catalog_sha256}" + ), Self::LegacyImportFilesystem { operation, .. } => write!( formatter, "SQLite legacy import filesystem operation failed: {operation}"