From 8564d0a23f51ac8de7fd7dda97b38f18c7b5aae1 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Fri, 30 Oct 2026 14:38:35 +0800 Subject: [PATCH] fix(indexeddb): preserve UTF-16 database name identity Preserve database DOMString names as IndexedDbName code units across derived WebIDL conversion, native connection FIFO, SQL service identity, IDBDatabase.name and databases() results. Store database names as UTF-16BE BLOBs in SQLite schema v2. Migrate v1 TEXT metadata inside one SQL transaction after owner/name/generation validation, keeping records, stable ids, generators and logical quota accounting. Extend the existing generation-path hash with canonical WTF-8 so legacy Unicode directory paths remain unchanged. Retain the complete original Window/iframe/Worker conversion, independent-record/version, abort, deletion and cross-agent matrix. Add real disk-profile restart plus legacy migration/failure, exact queue identity, path compatibility and quota-rollback regressions. This database-name topic is independent of object-store/index rename and readonly schema attributes. --- .../fixtures/indexeddb-database-names.js | 182 ++++++++++++ .../tests/web_apis/indexed_db_transaction.rs | 22 ++ moli-indexeddb/src/connection_queue.rs | 13 +- .../src/sqlite/migrations/v2_metadata.sql | 19 ++ moli-indexeddb/src/sqlite/mod.rs | 39 ++- moli-indexeddb/src/sqlite/names.rs | 93 ++++++ moli-indexeddb/src/sqlite/schema.rs | 4 +- moli-indexeddb/src/sqlite/schema.sql | 4 +- moli-indexeddb/src/sqlite/service.rs | 4 +- moli-indexeddb/src/sqlite/service/catalog.rs | 31 +- .../src/sqlite/service/catalog/layout.rs | 68 ++++- .../src/sqlite/service/connections.rs | 12 +- moli-indexeddb/src/sqlite/service/tests.rs | 30 +- .../src/sqlite/service/tests/catalog_tests.rs | 278 +++++++++++++++++- .../service/tests/registration_tests.rs | 2 +- moli-indexeddb/src/sqlite/tests.rs | 1 + .../src/sqlite/tests/database_names.rs | 231 +++++++++++++++ .../src/sqlite/tests/fixtures/schema-v1.sql | 129 ++++++++ moli-indexeddb/src/sqlite_schema_tests.rs | 34 ++- .../core/objects/creation/database.rs | 11 +- .../context_bootstrap/indexed_db/database.rs | 31 +- .../indexed_db/database/factory.rs | 2 +- .../indexed_db/database/factory/databases.rs | 10 +- .../database/factory/delete_database.rs | 6 +- .../indexed_db/database/factory/open.rs | 6 +- .../src/context_bootstrap/indexed_db/mod.rs | 2 +- .../indexed_db/typed_state/factory.rs | 8 +- .../src/script_vm/tests/indexed_db.rs | 70 +++++ 28 files changed, 1257 insertions(+), 85 deletions(-) create mode 100644 moli-core/tests/web_apis/fixtures/indexeddb-database-names.js create mode 100644 moli-indexeddb/src/sqlite/migrations/v2_metadata.sql create mode 100644 moli-indexeddb/src/sqlite/names.rs create mode 100644 moli-indexeddb/src/sqlite/tests/database_names.rs create mode 100644 moli-indexeddb/src/sqlite/tests/fixtures/schema-v1.sql diff --git a/moli-core/tests/web_apis/fixtures/indexeddb-database-names.js b/moli-core/tests/web_apis/fixtures/indexeddb-database-names.js new file mode 100644 index 0000000000..19a8b314f7 --- /dev/null +++ b/moli-core/tests/web_apis/fixtures/indexeddb-database-names.js @@ -0,0 +1,182 @@ +globalThis.databaseNameChecks = []; +async function databaseNamesProbe(prefix = 'database-names') { + const checks = databaseNameChecks; + const check = (label, pass, actual = '') => checks.push({label, pass, actual: String(actual)}); + const units = name => Array.from({length: name.length}, (_, i) => name.charCodeAt(i)).join(','); + const result = request => new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const complete = tx => new Promise((resolve, reject) => { + tx.oncomplete = resolve; + tx.onabort = () => reject(tx.error || new Error('unexpected abort')); + }); + const exercise = async (label, Realm, MethodRealm) => { + const here = (name, pass, actual) => check(label + ': ' + name, pass, actual); + const factory = Realm.indexedDB; + const open = (...args) => MethodRealm.IDBFactory.prototype.open.apply(factory, args); + const remove = (...args) => MethodRealm.IDBFactory.prototype.deleteDatabase.apply(factory, args); + const list = () => MethodRealm.IDBFactory.prototype.databases.call(factory); + const getName = Object.getOwnPropertyDescriptor(MethodRealm.IDBDatabase.prototype, 'name').get; + const stem = prefix + '-' + label + '-'; + const names = ['', '\0', '\ud800', '\udfff', '\ufffd', '\ud801', + '\udfff\ud800', '\ufffd\ufffd', '💾', 'd800', '\ud800\0'].map(s => stem + s); + const retained = []; + for (let i = 0; i < names.length; ++i) { + const conversion = []; + const name = {[Symbol.toPrimitive](hint) { conversion.push(hint); return names[i]; }}; + const version = {valueOf() { conversion.push('version'); return 1; }}; + const request = open(name, version); + here('conversion order ' + i, conversion.join() === 'string,version', conversion); + let oldVersion, upgradeName; + request.onupgradeneeded = event => { + oldVersion = event.oldVersion; + upgradeName = getName.call(request.result); + request.result.createObjectStore('records'); + }; + const db = await result(request); + retained.push(db); + here('upgrade name ' + i, upgradeName === names[i], upgradeName === undefined ? '' : units(upgradeName)); + here('independent creation ' + i, oldVersion === 0, oldVersion); + here('open name ' + i, db.name === names[i], units(db.name)); + const tx = db.transaction('records', 'readwrite'); + const done = complete(tx); + tx.objectStore('records').put(i, 'same-key'); + await done; + db.close(); + here('closed name ' + i, getName.call(db) === names[i], units(getName.call(db))); + } + let listed = (await list()).filter(db => db.name.startsWith(stem)); + here('listing keeps every database', listed.length === names.length, listed.length); + for (let i = 0; i < names.length; ++i) { + here('listing units ' + i, listed.some(db => db.name === names[i] && db.version === 1)); + const db = await result(open(new Realm.String(names[i]))); + const tx = db.transaction('records'); + const done = complete(tx); + const read = tx.objectStore('records').get('same-key'); + await done; + here('independent records ' + i, read.result === i, read.result); + db.close(); + } + + const order = []; + const marker = new Error('name conversion'); + let caught; + try { + open({toString() { order.push('name'); throw marker; }}, {valueOf() { order.push('version'); return 1; }}); + } catch (error) { caught = error; } + here('name exception preserves identity and prevents version conversion', caught === marker && order.join() === 'name', order); + for (const [method, callback] of [['open', open], ['deleteDatabase', remove]]) { + let error; + try { callback(Symbol('name')); } catch (caught) { error = caught; } + here(method + ' rejects Symbol in callee realm', error !== undefined && Object.getPrototypeOf(error) === MethodRealm.TypeError.prototype, error?.name); + } + + const upgrading = open(names[2], 2); + let upgradeOld; + upgrading.onupgradeneeded = event => { + upgradeOld = event.oldVersion; + upgrading.transaction.objectStore('records').put('upgraded', 'same-key'); + }; + const upgraded = await result(upgrading); + here('upgrade uses exact database', upgradeOld === 1 && upgraded.name === names[2], upgradeOld); + upgraded.close(); + const aborted = open(names[3], 3); + aborted.onupgradeneeded = () => { + aborted.transaction.objectStore('records').put('aborted', 'same-key'); + aborted.transaction.abort(); + }; + let abortError; + try { (await result(aborted)).close(); } catch (error) { abortError = error; } + here('aborted upgrade reports AbortError', abortError?.name === 'AbortError', abortError?.name); + listed = (await list()).filter(db => db.name.startsWith(stem)); + for (let i = 0; i < names.length; ++i) { + here('versions remain independent ' + i, listed.some(db => db.name === names[i] && db.version === (i === 2 ? 2 : 1))); + } + const rollback = await result(open(names[3])); + const readTx = rollback.transaction('records'); + const readDone = complete(readTx); + const rolledBack = readTx.objectStore('records').get('same-key'); + await readDone; + here('aborted data remains independent', rolledBack.result === 3, rolledBack.result); + rollback.close(); + + // Keep a different surrogate name open. A mistaken identity match still + // completes via onblocked so every engine reports the same assertions. + const held = await result(open(names[3])); + let notifications = 0; + held.onversionchange = () => { ++notifications; }; + const deleting = remove({toString() { return names[2]; }}); + let blocked = false; + deleting.onblocked = () => { blocked = true; held.close(); }; + await result(deleting); + held.close(); + here('delete does not notify another database', notifications === 0, notifications); + here('delete is not blocked by another database', !blocked, blocked); + listed = (await list()).filter(db => db.name.startsWith(stem)); + here('delete removes only the exact database', !listed.some(db => db.name === names[2]) + && listed.length === names.length - 1, listed.length); + here('retained connection name survives deletion', getName.call(retained[2]) === names[2], units(getName.call(retained[2]))); + for (const name of names) await result(remove(name)); + here('cleanup removes all test databases', !(await list()).some(db => db.name.startsWith(stem))); + }; + + await exercise('own', globalThis, globalThis); + if (typeof document !== 'undefined') { + const frame = document.createElement('iframe'); + const loaded = new Promise(resolve => { frame.onload = resolve; }); + frame.src = 'about:blank'; + document.documentElement.appendChild(frame); + await loaded; + try { + await exercise('foreign-method', globalThis, frame.contentWindow); + await exercise('foreign-factory', frame.contentWindow, globalThis); + } finally { frame.remove(); } + + const firstName = prefix + '-shared-\ud800'; + const secondName = prefix + '-shared-\udfff'; + for (const name of [firstName, secondName]) { + const request = indexedDB.open(name, 1); + request.onupgradeneeded = () => request.result.createObjectStore('records'); + (await result(request)).close(); + } + const held = await result(indexedDB.open(firstName)); + let notices = 0; + held.onversionchange = () => { ++notices; held.close(); }; + const source = `onmessage = async event => { + const request = indexedDB.open(event.data, 2); + let oldVersion; + request.onupgradeneeded = event => { oldVersion = event.oldVersion; }; + request.onerror = () => postMessage({error: request.error.name}); + request.onsuccess = () => { + const db = request.result; + const name = db.name; + db.close(); + postMessage({name, oldVersion}); + }; + };`; + const url = URL.createObjectURL(new Blob([source], {type: 'text/javascript'})); + const worker = new Worker(url); + try { + const reply = new Promise((resolve, reject) => { + worker.onmessage = event => resolve(event.data); + worker.onerror = event => reject(new Error(event.message)); + }); + worker.postMessage(secondName); + const observed = await reply; + check('worker upgrade preserves database name', observed.name === secondName && observed.oldVersion === 1, JSON.stringify(observed)); + check('worker upgrade does not notify a different Window connection', notices === 0, notices); + held.close(); + const listed = await indexedDB.databases(); + check('Window sees independent versions after worker upgrade', listed.some(db => db.name === firstName && db.version === 1) + && listed.some(db => db.name === secondName && db.version === 2)); + } finally { + held.close(); + worker.terminate(); + URL.revokeObjectURL(url); + await result(indexedDB.deleteDatabase(firstName)); + await result(indexedDB.deleteDatabase(secondName)); + } + } + return {state: checks.every(check => check.pass) ? 'pass' : 'fail', checks}; +} diff --git a/moli-core/tests/web_apis/indexed_db_transaction.rs b/moli-core/tests/web_apis/indexed_db_transaction.rs index 223a1febbd..b15b50c2d2 100644 --- a/moli-core/tests/web_apis/indexed_db_transaction.rs +++ b/moli-core/tests/web_apis/indexed_db_transaction.rs @@ -500,3 +500,25 @@ async fn indexed_db_cursors_observe_writes_without_changing_pending_values() -> server.shutdown().await; Ok(()) } + +#[tokio::test(flavor = "multi_thread")] +async fn indexed_db_database_names_preserve_utf16_identity_across_realms_and_queues() -> Result<()> +{ + let server = FixtureServer::spawn().await?; + let browser = Browser::new(AppConfig::default())?; + let fixture = include_str!("fixtures/indexeddb-database-names.js"); + for target in ["window", "child", "worker"] { + let source = format!( + "{fixture}\ndatabaseNamesProbe('database-names-{target}').then(finish, error => finish({{state: 'error', error: String(error), checks: databaseNameChecks}}));" + ); + let result = run_probe(&browser, &server, target, &source).await?; + assert_eq!(result["state"], "pass", "{target}: {result}"); + assert_eq!( + result["checks"].as_array().unwrap().len(), + if target == "worker" { 100 } else { 303 }, + "{target}: {result}" + ); + } + server.shutdown().await; + Ok(()) +} diff --git a/moli-indexeddb/src/connection_queue.rs b/moli-indexeddb/src/connection_queue.rs index 6d8c319fdc..18b34e2de0 100644 --- a/moli-indexeddb/src/connection_queue.rs +++ b/moli-indexeddb/src/connection_queue.rs @@ -18,7 +18,7 @@ pub struct ConnectionRequestQueues { struct QueueState { closed: bool, next_id: u64, - queues: BTreeMap<(String, String), VecDeque>, + queues: BTreeMap<(String, crate::IndexedDbName), VecDeque>, } struct QueuedRequest { @@ -31,7 +31,7 @@ struct QueuedRequest { #[derive(Clone)] pub struct ConnectionRequestHandle { queues: Weak, - key: (String, String), + key: (String, crate::IndexedDbName), id: u64, } @@ -65,6 +65,15 @@ impl ConnectionRequestQueues { storage_key: &str, name: &str, wake: ConnectionRequestWake, + ) -> Result { + self.try_enqueue_named(storage_key, &name.into(), wake) + } + + pub fn try_enqueue_named( + self: &Arc, + storage_key: &str, + name: &crate::IndexedDbName, + wake: ConnectionRequestWake, ) -> Result { let mut state = self.state.lock(); if state.closed { diff --git a/moli-indexeddb/src/sqlite/migrations/v2_metadata.sql b/moli-indexeddb/src/sqlite/migrations/v2_metadata.sql new file mode 100644 index 0000000000..9fc8bf8d6f --- /dev/null +++ b/moli-indexeddb/src/sqlite/migrations/v2_metadata.sql @@ -0,0 +1,19 @@ +CREATE TABLE database_metadata ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + name BLOB NOT NULL CHECK (length(name) % 2 = 0), + generation BLOB NOT NULL CHECK (length(generation) = 16), + version INTEGER NOT NULL CHECK (version >= 0), + state TEXT NOT NULL CHECK (state IN ('creating', 'live')), + key_format INTEGER NOT NULL CHECK (key_format = 1), + key_path_format INTEGER NOT NULL CHECK (key_path_format = 1), + value_format INTEGER NOT NULL CHECK (value_format = 1), + usage INTEGER NOT NULL CHECK (usage >= 0), + next_store_id INTEGER NOT NULL DEFAULT 1 CHECK (next_store_id > 0), + next_index_id INTEGER NOT NULL DEFAULT 1 CHECK (next_index_id > 0), + next_record_id INTEGER NOT NULL DEFAULT 1 CHECK (next_record_id > 0), + next_object_id INTEGER NOT NULL DEFAULT 1 CHECK (next_object_id > 0), + CHECK ( + (state = 'creating' AND version = 0) + OR (state = 'live' AND version > 0) + ) +) STRICT, WITHOUT ROWID; diff --git a/moli-indexeddb/src/sqlite/mod.rs b/moli-indexeddb/src/sqlite/mod.rs index 8853e56b7b..b622d1ab49 100644 --- a/moli-indexeddb/src/sqlite/mod.rs +++ b/moli-indexeddb/src/sqlite/mod.rs @@ -5,6 +5,7 @@ //! worker connection under object-store scope admission. No overlapping writer //! can modify a reader's stores. Records and rollback state stay in SQLite. +mod names; mod query; mod records; mod schema; @@ -27,7 +28,7 @@ use crate::{ pub use query::{Query, QuerySource, Seek}; pub use records::RecordWrite; -pub const SCHEMA_VERSION: u32 = 1; +pub const SCHEMA_VERSION: u32 = 2; pub const CHUNK_BYTES: usize = 256 * 1024; const REQUEST_SAVEPOINT: &str = "SAVEPOINT idb_request"; @@ -60,7 +61,7 @@ impl QuotaCheck { #[derive(Debug, Clone, PartialEq, Eq)] pub struct DatabaseIdentity { pub owner_key: Vec, - pub name: String, + pub name: crate::IndexedDbName, pub generation: [u8; 16], } @@ -81,7 +82,7 @@ pub type StoreDefinitions = BTreeMap; #[derive(Debug)] pub struct DatabaseMetadata { - pub name: String, + pub name: crate::IndexedDbName, pub version: u64, pub usage: u64, pub stores: Arc, @@ -169,7 +170,7 @@ impl Database { )?; transaction.execute( "INSERT INTO database_metadata(singleton, name, generation, version, state, key_format, key_path_format, value_format, usage) VALUES(1, ?1, ?2, 0, 'creating', 1, 1, 1, ?3)", - rusqlite::params![identity.name, identity.generation.as_slice(), sql_integer(identity.name.len() as u64 + 8)?], + rusqlite::params![names::encode(&identity.name)?, identity.generation.as_slice(), sql_integer(identity.name.usage_bytes() + 8)?], )?; transaction.commit()?; } @@ -185,25 +186,35 @@ impl Database { } fn open_shared( - connection: Connection, + mut connection: Connection, identity: Arc, ) -> Result { - moli_sqlite::require_schema_version(&connection, SCHEMA_VERSION) - .map_err(|error| corruption(&error.to_string()))?; + let version = names::schema_version(&connection)?; let saved: Option = connection.query_row( "SELECT owner_key=?1 FROM database_info WHERE singleton=1 AND backend='indexeddb' AND identity_format=1", [&identity.owner_key], |row| row.get(0), ).optional()?; if saved != Some(true) { return Err(corruption("IndexedDB owner identity mismatch")); } - let generation: bool = connection.query_row( - "SELECT generation=?1 FROM database_metadata WHERE singleton=1 AND name=?2", - rusqlite::params![identity.generation.as_slice(), identity.name], - |row| row.get(0), - )?; - if !generation { + let mut statement = connection + .prepare("SELECT name, generation FROM database_metadata WHERE singleton=1")?; + let mut rows = statement.query([])?; + let row = rows + .next()? + .ok_or_else(|| corruption("missing IndexedDB identity"))?; + let name = names::read_name(row, 0, version)?; + let generation = row + .get_ref(1)? + .as_blob() + .map_err(|_| corruption("invalid IndexedDB generation"))?; + if name != identity.name || generation != identity.generation.as_slice() { return Err(corruption("IndexedDB generation mismatch")); } + drop(rows); + drop(statement); + if version == 1 { + names::migrate_legacy(&mut connection, &identity.name)?; + } let committed = schema::read_metadata(&connection)?; Ok(Self { connection, @@ -347,7 +358,7 @@ impl Database { } let usage: Option = self.connection.query_row( "SELECT usage FROM database_metadata WHERE singleton=1 AND name=?1 AND version=?2 AND state='live' AND key_format=1 AND key_path_format=1 AND value_format=1", - rusqlite::params![self.committed.name, sql_integer(self.committed.version)?], + rusqlite::params![names::encode(&self.committed.name)?, sql_integer(self.committed.version)?], |row| row.get(0), ).optional()?; let usage = usage diff --git a/moli-indexeddb/src/sqlite/names.rs b/moli-indexeddb/src/sqlite/names.rs new file mode 100644 index 0000000000..47937e0b50 --- /dev/null +++ b/moli-indexeddb/src/sqlite/names.rs @@ -0,0 +1,93 @@ +//! Database names use UTF-16BE BLOBs in schema v2. Store/index names retain +//! their schema-v1 representation until their independent name migrations. +use super::*; +use crate::IndexedDbName; + +pub(super) fn schema_version(connection: &Connection) -> Result { + let version: i64 = connection.pragma_query_value(None, "user_version", |row| row.get(0))?; + match version { + 1 | 2 => Ok(version as u32), + _ => Err(corruption("unsupported IndexedDB SQL schema version")), + } +} + +pub(super) fn encode(name: &IndexedDbName) -> Result, IndexedDbError> { + let len = name + .as_utf16() + .len() + .checked_mul(2) + .ok_or_else(|| IndexedDbError::Resource("IndexedDB name size overflow".into()))?; + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(len) + .map_err(|_| IndexedDbError::Resource("Unable to allocate IndexedDB name".into()))?; + for unit in name.as_utf16() { + bytes.extend_from_slice(&unit.to_be_bytes()); + } + Ok(bytes) +} + +pub(super) fn decode(bytes: &[u8]) -> Result { + if !bytes.len().is_multiple_of(2) { + return Err(corruption("invalid UTF-16 database name")); + } + let mut units = Vec::new(); + units + .try_reserve_exact(bytes.len() / 2) + .map_err(|_| IndexedDbError::Resource("Unable to allocate IndexedDB name".into()))?; + for pair in bytes.chunks_exact(2) { + units.push(u16::from_be_bytes([pair[0], pair[1]])); + } + Ok(IndexedDbName::from_utf16(units)) +} + +pub(super) fn read_name( + row: &rusqlite::Row<'_>, + column: usize, + version: u32, +) -> Result { + match (version, row.get_ref(column)?) { + (1, rusqlite::types::ValueRef::Text(bytes)) => { + let text = std::str::from_utf8(bytes) + .map_err(|_| corruption("invalid legacy database name"))?; + let mut units = Vec::new(); + units.try_reserve_exact(text.len()).map_err(|_| { + IndexedDbError::Resource("Unable to allocate IndexedDB name".into()) + })?; + units.extend(text.encode_utf16()); + Ok(IndexedDbName::from_utf16(units)) + } + (2, rusqlite::types::ValueRef::Blob(bytes)) => decode(bytes), + _ => Err(corruption("invalid IndexedDB database name column")), + } +} + +pub(super) fn copy(name: &IndexedDbName) -> Result { + let mut units = Vec::new(); + units + .try_reserve_exact(name.as_utf16().len()) + .map_err(|_| IndexedDbError::Resource("Unable to allocate IndexedDB name".into()))?; + units.extend_from_slice(name.as_utf16()); + Ok(IndexedDbName::from_utf16(units)) +} + +/// Called only after the legacy owner/name/generation have been validated. +/// Table replacement, name conversion and format publication share one SQL +/// transaction. Records and all stable ids are left in their existing tables. +pub(super) fn migrate_legacy( + connection: &mut Connection, + name: &IndexedDbName, +) -> Result<(), IndexedDbError> { + let name = encode(name)?; + let transaction = + connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + transaction.execute_batch("ALTER TABLE database_metadata RENAME TO database_metadata_v1")?; + transaction.execute_batch(include_str!("migrations/v2_metadata.sql"))?; + transaction.execute( + "INSERT INTO database_metadata SELECT singleton, ?1, generation, version, state, key_format, key_path_format, value_format, usage, next_store_id, next_index_id, next_record_id, next_object_id FROM database_metadata_v1", + [name], + )?; + transaction.execute_batch("DROP TABLE database_metadata_v1; PRAGMA user_version=2")?; + transaction.commit()?; + Ok(()) +} diff --git a/moli-indexeddb/src/sqlite/schema.rs b/moli-indexeddb/src/sqlite/schema.rs index 0889af0c0f..9e21b1cce4 100644 --- a/moli-indexeddb/src/sqlite/schema.rs +++ b/moli-indexeddb/src/sqlite/schema.rs @@ -19,7 +19,7 @@ pub(super) fn read_metadata( if version < 0 || usage < 0 || !matches!((state, version), ("creating", 0) | ("live", 1..)) { return Err(corruption("invalid IndexedDB database metadata")); } - let name = owned_text(text(row, 0)?)?; + let name = names::read_name(row, 0, SCHEMA_VERSION)?; drop(rows); drop(statement); let mut stores = BTreeMap::new(); @@ -94,7 +94,7 @@ pub(super) fn read_metadata( impl DatabaseMetadata { pub(super) fn with_usage(&self, usage: u64) -> Result, IndexedDbError> { - let name = owned_text(&self.name)?; + let name = names::copy(&self.name)?; Ok(Arc::new(Self { name, usage, diff --git a/moli-indexeddb/src/sqlite/schema.sql b/moli-indexeddb/src/sqlite/schema.sql index f409285e25..46c413a072 100644 --- a/moli-indexeddb/src/sqlite/schema.sql +++ b/moli-indexeddb/src/sqlite/schema.sql @@ -1,4 +1,4 @@ -PRAGMA user_version = 1; +PRAGMA user_version = 2; CREATE TABLE database_info ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), @@ -9,7 +9,7 @@ CREATE TABLE database_info ( CREATE TABLE database_metadata ( singleton INTEGER PRIMARY KEY CHECK (singleton = 1), - name TEXT NOT NULL, + name BLOB NOT NULL CHECK (length(name) % 2 = 0), generation BLOB NOT NULL CHECK (length(generation) = 16), version INTEGER NOT NULL CHECK (version >= 0), state TEXT NOT NULL CHECK (state IN ('creating', 'live')), diff --git a/moli-indexeddb/src/sqlite/service.rs b/moli-indexeddb/src/sqlite/service.rs index 0878492ed6..b7f4c71699 100644 --- a/moli-indexeddb/src/sqlite/service.rs +++ b/moli-indexeddb/src/sqlite/service.rs @@ -200,7 +200,7 @@ struct Lane { struct Registry { closed: bool, next_id: u64, - entries: BTreeMap<(Vec, String), DatabaseClient>, + entries: BTreeMap<(Vec, crate::IndexedDbName), DatabaseClient>, clearing: BTreeMap, } @@ -304,7 +304,7 @@ impl ServiceClient { fn register_client( &self, registry: &mut Registry, - key: (Vec, String), + key: (Vec, crate::IndexedDbName), generation: [u8; 16], location: Location, catalog: Option>, diff --git a/moli-indexeddb/src/sqlite/service/catalog.rs b/moli-indexeddb/src/sqlite/service/catalog.rs index 21b424360d..468d6dbb82 100644 --- a/moli-indexeddb/src/sqlite/service/catalog.rs +++ b/moli-indexeddb/src/sqlite/service/catalog.rs @@ -80,6 +80,15 @@ impl ServiceClient { owner_key: Vec, name: String, completion: impl FnOnce(Completion) + Send + 'static, + ) -> Result { + self.resolve_named(owner_key, name.into(), completion) + } + + pub fn resolve_named( + &self, + owner_key: Vec, + name: crate::IndexedDbName, + completion: impl FnOnce(Completion) + Send + 'static, ) -> Result { if owner_key.is_empty() { return Err(DispatchError::Storage(IndexedDbError::Data( @@ -166,9 +175,7 @@ impl ServiceClient { }; databases.push(metadata); } - databases.sort_unstable_by(|left, right| { - left.name.encode_utf16().cmp(right.name.encode_utf16()) - }); + databases.sort_unstable_by(|left, right| left.name.cmp(&right.name)); Ok(databases) } @@ -176,6 +183,14 @@ impl ServiceClient { &self, owner_key: &[u8], name: &str, + ) -> Result, IndexedDbError> { + self.database_version_named(owner_key, &name.into()) + } + + pub fn database_version_named( + &self, + owner_key: &[u8], + name: &crate::IndexedDbName, ) -> Result, IndexedDbError> { let registry = self.registry.lock(); if registry.closed { @@ -335,8 +350,7 @@ fn inspect_header( return Ok(None); } } - moli_sqlite::require_schema_version(&connection, SCHEMA_VERSION) - .map_err(|error| corruption(&error.to_string()))?; + let version = names::schema_version(&connection)?; let mut statement = connection.prepare("SELECT i.owner_key, m.name, m.generation FROM database_info i JOIN database_metadata m ON m.singleton=i.singleton WHERE i.singleton=1 AND i.backend='indexeddb' AND i.identity_format=1")?; let mut rows = statement.query([])?; let row = rows @@ -346,10 +360,7 @@ fn inspect_header( .get_ref(0)? .as_blob() .map_err(|_| corruption("invalid IndexedDB owner"))?; - let name = row - .get_ref(1)? - .as_str() - .map_err(|_| corruption("invalid IndexedDB name"))?; + let name = names::read_name(row, 1, version)?; let generation: [u8; 16] = row .get_ref(2)? .as_blob() @@ -358,7 +369,7 @@ fn inspect_header( .map_err(|_| corruption("invalid IndexedDB generation length"))?; let identity = DatabaseIdentity { owner_key: owner.to_vec(), - name: name.to_owned(), + name, generation, }; drop(rows); diff --git a/moli-indexeddb/src/sqlite/service/catalog/layout.rs b/moli-indexeddb/src/sqlite/service/catalog/layout.rs index cef2218720..9437891eef 100644 --- a/moli-indexeddb/src/sqlite/service/catalog/layout.rs +++ b/moli-indexeddb/src/sqlite/service/catalog/layout.rs @@ -112,11 +112,16 @@ impl DiskCatalog { }) } - pub(super) fn path(&self, owner: &[u8], name: &str, generation: [u8; 16]) -> PathBuf { + pub(super) fn path( + &self, + owner: &[u8], + name: &crate::IndexedDbName, + generation: [u8; 16], + ) -> PathBuf { self.root .join("live") .join(hash(1, owner)) - .join(hash(2, name.as_bytes())) + .join(hash_database_name(name)) .join(hex(&generation)) .join("db.sqlite") } @@ -451,3 +456,62 @@ pub(super) fn hex(bytes: &[u8]) -> String { pub(super) fn io_error(action: &str, path: &Path, error: std::io::Error) -> IndexedDbError { IndexedDbError::Io(format!("IndexedDB {action} `{}`: {error}", path.display())) } + +/// Keep every schema-v1 Unicode name in its original generation directory. +/// Canonical WTF-8 extends the same hash domain to unpaired surrogates without +/// projecting distinct DOMStrings onto the same replacement character. +fn hash_database_name(name: &crate::IndexedDbName) -> String { + let mut hash = moli_crypto::Sha256Context::new(); + hash.update(b"moli-indexeddb-path-v1\0"); + hash.update([2]); + hash.update(name.usage_bytes().to_be_bytes()); + for character in char::decode_utf16(name.as_utf16().iter().copied()) { + match character { + Ok(character) => { + let mut bytes = [0; 4]; + hash.update(character.encode_utf8(&mut bytes).as_bytes()); + } + Err(error) => { + let surrogate = error.unpaired_surrogate(); + hash.update([ + 0xe0 | (surrogate >> 12) as u8, + 0x80 | ((surrogate >> 6) & 0x3f) as u8, + 0x80 | (surrogate & 0x3f) as u8, + ]); + } + } + } + hex(&hash.finish()) +} + +#[cfg(test)] +mod database_name_tests { + use super::*; + + #[test] + fn database_name_paths_keep_legacy_unicode_hashes_and_distinguish_surrogates() { + for text in [ + "", + "\0", + "ordinary", + "\u{1f4be}", + "\u{e000}", + "legacy-\0-数据库-\u{1f4be}", + ] { + assert_eq!(hash_database_name(&text.into()), hash(2, text.as_bytes())); + } + let names = [ + vec![0xd800], + vec![0xd801], + vec![0xfffd], + vec![0x64, 0x38, 0x30, 0x30], + vec![0xdfff, 0xd800], + vec![0xfffd, 0xfffd], + ]; + let hashes = names + .into_iter() + .map(|units| hash_database_name(&crate::IndexedDbName::from_utf16(units))) + .collect::>(); + assert_eq!(hashes.len(), 6); + } +} diff --git a/moli-indexeddb/src/sqlite/service/connections.rs b/moli-indexeddb/src/sqlite/service/connections.rs index a6ab6282ca..490d0fa180 100644 --- a/moli-indexeddb/src/sqlite/service/connections.rs +++ b/moli-indexeddb/src/sqlite/service/connections.rs @@ -109,6 +109,15 @@ impl ServiceClient { owner_key: &[u8], name: &str, wake: crate::ConnectionRequestWake, + ) -> Result { + self.queue_connection_request_named(owner_key, &name.into(), wake) + } + + pub fn queue_connection_request_named( + &self, + owner_key: &[u8], + name: &crate::IndexedDbName, + wake: crate::ConnectionRequestWake, ) -> Result { let registry = self.registry.lock(); if registry.closed { @@ -116,7 +125,8 @@ impl ServiceClient { } registry.check_owner(owner_key)?; let owner = request_owner_key(owner_key)?; - self.connection_requests.try_enqueue(&owner, name, wake) + self.connection_requests + .try_enqueue_named(&owner, name, wake) } } diff --git a/moli-indexeddb/src/sqlite/service/tests.rs b/moli-indexeddb/src/sqlite/service/tests.rs index 3e0437dd37..53e6f256e1 100644 --- a/moli-indexeddb/src/sqlite/service/tests.rs +++ b/moli-indexeddb/src/sqlite/service/tests.rs @@ -22,7 +22,7 @@ fn config() -> ServiceConfig { } } -fn identity(name: &str) -> DatabaseIdentity { +fn identity(name: impl Into) -> DatabaseIdentity { DatabaseIdentity { owner_key: b"partition/default/storage-key/top-site/bucket-17".to_vec(), name: name.into(), @@ -36,7 +36,11 @@ fn receive(receiver: &mpsc::Receiver) -> Completion { .expect("SQL worker must complete without waiting for the JS owner") } -fn register(service: &Service, name: &str, location: Location) -> (DatabaseClient, Completion) { +fn register( + service: &Service, + name: impl Into, + location: Location, +) -> (DatabaseClient, Completion) { let (sender, receiver) = mpsc::channel(); let client = service .open(identity(name), location, move |reply| { @@ -46,7 +50,11 @@ fn register(service: &Service, name: &str, location: Location) -> (DatabaseClien (client, receive(&receiver)) } -fn open(service: &Service, name: &str, location: Location) -> DatabaseClient { +fn open( + service: &Service, + name: impl Into, + location: Location, +) -> DatabaseClient { let (client, reply) = register(service, name, location); assert!(matches!(reply.result.unwrap(), CommandResult::Metadata(_))); client @@ -56,7 +64,7 @@ fn open(service: &Service, name: &str, location: Location) -> DatabaseClient { fn registration_accepts_more_than_1024_databases_and_reuses_each_owner() { let service = Service::new(config()).unwrap(); let clients = (0..1030) - .map(|id| open(&service, &format!("registration-{id}"), Location::Memory)) + .map(|id| open(&service, format!("registration-{id}"), Location::Memory)) .collect::>(); assert_eq!(service.registry.lock().entries.len(), clients.len()); let first = open(&service, "registration-0", Location::Memory); @@ -567,7 +575,12 @@ fn private_memory_databases_survive_more_than_eight_connections_on_one_worker() assert_eq!(records.len(), 1); assert_eq!( records[0].value, - Some(client.identity().name.as_bytes().to_vec().into()) + Some( + String::from_utf16(client.identity().name.as_utf16()) + .unwrap() + .into_bytes() + .into() + ) ); execute( &reopened, @@ -662,7 +675,12 @@ fn active_disk_transactions_allow_more_than_eight_databases_on_one_worker() { if let Some(record) = records.first() { assert_eq!( record.value, - Some(client.identity().name.as_bytes().to_vec().into()) + Some( + String::from_utf16(client.identity().name.as_utf16()) + .unwrap() + .into_bytes() + .into() + ) ); } execute( diff --git a/moli-indexeddb/src/sqlite/service/tests/catalog_tests.rs b/moli-indexeddb/src/sqlite/service/tests/catalog_tests.rs index afdb7b8d94..1516111813 100644 --- a/moli-indexeddb/src/sqlite/service/tests/catalog_tests.rs +++ b/moli-indexeddb/src/sqlite/service/tests/catalog_tests.rs @@ -156,7 +156,7 @@ fn catalog_lists_committed_metadata_and_snapshots_survive_upgrade_and_shutdown() assert_eq!( snapshot .iter() - .map(|metadata| metadata.name.as_str()) + .map(|metadata| String::from_utf16(metadata.name.as_utf16()).unwrap()) .collect::>(), ["\u{10000}", "\u{e000}"] ); @@ -505,7 +505,9 @@ fn catalog_refuses_a_second_owner_and_unsupported_sql_schema_without_erasing_dat let file = path(&client); let connection = moli_sqlite::open_disk(&file, OpenMode::Existing, config().connection).unwrap(); - connection.pragma_update(None, "user_version", 2).unwrap(); + connection + .pragma_update(None, "user_version", SCHEMA_VERSION + 1) + .unwrap(); connection.close().unwrap(); assert!(Service::on_disk(root.clone(), config()).is_err()); let connection = @@ -514,9 +516,11 @@ fn catalog_refuses_a_second_owner_and_unsupported_sql_schema_without_erasing_dat connection .pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) .unwrap(), - 2 + i64::from(SCHEMA_VERSION + 1) ); - connection.pragma_update(None, "user_version", 1).unwrap(); + connection + .pragma_update(None, "user_version", SCHEMA_VERSION) + .unwrap(); connection.close().unwrap(); let recovered = Service::on_disk(root, config()).unwrap(); verify_record(&resolve(&recovered, OWNER, "protected")); @@ -633,3 +637,269 @@ fn catalog_root_initialization_is_retryable_but_never_fills_holes_in_published_o b"legacy or foreign" ); } + +fn resolve_dom_name(service: &Service, name: crate::IndexedDbName) -> DatabaseClient { + let (sender, receiver) = mpsc::channel(); + let client = service + .resolve_named(OWNER.to_vec(), name, move |reply| { + let _ = sender.send(reply); + }) + .unwrap(); + receive(&receiver).result.unwrap(); + client +} + +#[test] +fn catalog_utf16_database_names_survive_recovery_without_path_or_identity_collisions() { + let directory = Directory::new(); + let root = directory.0.join("idb-lite"); + let mut names = [ + vec![], + vec![0], + vec![0xd800], + vec![0xd801], + vec![0xfffd], + vec![0xd800, 0xdc00], + vec![0xdc00, 0xd800], + vec![0xd800, 0], + ] + .into_iter() + .map(crate::IndexedDbName::from_utf16) + .collect::>(); + names.sort(); + let mut first_paths = None; + for write in [true, false] { + let mut service = Service::on_disk(root.clone(), config()).unwrap(); + let mut paths = Vec::new(); + for name in &names { + let client = resolve_dom_name(&service, name.clone()); + if write { + seed(&client); + } + verify_record(&client); + assert_eq!(&client.identity().name, name); + assert_eq!( + service.database_version_named(OWNER, name).unwrap(), + Some(1) + ); + paths.push(path(&client)); + } + assert_eq!( + service + .databases(OWNER) + .unwrap() + .into_iter() + .map(|metadata| metadata.name.clone()) + .collect::>(), + names + ); + assert_eq!( + paths + .iter() + .collect::>() + .len(), + names.len() + ); + if let Some(first) = &first_paths { + assert_eq!(&paths, first); + } else { + first_paths = Some(paths); + } + service.shutdown().unwrap(); + } +} + +#[test] +fn utf16_connection_requests_share_only_the_exact_code_unit_name() { + let service = Service::new(config()).unwrap(); + let surrogate = crate::IndexedDbName::from_utf16(vec![0xd800]); + let replacement = crate::IndexedDbName::from("\u{fffd}"); + let first = service + .queue_connection_request_named(OWNER, &surrogate, Arc::new(|| {})) + .unwrap(); + let second = service + .queue_connection_request_named(OWNER, &surrogate, Arc::new(|| {})) + .unwrap(); + let independent = service + .queue_connection_request_named(OWNER, &replacement, Arc::new(|| {})) + .unwrap(); + assert!(first.handle().is_head()); + assert!(!second.handle().is_head()); + assert!(independent.handle().is_head()); + drop(first); + assert!(second.handle().is_head()); + assert!(independent.handle().is_head()); +} + +#[test] +fn utf16_database_names_keep_transaction_admission_separate() { + let service = Service::new(config()).unwrap(); + let clients = [0xd800, 0xdfff, 0xfffd].map(|unit| { + let client = resolve_dom_name(&service, crate::IndexedDbName::from_utf16(vec![unit])); + initialize(&client); + client + }); + let leases = clients + .each_ref() + .map(|client| queue(client, "a", TransactionMode::ReadWrite)); + let same = queue(&clients[0], "a", TransactionMode::ReadWrite); + assert!(leases.iter().all(|lease| lease.handle().is_ready())); + assert!(!same.handle().is_ready()); + for (index, (client, lease)) in clients.iter().zip(leases).enumerate() { + start(client, "a", TransactionMode::ReadWrite, &lease); + write(client, &[index as u8]); + execute( + client, + Command::Commit { + transaction: TX, + quota: None, + }, + ) + .unwrap(); + } + assert!(same.handle().is_ready()); + start(&clients[0], "a", TransactionMode::ReadWrite, &same); + assert_eq!(query(&clients[0])[0].value, Some(vec![0].into())); + execute( + &clients[0], + Command::Commit { + transaction: TX, + quota: None, + }, + ) + .unwrap(); +} + +#[test] +fn utf16_database_name_quota_rollback_preserves_other_database_versions_and_usage() { + let service = Service::new(config()).unwrap(); + let clients = [0xd800, 0xdfff, 0xfffd].map(|unit| { + let client = resolve_dom_name(&service, crate::IndexedDbName::from_utf16(vec![unit])); + execute( + &client, + Command::Begin { + transaction: TX, + mode: TransactionMode::VersionChange, + stores: BTreeSet::new(), + version: Some(1), + admission: None, + }, + ) + .unwrap(); + execute( + &client, + Command::Commit { + transaction: TX, + quota: None, + }, + ) + .unwrap(); + client + }); + assert_eq!(service.owner_usage_bytes(OWNER).unwrap(), 33); + let client = &clients[0]; + execute( + client, + Command::Begin { + transaction: TX, + mode: TransactionMode::VersionChange, + stores: BTreeSet::new(), + version: Some(2), + admission: None, + }, + ) + .unwrap(); + execute( + client, + Command::CreateStore { + transaction: TX, + name: "new".into(), + options: Default::default(), + }, + ) + .unwrap(); + assert!(matches!( + execute( + client, + Command::Commit { + transaction: TX, + quota: Some(QuotaCheck { + quota: 33, + other_usage: 22 + }) + } + ), + Err(IndexedDbError::QuotaExceeded { .. }) + )); + assert_eq!(service.owner_usage_bytes(OWNER).unwrap(), 33); + assert!( + service + .databases(OWNER) + .unwrap() + .iter() + .all(|metadata| metadata.version == 1) + ); +} + +#[test] +fn catalog_recovers_legacy_text_names_in_the_existing_generation_directory() { + let directory = Directory::new(); + let root = directory.0.join("idb-lite"); + let legacy_name = "legacy-\0-数据库-\u{1f4be}"; + let mut service = Service::on_disk(root.clone(), config()).unwrap(); + let client = resolve(&service, OWNER, legacy_name); + seed(&client); + let file = path(&client); + let generation = client.identity().generation; + service.shutdown().unwrap(); + // Recreate the exact v1 metadata representation while keeping this actual + // generation's records, store ids and allocator counters untouched. + let mut connection = Connection::open(&file).unwrap(); + let transaction = connection.transaction().unwrap(); + transaction + .execute_batch("ALTER TABLE database_metadata RENAME TO database_metadata_v2") + .unwrap(); + let schema = include_str!("../../tests/fixtures/schema-v1.sql"); + let metadata = schema + .split_once("CREATE TABLE database_metadata") + .unwrap() + .1 + .split_once("CREATE TABLE object_stores") + .unwrap() + .0; + transaction + .execute_batch(&format!("CREATE TABLE database_metadata{metadata}")) + .unwrap(); + transaction.execute("INSERT INTO database_metadata SELECT singleton,?1,generation,version,state,key_format,key_path_format,value_format,usage,next_store_id,next_index_id,next_record_id,next_object_id FROM database_metadata_v2",[legacy_name]).unwrap(); + transaction + .execute_batch("DROP TABLE database_metadata_v2; PRAGMA user_version=1") + .unwrap(); + transaction.commit().unwrap(); + drop(connection); + let mut service = Service::on_disk(root, config()).unwrap(); + let client = resolve(&service, OWNER, legacy_name); + assert_eq!( + client.identity().name, + crate::IndexedDbName::from(legacy_name) + ); + assert_eq!(client.identity().generation, generation); + assert_eq!(path(&client), file); + verify_record(&client); + service.shutdown().unwrap(); + let connection = Connection::open(&file).unwrap(); + assert_eq!( + connection + .pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) + .unwrap(), + 2 + ); + assert_eq!( + connection + .query_row("SELECT typeof(name) FROM database_metadata", [], |row| { + row.get::<_, String>(0) + }) + .unwrap(), + "blob" + ); + drop(connection); +} diff --git a/moli-indexeddb/src/sqlite/service/tests/registration_tests.rs b/moli-indexeddb/src/sqlite/service/tests/registration_tests.rs index a674ef82d4..73fbb80961 100644 --- a/moli-indexeddb/src/sqlite/service/tests/registration_tests.rs +++ b/moli-indexeddb/src/sqlite/service/tests/registration_tests.rs @@ -32,7 +32,7 @@ fn registration_identity_is_released_independently_of_retained_schema() { assert_eq!(service.registry.lock().entries.len(), 1); drop(client); assert!(identity.upgrade().is_none()); - assert_eq!(held_snapshot.name, "old"); + assert_eq!(held_snapshot.name, crate::IndexedDbName::from("old")); drop(held_snapshot); drop(other); } diff --git a/moli-indexeddb/src/sqlite/tests.rs b/moli-indexeddb/src/sqlite/tests.rs index e4a16f4074..4baef4437c 100644 --- a/moli-indexeddb/src/sqlite/tests.rs +++ b/moli-indexeddb/src/sqlite/tests.rs @@ -1,3 +1,4 @@ +mod database_names; mod schema_rename; mod storage_contracts; use super::*; diff --git a/moli-indexeddb/src/sqlite/tests/database_names.rs b/moli-indexeddb/src/sqlite/tests/database_names.rs new file mode 100644 index 0000000000..cb19a5dfa8 --- /dev/null +++ b/moli-indexeddb/src/sqlite/tests/database_names.rs @@ -0,0 +1,231 @@ +use super::*; +use crate::IndexedDbName; + +struct Directory(std::path::PathBuf); +impl Directory { + fn new() -> Self { + let mut token = [0; 16]; + moli_crypto::fill_secure_random(&mut token).unwrap(); + let path = std::env::temp_dir().join(format!( + "moli-idb-name-migration-{}", + moli_crypto::sha256_hex(token) + )); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + fn path(&self) -> std::path::PathBuf { + self.0.join("database.sqlite") + } +} +impl Drop for Directory { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn legacy_database(directory: &Directory) -> DatabaseIdentity { + let mut identity = identity(); + identity.name = "legacy-\0-数据库-\u{1f4be}".into(); + let mut connection = moli_sqlite::open_disk( + &directory.path(), + moli_sqlite::OpenMode::Create, + Default::default(), + ) + .unwrap(); + let transaction = connection.transaction().unwrap(); + transaction + .execute_batch(include_str!("fixtures/schema-v1.sql")) + .unwrap(); + transaction + .execute( + "INSERT INTO database_info VALUES(1, 'indexeddb', ?1, 1)", + [&identity.owner_key], + ) + .unwrap(); + let options = ObjectStoreOptions { + auto_increment: true, + ..Default::default() + }; + let usage = identity.name.usage_bytes() + 8 + schema::store_usage("a", &options) + 12; + transaction.execute( + "INSERT INTO database_metadata(singleton,name,generation,version,state,key_format,key_path_format,value_format,usage,next_store_id,next_record_id) VALUES(1,?1,?2,1,'live',1,1,1,?3,4,8)", + rusqlite::params![String::from_utf16(identity.name.as_utf16()).unwrap(), identity.generation.as_slice(), usage as i64], + ).unwrap(); + transaction + .execute("INSERT INTO object_stores VALUES(3,'a',NULL,1,7)", []) + .unwrap(); + let key = Key::from(5); + transaction + .execute( + "INSERT INTO records VALUES(7,3,?1,?2,4,12)", + rusqlite::params![key.encode_ordered().unwrap(), key.encode_value().unwrap()], + ) + .unwrap(); + transaction + .execute( + "INSERT INTO record_chunks VALUES(7,0,?1)", + [b"keep".as_slice()], + ) + .unwrap(); + transaction.commit().unwrap(); + identity +} + +#[test] +fn database_name_codec_preserves_code_units_and_rejects_malformed_payloads() { + for units in [ + vec![], + vec![0], + vec![0xd800], + vec![0xd801], + vec![0xfffd], + vec![0xd800, 0xdc00], + vec![0xdc00, 0xd800], + vec![0xd800, 0], + ] { + let name = IndexedDbName::from_utf16(units.clone()); + let bytes = names::encode(&name).unwrap(); + assert_eq!(names::decode(&bytes).unwrap().as_utf16(), units); + let mut database = Database::create( + moli_sqlite::open_private_memory(Default::default()).unwrap(), + DatabaseIdentity { + name: name.clone(), + ..identity() + }, + ) + .unwrap(); + database + .begin(TX, TransactionMode::VersionChange, BTreeSet::new(), Some(1)) + .unwrap(); + database.commit(TX).unwrap(); + assert_eq!(database.committed().name, name); + let stored: Vec = database + .connection + .query_row("SELECT name FROM database_metadata", [], |row| row.get(0)) + .unwrap(); + assert_eq!(stored, bytes); + } + assert!(names::decode(&[0xd8]).is_err()); +} + +#[test] +fn legacy_name_migration_preserves_records_ids_generator_and_following_commits() { + let directory = Directory::new(); + let identity = legacy_database(&directory); + let connection = moli_sqlite::open_disk( + &directory.path(), + moli_sqlite::OpenMode::Existing, + Default::default(), + ) + .unwrap(); + let mut database = Database::open(connection, identity.clone()).unwrap(); + assert_eq!(database.committed().name, identity.name); + assert_eq!(names::schema_version(&database.connection).unwrap(), 2); + assert_eq!(database.committed().stores["a"].id, 3); + begin(&mut database, TransactionMode::ReadWrite); + assert_eq!( + database + .get(TX, "a", &5.into()) + .unwrap() + .unwrap() + .wire_bytes(), + b"keep" + ); + assert_eq!( + put(&mut database, None, b"new", vec![], None).unwrap(), + 8.into() + ); + database.commit(TX).unwrap(); + database.close().unwrap(); + let connection = moli_sqlite::open_disk( + &directory.path(), + moli_sqlite::OpenMode::Existing, + Default::default(), + ) + .unwrap(); + let mut database = Database::open(connection, identity).unwrap(); + begin(&mut database, TransactionMode::ReadOnly); + assert_eq!(database.count(TX, "a").unwrap(), 2); + assert_eq!( + database + .get(TX, "a", &8.into()) + .unwrap() + .unwrap() + .wire_bytes(), + b"new" + ); + database.commit(TX).unwrap(); +} + +#[test] +fn failed_name_migration_keeps_legacy_format_and_data_available_for_reopen() { + let directory = Directory::new(); + let identity = legacy_database(&directory); + let connection = + Connection::open_with_flags(directory.path(), rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .unwrap(); + assert!(Database::open(connection, identity.clone()).is_err()); + let connection = Connection::open(directory.path()).unwrap(); + assert_eq!(names::schema_version(&connection).unwrap(), 1); + assert_eq!( + connection + .query_row("SELECT typeof(name) FROM database_metadata", [], |row| { + row.get::<_, String>(0) + }) + .unwrap(), + "text" + ); + assert_eq!( + connection + .query_row( + "SELECT count(*) FROM sqlite_schema WHERE name='database_metadata_v1'", + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 0 + ); + assert_eq!( + connection + .query_row( + "SELECT bytes FROM record_chunks WHERE record_id=7", + [], + |row| row.get::<_, Vec>(0) + ) + .unwrap(), + b"keep" + ); + drop(connection); + let connection = moli_sqlite::open_disk( + &directory.path(), + moli_sqlite::OpenMode::Existing, + Default::default(), + ) + .unwrap(); + assert_eq!( + Database::open(connection, identity) + .unwrap() + .committed() + .version, + 1 + ); +} + +#[test] +fn foreign_legacy_identity_is_rejected_before_migration() { + let directory = Directory::new(); + let mut identity = legacy_database(&directory); + identity.owner_key.push(0); + let connection = Connection::open(directory.path()).unwrap(); + assert!(Database::open(connection, identity).is_err()); + let connection = Connection::open(directory.path()).unwrap(); + assert_eq!(names::schema_version(&connection).unwrap(), 1); + assert_eq!( + connection + .query_row("SELECT typeof(name) FROM database_metadata", [], |row| { + row.get::<_, String>(0) + }) + .unwrap(), + "text" + ); +} diff --git a/moli-indexeddb/src/sqlite/tests/fixtures/schema-v1.sql b/moli-indexeddb/src/sqlite/tests/fixtures/schema-v1.sql new file mode 100644 index 0000000000..f409285e25 --- /dev/null +++ b/moli-indexeddb/src/sqlite/tests/fixtures/schema-v1.sql @@ -0,0 +1,129 @@ +PRAGMA user_version = 1; + +CREATE TABLE database_info ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + backend TEXT NOT NULL CHECK (backend = 'indexeddb'), + owner_key BLOB NOT NULL CHECK (length(owner_key) > 0), + identity_format INTEGER NOT NULL CHECK (identity_format = 1) +) STRICT, WITHOUT ROWID; + +CREATE TABLE database_metadata ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + name TEXT NOT NULL, + generation BLOB NOT NULL CHECK (length(generation) = 16), + version INTEGER NOT NULL CHECK (version >= 0), + state TEXT NOT NULL CHECK (state IN ('creating', 'live')), + key_format INTEGER NOT NULL CHECK (key_format = 1), + key_path_format INTEGER NOT NULL CHECK (key_path_format = 1), + value_format INTEGER NOT NULL CHECK (value_format = 1), + usage INTEGER NOT NULL CHECK (usage >= 0), + next_store_id INTEGER NOT NULL DEFAULT 1 CHECK (next_store_id > 0), + next_index_id INTEGER NOT NULL DEFAULT 1 CHECK (next_index_id > 0), + next_record_id INTEGER NOT NULL DEFAULT 1 CHECK (next_record_id > 0), + next_object_id INTEGER NOT NULL DEFAULT 1 CHECK (next_object_id > 0), + CHECK ( + (state = 'creating' AND version = 0) + OR (state = 'live' AND version > 0) + ) +) STRICT, WITHOUT ROWID; + +CREATE TABLE object_stores ( + store_id INTEGER PRIMARY KEY CHECK (store_id > 0), + name TEXT NOT NULL UNIQUE, + key_path BLOB, + auto_increment INTEGER NOT NULL CHECK (auto_increment IN (0, 1)), + generator INTEGER NOT NULL CHECK (generator BETWEEN 0 AND 9007199254740992) +) STRICT, WITHOUT ROWID; + +CREATE TABLE indexes ( + index_id INTEGER PRIMARY KEY CHECK (index_id > 0), + store_id INTEGER NOT NULL CHECK (store_id > 0), + name TEXT NOT NULL, + key_path BLOB NOT NULL, + is_unique INTEGER NOT NULL CHECK (is_unique IN (0, 1)), + multi_entry INTEGER NOT NULL CHECK (multi_entry IN (0, 1)), + UNIQUE (store_id, name) +) STRICT, WITHOUT ROWID; + +CREATE TABLE records ( + record_id INTEGER PRIMARY KEY CHECK (record_id > 0), + store_id INTEGER NOT NULL CHECK (store_id > 0), + key_sort BLOB NOT NULL CHECK (length(key_sort) > 0), + key_value BLOB NOT NULL CHECK (length(key_value) > 0), + wire_size INTEGER NOT NULL CHECK (wire_size >= 0), + usage INTEGER NOT NULL CHECK (usage >= 0), + UNIQUE (store_id, key_sort) +) STRICT, WITHOUT ROWID; + +CREATE TABLE record_chunks ( + record_id INTEGER NOT NULL CHECK (record_id > 0), + chunk_no INTEGER NOT NULL CHECK (chunk_no >= 0), + bytes BLOB NOT NULL CHECK (length(bytes) BETWEEN 1 AND 262144), + PRIMARY KEY (record_id, chunk_no) +) STRICT, WITHOUT ROWID; + +CREATE TABLE index_entries ( + index_id INTEGER NOT NULL CHECK (index_id > 0), + index_key_sort BLOB NOT NULL CHECK (length(index_key_sort) > 0), + index_key_value BLOB NOT NULL CHECK (length(index_key_value) > 0), + primary_key_sort BLOB NOT NULL CHECK (length(primary_key_sort) > 0), + record_id INTEGER NOT NULL CHECK (record_id > 0), + is_unique INTEGER NOT NULL CHECK (is_unique IN (0, 1)), + PRIMARY KEY (index_id, index_key_sort, primary_key_sort) +) STRICT, WITHOUT ROWID; + +CREATE UNIQUE INDEX index_entries_unique + ON index_entries(index_id, index_key_sort) WHERE is_unique = 1; +CREATE INDEX index_entries_record ON index_entries(record_id); + +CREATE TABLE external_objects ( + object_id INTEGER PRIMARY KEY CHECK (object_id > 0), + kind TEXT NOT NULL CHECK (kind IN ('blob', 'file', 'file-system-handle')), + size INTEGER NOT NULL CHECK (size >= 0), + mime_type TEXT, + file_name TEXT, + last_modified_f64 BLOB CHECK ( + last_modified_f64 IS NULL OR length(last_modified_f64) = 8 + ), + handle_kind TEXT CHECK (handle_kind IN ('file', 'directory')), + handle_bucket_kind TEXT CHECK (handle_bucket_kind IN ('default', 'named')), + handle_bucket_id INTEGER CHECK (handle_bucket_id IS NULL OR handle_bucket_id > 0), + CHECK ( + (kind = 'blob' AND mime_type IS NOT NULL + AND file_name IS NULL AND last_modified_f64 IS NULL + AND handle_kind IS NULL AND handle_bucket_kind IS NULL AND handle_bucket_id IS NULL) + OR + (kind = 'file' AND mime_type IS NOT NULL + AND file_name IS NOT NULL AND last_modified_f64 IS NOT NULL + AND handle_kind IS NULL AND handle_bucket_kind IS NULL AND handle_bucket_id IS NULL) + OR + (kind = 'file-system-handle' AND mime_type IS NULL + AND file_name IS NULL AND last_modified_f64 IS NULL AND size = 0 + AND handle_kind IS NOT NULL AND handle_bucket_kind IS NOT NULL + AND ((handle_bucket_kind = 'default' AND handle_bucket_id IS NULL) + OR (handle_bucket_kind = 'named' AND handle_bucket_id IS NOT NULL))) + ) +) STRICT, WITHOUT ROWID; + +CREATE TABLE record_objects ( + record_id INTEGER NOT NULL CHECK (record_id > 0), + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + object_id INTEGER NOT NULL CHECK (object_id > 0), + PRIMARY KEY (record_id, ordinal) +) STRICT, WITHOUT ROWID; + +CREATE INDEX record_objects_object ON record_objects(object_id); + +CREATE TABLE blob_chunks ( + object_id INTEGER NOT NULL CHECK (object_id > 0), + chunk_no INTEGER NOT NULL CHECK (chunk_no >= 0), + bytes BLOB NOT NULL CHECK (length(bytes) BETWEEN 1 AND 262144), + PRIMARY KEY (object_id, chunk_no) +) STRICT, WITHOUT ROWID; + +CREATE TABLE handle_components ( + object_id INTEGER NOT NULL CHECK (object_id > 0), + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + name TEXT NOT NULL, + PRIMARY KEY (object_id, ordinal) +) STRICT, WITHOUT ROWID; diff --git a/moli-indexeddb/src/sqlite_schema_tests.rs b/moli-indexeddb/src/sqlite_schema_tests.rs index 68dcb16289..7f2641bc0c 100644 --- a/moli-indexeddb/src/sqlite_schema_tests.rs +++ b/moli-indexeddb/src/sqlite_schema_tests.rs @@ -17,8 +17,11 @@ fn database() -> Connection { "INSERT INTO database_metadata (singleton, name, generation, version, state, key_format, key_path_format, value_format, usage) - VALUES (1, 'database', zeroblob(16), 0, 'creating', 1, 1, 1, 0)", - [], + VALUES (1, ?1, zeroblob(16), 0, 'creating', 1, 1, 1, 0)", + ["database" + .encode_utf16() + .flat_map(u16::to_be_bytes) + .collect::>()], ) .unwrap(); transaction.commit().unwrap(); @@ -54,7 +57,7 @@ fn schema_uses_eleven_strict_without_rowid_tables_and_explicit_ids() { connection .pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0)) .unwrap(), - 1 + i64::from(crate::sqlite::SCHEMA_VERSION) ); for statement in [ "INSERT INTO object_stores(name, auto_increment, generator) VALUES ('store', 0, 0)", @@ -239,3 +242,28 @@ fn schema_rejects_invalid_external_objects_and_unbounded_chunks() { ) .unwrap(); } + +#[test] +fn schema_database_names_are_even_utf16_blobs_and_preserve_lone_surrogates() { + let connection = database(); + assert!( + connection + .execute("UPDATE database_metadata SET name='text'", []) + .is_err() + ); + assert!( + connection + .execute("UPDATE database_metadata SET name=X'D8'", []) + .is_err() + ); + connection + .execute("UPDATE database_metadata SET name=X'D800'", []) + .unwrap(); + assert_eq!( + connection + .query_row("SELECT name FROM database_metadata", [], |row| row + .get::<_, Vec>(0)) + .unwrap(), + vec![0xd8, 0] + ); +} diff --git a/moli-renderer-v8/src/context_bootstrap/indexed_db/core/objects/creation/database.rs b/moli-renderer-v8/src/context_bootstrap/indexed_db/core/objects/creation/database.rs index 7e80c896a2..f5cfac0e23 100644 --- a/moli-renderer-v8/src/context_bootstrap/indexed_db/core/objects/creation/database.rs +++ b/moli-renderer-v8/src/context_bootstrap/indexed_db/core/objects/creation/database.rs @@ -4,6 +4,7 @@ use crate::context_bootstrap::{ simple_event_target_handler_setter_callback, }; use crate::web_api_interfaces; +use moli_indexeddb::IndexedDbName; use moli_webapi_declare::WebApiObject; #[derive(WebApiObject)] @@ -13,12 +14,12 @@ use moli_webapi_declare::WebApiObject; data_properties, enumerable )] -struct IdbDatabaseObjectDeclaration { +struct IdbDatabaseObjectDeclaration<'scope> { #[webapi(slot = SIMPLE_EVENT_TARGET_LISTENERS_SLOT, init = "null_object")] event_listeners: (), #[webapi(slot = crate::context_bootstrap::indexed_db::DATABASE_NAME_SLOT)] - name: String, + name: v8::Local<'scope, v8::String>, #[webapi(slot = crate::context_bootstrap::indexed_db::DATABASE_VERSION_SLOT)] version: f64, #[webapi( @@ -68,11 +69,13 @@ pub(in crate::context_bootstrap::indexed_db) fn create_database_object<'s>( storage_scope: IndexedDbStorageScope, owner: IndexedDbExecutionOwner, handle: DatabaseHandle, - name: &str, + name: &IndexedDbName, version: u64, client: moli_indexeddb::sqlite::service::DatabaseClient, ) -> Result, IndexedDbError> { - let database = IdbDatabaseObjectDeclaration::new(name.to_owned(), version as f64) + let name = v8::String::new_from_two_byte(scope, name.as_utf16(), v8::NewStringType::Normal) + .ok_or_else(|| IndexedDbError::Resource("Unable to allocate IndexedDB name".into()))?; + let database = IdbDatabaseObjectDeclaration::new(name, version as f64) .bind(scope) .map_err(|error| IndexedDbError::InvalidState(error.to_string()))?; register_indexed_db_wrapper_with_owner( diff --git a/moli-renderer-v8/src/context_bootstrap/indexed_db/database.rs b/moli-renderer-v8/src/context_bootstrap/indexed_db/database.rs index bdf5d9e61e..e394797710 100644 --- a/moli-renderer-v8/src/context_bootstrap/indexed_db/database.rs +++ b/moli-renderer-v8/src/context_bootstrap/indexed_db/database.rs @@ -1,23 +1,24 @@ use super::{ INDEXED_DB_DATABASE_CLOSED_SLOT, INDEXED_DB_DATABASE_UPGRADE_TRANSACTION_SLOT, INDEXED_DB_TRANSACTION_ABORTED_SLOT, INDEXED_DB_TRANSACTION_ACTIVE_SLOT, - INDEXED_DB_TRANSACTION_FINISHED_SLOT, IndexedDbExecutionOwner, IndexedDbStorageScope, - IndexedDbWrapperKind, KeyPath, ObjectStoreInfo, ObjectStoreOptions, TransactionMode, - abort_queued_transaction_requests, compare_idb_keys, context_host_ptr_from_global_bridge, - create_object_store_object, create_open_request_object, create_transaction_object, - current_storage_scope, database_handle_from_value, dom_exception_value, - enqueue_indexed_db_task, enqueue_ready_transaction_starts, enqueue_transaction_abort_task, - enqueue_transaction_commit_task, indexed_db_databases_settle_task_payload, - indexed_db_factory_storage_scope, indexed_db_runtime_factory, indexed_db_typed_execution_owner, - indexed_db_typed_wrapper_is, object_bool_property, object_property_as_object, - object_store_info_from_database_metadata, origin_allows_indexed_db, - parse_optional_idb_key_path_member, register_indexed_db_databases_settle_task, - register_regular_transaction, remove_database_store_metadata, request_error_object, - require_idb_key, set_database_store_metadata, set_indexed_db_slot_value, + INDEXED_DB_TRANSACTION_FINISHED_SLOT, IndexedDbExecutionOwner, IndexedDbName, + IndexedDbStorageScope, IndexedDbWrapperKind, KeyPath, ObjectStoreInfo, ObjectStoreOptions, + TransactionMode, abort_queued_transaction_requests, compare_idb_keys, + context_host_ptr_from_global_bridge, create_object_store_object, create_open_request_object, + create_transaction_object, current_storage_scope, database_handle_from_value, + dom_exception_value, enqueue_indexed_db_task, enqueue_ready_transaction_starts, + enqueue_transaction_abort_task, enqueue_transaction_commit_task, + indexed_db_databases_settle_task_payload, indexed_db_factory_storage_scope, + indexed_db_runtime_factory, indexed_db_typed_execution_owner, indexed_db_typed_wrapper_is, + object_bool_property, object_property_as_object, object_store_info_from_database_metadata, + origin_allows_indexed_db, parse_optional_idb_key_path_member, + register_indexed_db_databases_settle_task, register_regular_transaction, + remove_database_store_metadata, request_error_object, require_idb_key, + set_database_store_metadata, set_indexed_db_slot_value, storage_scope_for_window_execution_context, store_request_error, sync_transaction_object_store_names_from_database, throw_type_error, - transaction_handle_from_value, unregister_regular_transaction, v8_string, - validate_storage_bucket_scope, with_indexed_db_manager, + transaction_handle_from_value, unregister_regular_transaction, validate_storage_bucket_scope, + with_indexed_db_manager, }; mod attributes; diff --git a/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory.rs b/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory.rs index 916b1165b5..d30d9924e9 100644 --- a/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory.rs +++ b/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory.rs @@ -72,7 +72,7 @@ fn dispatch_factory_request<'s>( scope: &mut v8::PinScope<'s, '_>, request: v8::Local<'s, v8::Object>, storage_scope: IndexedDbStorageScope, - name: String, + name: IndexedDbName, operation: FactoryOperation, ) { let result = factory_sql_service(scope).and_then(|service| { diff --git a/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory/databases.rs b/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory/databases.rs index bb4117eb4c..0460758383 100644 --- a/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory/databases.rs +++ b/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory/databases.rs @@ -120,20 +120,20 @@ fn database_info_array<'s>( let snapshot = service.databases(storage_scope.storage_key().as_bytes())?; Ok(info_array( scope, - snapshot - .iter() - .map(|info| (info.name.as_str(), info.version)), + snapshot.iter().map(|info| (&info.name, info.version)), )) } fn info_array<'s, 'a>( scope: &mut v8::PinScope<'s, '_>, - infos: impl ExactSizeIterator, + infos: impl ExactSizeIterator, ) -> v8::Local<'s, v8::Array> { let array = v8::Array::new(scope, infos.len() as i32); for (index, (name, version)) in infos.enumerate() { let object = ObjectLiteralDeclaration::bind(scope); - if let Some(name) = v8_string(scope, name) { + if let Some(name) = + v8::String::new_from_two_byte(scope, name.as_utf16(), v8::NewStringType::Normal) + { object.set_string_property(scope, "name", name.into()); } let version = v8::Number::new(scope, version as f64); diff --git a/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory/delete_database.rs b/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory/delete_database.rs index 71f9c96b6e..fdf22520e8 100644 --- a/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory/delete_database.rs +++ b/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory/delete_database.rs @@ -5,8 +5,8 @@ use crate::webidl; #[derive(webidl::WebIdlArgs)] #[webidl(prefix = "IDBFactory.deleteDatabase")] struct IdbFactoryDeleteDatabaseArgs { - #[webidl(required)] - name: String, + #[webidl(required, converter = "raw")] + name: webidl::DomString16, } pub(in crate::context_bootstrap::indexed_db) fn idb_factory_delete_database_callback<'s>( @@ -17,7 +17,7 @@ pub(in crate::context_bootstrap::indexed_db) fn idb_factory_delete_database_call let Some(parsed) = webidl::parse_args::(scope, &args) else { return; }; - let name = parsed.name; + let name = IndexedDbName::from_utf16(parsed.name.0); let Some(owner) = idb_factory_effective_execution_owner(scope, args.this()) else { let exception = dom_exception_value( scope, diff --git a/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory/open.rs b/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory/open.rs index c0ca4bedca..b44da41068 100644 --- a/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory/open.rs +++ b/moli-renderer-v8/src/context_bootstrap/indexed_db/database/factory/open.rs @@ -5,8 +5,8 @@ use crate::webidl; #[derive(webidl::WebIdlArgs)] #[webidl(prefix = "IDBFactory.open")] struct IdbFactoryOpenArgs { - #[webidl(required)] - name: String, + #[webidl(required, converter = "raw")] + name: webidl::DomString16, #[webidl(converter = "enforce_range_unsigned_long_long")] version: Option, } @@ -19,7 +19,7 @@ pub(in crate::context_bootstrap::indexed_db) fn idb_factory_open_callback<'s>( let Some(parsed) = webidl::parse_args::(scope, &args) else { return; }; - let name = parsed.name; + let name = IndexedDbName::from_utf16(parsed.name.0); let version = parsed.version; if version == Some(0) { throw_type_error( diff --git a/moli-renderer-v8/src/context_bootstrap/indexed_db/mod.rs b/moli-renderer-v8/src/context_bootstrap/indexed_db/mod.rs index 6762d20a9e..96a5c6b453 100644 --- a/moli-renderer-v8/src/context_bootstrap/indexed_db/mod.rs +++ b/moli-renderer-v8/src/context_bootstrap/indexed_db/mod.rs @@ -164,7 +164,7 @@ pub use self::state::{ new_indexed_db_manager, new_sqlite_indexed_db_manager, }; pub(in crate::context_bootstrap) use self::storage_bucket::scoped_storage_bucket_indexed_db_factory; -pub use moli_indexeddb::{Key, ObjectStoreOptions, TransactionMode}; +pub use moli_indexeddb::{IndexedDbName, Key, ObjectStoreOptions, TransactionMode}; fn is_indexed_db_private_slot(slot: &str) -> bool { slot.starts_with("moli.IndexedDb.") diff --git a/moli-renderer-v8/src/context_bootstrap/indexed_db/typed_state/factory.rs b/moli-renderer-v8/src/context_bootstrap/indexed_db/typed_state/factory.rs index 2c6430d769..c6cbacdc43 100644 --- a/moli-renderer-v8/src/context_bootstrap/indexed_db/typed_state/factory.rs +++ b/moli-renderer-v8/src/context_bootstrap/indexed_db/typed_state/factory.rs @@ -36,7 +36,7 @@ pub(super) struct FactoryRequest { request: RealmValueHandle, service: ServiceClient, storage_scope: IndexedDbStorageScope, - name: String, + name: IndexedDbName, operation: FactoryOperation, client: Option, stage: Stage, @@ -69,7 +69,7 @@ pub(in crate::context_bootstrap::indexed_db) fn enqueue_sql_factory_request<'s>( request: v8::Local<'s, v8::Object>, service: ServiceClient, storage_scope: IndexedDbStorageScope, - name: String, + name: IndexedDbName, operation: FactoryOperation, ) -> Result<(), IndexedDbError> { let owner = indexed_db_typed_execution_owner(scope, request) @@ -84,7 +84,7 @@ pub(in crate::context_bootstrap::indexed_db) fn enqueue_sql_factory_request<'s>( ); let wake = super::super::runtime::indexed_db_sql_completion_wake(scope, owner, id); let queued_wake = wake.clone(); - let lease = match service.queue_connection_request( + let lease = match service.queue_connection_request_named( storage_scope.storage_key().as_bytes(), &name, Arc::new(move || { @@ -312,7 +312,7 @@ fn resolve<'s>( scope, request, move |completion| { - let client = service.resolve(owner, name, completion)?; + let client = service.resolve_named(owner, name, completion)?; if let Some(payload) = target.upgrade() { payload.borrow_mut().client = Some(client.clone()); } diff --git a/moli-renderer-v8/src/script_vm/tests/indexed_db.rs b/moli-renderer-v8/src/script_vm/tests/indexed_db.rs index 9694d40536..d7372710de 100644 --- a/moli-renderer-v8/src/script_vm/tests/indexed_db.rs +++ b/moli-renderer-v8/src/script_vm/tests/indexed_db.rs @@ -6777,3 +6777,73 @@ fn indexed_db_roundtrips_image_data_and_geometry_objects() { r#"{"image":[true,true,true,2,1,"display-p3","1,2,3,4,5,6,7,8",true],"pointReadOnly":true,"point":true,"rectReadOnly":true,"rect":true,"quad":true,"matrixReadOnly":true,"matrix":true,"expandosExcluded":true}"# ); } + +#[test] +fn indexed_db_utf16_database_names_survive_sqlite_profile_restart() { + let root = indexed_db_test_root("database-names-profile-restart"); + let units: Vec> = vec![ + vec![], + vec![0], + vec![0xd800], + vec![0xd801], + vec![0xdfff], + vec![0xfffd], + vec![0xd800, 0xdc00], + vec![0xdc00, 0xd800], + vec![0xd800, 0], + vec![0x64, 0x38, 0x30, 0x30], + ]; + for write in [true, false] { + let manager = crate::new_indexed_db_manager(Some(root.clone())).unwrap(); + let mut vm = new_storage_page_task_executor_test_vm( + "https://indexeddb-database-names-profile.test/", + ); + vm.set_indexed_db_manager(Some(crate::downgrade_indexed_db_manager(&manager))); + vm.set_storage_bucket_store( + crate::new_shared_storage_bucket_store_with_indexed_db_manager(&manager), + ); + vm.eval(&format!( + "globalThis.writeNames={write}; globalThis.nameUnits={};", + serde_json::to_string(&units).unwrap() + )) + .unwrap(); + vm.eval(r#" +globalThis.databaseNameProfile = 'pending'; +(async () => { + const result = request => new Promise((resolve,reject) => { + request.onsuccess=()=>resolve(request.result); request.onerror=()=>reject(request.error); + }); + const complete = transaction => new Promise((resolve,reject) => { + transaction.oncomplete=resolve; transaction.onabort=()=>reject(transaction.error); + }); + const records=[]; + for (let i=0;iopen.result.createObjectStore('s'); + const db=await result(open); + if(db.name!==name) throw Error('database name identity'); + if(writeNames) { + const transaction=db.transaction('s','readwrite');const done=complete(transaction); + transaction.objectStore('s').put(i,'key');await done; + } + const transaction=db.transaction('s');const done=complete(transaction); + records.push(await result(transaction.objectStore('s').get('key')));await done;db.close(); + } + const listed=(await indexedDB.databases()).map(info=>Array.from({length:info.name.length},(_,i)=>info.name.charCodeAt(i))); + return {records,listed}; +})().then(result=>databaseNameProfile=JSON.stringify(result),error=>databaseNameProfile=String(error)); +"#).unwrap(); + let actual = vm + .eval_after_selected_page_tasks("databaseNameProfile") + .unwrap(); + let mut ordered = units.clone(); + ordered.sort(); + assert_eq!( + serde_json::from_str::(&actual).unwrap(), + serde_json::json!({"records":(0..units.len()).collect::>(),"listed":ordered}), + "profile write: {write}" + ); + } + std::fs::remove_dir_all(root).unwrap(); +}