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.
This commit is contained in:
ldm0
2026-10-30 17:50:36 +08:00
committed by Donough Liu
parent 53fd3f6fd8
commit 8564d0a23f
28 changed files with 1257 additions and 85 deletions
@@ -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};
}
@@ -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(())
}
+11 -2
View File
@@ -18,7 +18,7 @@ pub struct ConnectionRequestQueues {
struct QueueState {
closed: bool,
next_id: u64,
queues: BTreeMap<(String, String), VecDeque<QueuedRequest>>,
queues: BTreeMap<(String, crate::IndexedDbName), VecDeque<QueuedRequest>>,
}
struct QueuedRequest {
@@ -31,7 +31,7 @@ struct QueuedRequest {
#[derive(Clone)]
pub struct ConnectionRequestHandle {
queues: Weak<ConnectionRequestQueues>,
key: (String, String),
key: (String, crate::IndexedDbName),
id: u64,
}
@@ -65,6 +65,15 @@ impl ConnectionRequestQueues {
storage_key: &str,
name: &str,
wake: ConnectionRequestWake,
) -> Result<ConnectionRequestLease, crate::IndexedDbError> {
self.try_enqueue_named(storage_key, &name.into(), wake)
}
pub fn try_enqueue_named(
self: &Arc<Self>,
storage_key: &str,
name: &crate::IndexedDbName,
wake: ConnectionRequestWake,
) -> Result<ConnectionRequestLease, crate::IndexedDbError> {
let mut state = self.state.lock();
if state.closed {
@@ -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;
+25 -14
View File
@@ -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<u8>,
pub name: String,
pub name: crate::IndexedDbName,
pub generation: [u8; 16],
}
@@ -81,7 +82,7 @@ pub type StoreDefinitions = BTreeMap<String, StoreMetadata>;
#[derive(Debug)]
pub struct DatabaseMetadata {
pub name: String,
pub name: crate::IndexedDbName,
pub version: u64,
pub usage: u64,
pub stores: Arc<StoreDefinitions>,
@@ -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<DatabaseIdentity>,
) -> Result<Self, IndexedDbError> {
moli_sqlite::require_schema_version(&connection, SCHEMA_VERSION)
.map_err(|error| corruption(&error.to_string()))?;
let version = names::schema_version(&connection)?;
let saved: Option<bool> = 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<i64> = 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
+93
View File
@@ -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<u32, IndexedDbError> {
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<Vec<u8>, 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<IndexedDbName, IndexedDbError> {
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<IndexedDbName, IndexedDbError> {
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<IndexedDbName, IndexedDbError> {
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(())
}
+2 -2
View File
@@ -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<Arc<Self>, IndexedDbError> {
let name = owned_text(&self.name)?;
let name = names::copy(&self.name)?;
Ok(Arc::new(Self {
name,
usage,
+2 -2
View File
@@ -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')),
+2 -2
View File
@@ -200,7 +200,7 @@ struct Lane {
struct Registry {
closed: bool,
next_id: u64,
entries: BTreeMap<(Vec<u8>, String), DatabaseClient>,
entries: BTreeMap<(Vec<u8>, crate::IndexedDbName), DatabaseClient>,
clearing: BTreeMap<clear::ClearScope, clear::ClearingOwner>,
}
@@ -304,7 +304,7 @@ impl ServiceClient {
fn register_client(
&self,
registry: &mut Registry,
key: (Vec<u8>, String),
key: (Vec<u8>, crate::IndexedDbName),
generation: [u8; 16],
location: Location,
catalog: Option<Arc<catalog::DiskCatalog>>,
+21 -10
View File
@@ -80,6 +80,15 @@ impl ServiceClient {
owner_key: Vec<u8>,
name: String,
completion: impl FnOnce(Completion) + Send + 'static,
) -> Result<DatabaseClient, DispatchError> {
self.resolve_named(owner_key, name.into(), completion)
}
pub fn resolve_named(
&self,
owner_key: Vec<u8>,
name: crate::IndexedDbName,
completion: impl FnOnce(Completion) + Send + 'static,
) -> Result<DatabaseClient, DispatchError> {
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<Option<u64>, IndexedDbError> {
self.database_version_named(owner_key, &name.into())
}
pub fn database_version_named(
&self,
owner_key: &[u8],
name: &crate::IndexedDbName,
) -> Result<Option<u64>, 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);
@@ -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::<std::collections::BTreeSet<_>>();
assert_eq!(hashes.len(), 6);
}
}
@@ -109,6 +109,15 @@ impl ServiceClient {
owner_key: &[u8],
name: &str,
wake: crate::ConnectionRequestWake,
) -> Result<crate::ConnectionRequestLease, IndexedDbError> {
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<crate::ConnectionRequestLease, IndexedDbError> {
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)
}
}
+24 -6
View File
@@ -22,7 +22,7 @@ fn config() -> ServiceConfig {
}
}
fn identity(name: &str) -> DatabaseIdentity {
fn identity(name: impl Into<crate::IndexedDbName>) -> 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>) -> 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<crate::IndexedDbName>,
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<crate::IndexedDbName>,
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::<Vec<_>>();
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(
@@ -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::<Vec<_>>(),
["\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::<Vec<_>>();
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::<Vec<_>>(),
names
);
assert_eq!(
paths
.iter()
.collect::<std::collections::BTreeSet<_>>()
.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);
}
@@ -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);
}
+1
View File
@@ -1,3 +1,4 @@
mod database_names;
mod schema_rename;
mod storage_contracts;
use super::*;
@@ -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<u8> = 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<u8>>(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"
);
}
+129
View File
@@ -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;
+31 -3
View File
@@ -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::<Vec<_>>()],
)
.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<u8>>(0))
.unwrap(),
vec![0xd8, 0]
);
}
@@ -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<v8::Local<'s, v8::Object>, 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(
@@ -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;
@@ -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| {
@@ -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<Item = (&'a str, u64)>,
infos: impl ExactSizeIterator<Item = (&'a IndexedDbName, u64)>,
) -> 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);
@@ -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::<IdbFactoryDeleteDatabaseArgs>(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,
@@ -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<u64>,
}
@@ -19,7 +19,7 @@ pub(in crate::context_bootstrap::indexed_db) fn idb_factory_open_callback<'s>(
let Some(parsed) = webidl::parse_args::<IdbFactoryOpenArgs>(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(
@@ -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.")
@@ -36,7 +36,7 @@ pub(super) struct FactoryRequest {
request: RealmValueHandle,
service: ServiceClient,
storage_scope: IndexedDbStorageScope,
name: String,
name: IndexedDbName,
operation: FactoryOperation,
client: Option<DatabaseClient>,
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());
}
@@ -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<u16>> = 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;i<nameUnits.length;i++) {
const name=String.fromCharCode(...nameUnits[i]);
const open=indexedDB.open(name,1);
open.onupgradeneeded=()=>open.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::<serde_json::Value>(&actual).unwrap(),
serde_json::json!({"records":(0..units.len()).collect::<Vec<_>>(),"listed":ordered}),
"profile write: {write}"
);
}
std::fs::remove_dir_all(root).unwrap();
}