tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 36bd15ca9dfdf8bc59e8f8b8de134f7f1889750c
parent 2cb6d175faeb898a9f06659143349146e6865e9e
Author: Luca Greco <lgreco@mozilla.com>
Date:   Wed,  8 Oct 2025 18:25:01 +0000

Bug 1885297 - Make sure storage.local.clear can successfully drop an underlying corrupted IDB database. r=robwu

Differential Revision: https://phabricator.services.mozilla.com/D265632

Diffstat:
Mtoolkit/components/extensions/ExtensionStorageIDB.sys.mjs | 52+++++++++++++++++++++++++++++++++++++++++++++-------
Mtoolkit/components/extensions/metrics.yaml | 3+++
Mtoolkit/components/extensions/test/xpcshell/test_ext_storage_local_corrupted_idb.js | 194+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtoolkit/components/extensions/test/xpcshell/xpcshell-common.toml | 1+
4 files changed, 243 insertions(+), 7 deletions(-)

diff --git a/toolkit/components/extensions/ExtensionStorageIDB.sys.mjs b/toolkit/components/extensions/ExtensionStorageIDB.sys.mjs @@ -169,6 +169,8 @@ var ErrorsTelemetry = { }; export class ExtensionStorageLocalIDB extends IndexedDB { + #storagePrincipal; + onupgradeneeded(event) { if (event.oldVersion < 1) { this.createObjectStore(IDB_DATA_STORENAME); @@ -184,6 +186,7 @@ export class ExtensionStorageLocalIDB extends IndexedDB { let result = await super.openForPrincipal(storagePrincipal, IDB_NAME, { version: IDB_VERSION, }); + result.#storagePrincipal = storagePrincipal; const isMissingObjestStore = db => !db.objectStoreNames.contains(IDB_DATA_STORENAME) && @@ -248,6 +251,19 @@ export class ExtensionStorageLocalIDB extends IndexedDB { }); } + async dropAndReopen() { + // Forcefully drop the corrupted IndexedDB database. + await ExtensionStorageLocalIDB.resetForPrincipal(this.#storagePrincipal); + // Reopen the database after it has been reset and retrive the + // underlying wrapped IndexedDB database instance to become + // the active one for the current IndexedDB database wrapper + // instance. + const newInstance = await ExtensionStorageLocalIDB.openForPrincipal( + this.#storagePrincipal + ); + this.db = newInstance.db; + } + async isEmpty() { const cursor = await this.objectStore( IDB_DATA_STORENAME, @@ -462,15 +478,37 @@ export class ExtensionStorageLocalIDB extends IndexedDB { const objectStore = this.objectStore(IDB_DATA_STORENAME, "readwrite"); - const cursor = await objectStore.openCursor(); - while (!cursor.done) { - changes[cursor.key] = { oldValue: cursor.value }; - changed = true; - await cursor.continue(); + try { + const cursor = await objectStore.openCursor(); + while (!cursor.done) { + changes[cursor.key] = { oldValue: cursor.value }; + changed = true; + await cursor.continue(); + } + await objectStore.clear(); + } catch (err) { + // Error names expected to be raised on known corrupted storage + // issues that storage.local.clear method may be hitting. + const KNOWN_CORRUPTED_ERROR_NAMES = ["UnknownError"]; + const errorName = ErrorsTelemetry.getErrorName(err); + if ( + lazy.disabledAutoResetOnCorrupted || + !KNOWN_CORRUPTED_ERROR_NAMES.includes(errorName) + ) { + throw err; + } else { + // Drop and reopen the database if iterating over the + // IDB objectStore keys or clearing the objectStore + // has hit unexpected rejections. + Cu.reportError(err); + Glean.extensionsData.storageLocalCorruptedReset.record({ + addon_id: this.#storagePrincipal.addonId, + reason: `RejectedClear:${errorName}`, + }); + await this.dropAndReopen(); + } } - await objectStore.clear(); - return changed ? changes : null; } } diff --git a/toolkit/components/extensions/metrics.yaml b/toolkit/components/extensions/metrics.yaml @@ -408,6 +408,9 @@ extensions.data: database. e.g. - "ObjectStoreNotFound": (The IndexedDB instance is missing the expected ObjectStorage name). + - "RejectedClear:ERROR_NAME": (The storage.local.clear method has hit an unexpected + error while iterating over the database keys, ERROR_NAME is either a DOMException + error name or "OtherError" in case of non-DOMException errors). type: string after_reset: description: | diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_local_corrupted_idb.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_local_corrupted_idb.js @@ -36,6 +36,9 @@ add_task(async function test_idb_autoreset_default() { // This test the same kind of unexpected corruption of the underlying // idb database tracked by Bug 1979997. add_task(async function test_idb_reset_on_missing_object_store() { + // Clear any previously collected telemetry. + Services.fog.testResetFOG(); + const id = "test-corrupted-idb@test-ext"; // NOTE: this test extension is only used to derive the storagePrincipal, @@ -224,3 +227,194 @@ add_task(async function test_idb_reset_on_missing_object_store() { await extension.unload(); }); + +add_task(async function test_corrupted_idb_key() { + // Clear any previously collected telemetry. + Services.fog.testResetFOG(); + + const id = "test-corrupted-idb-key@test-ext"; + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["storage", "unlimitedStorage"], + browser_specific_settings: { + gecko: { id }, + }, + }, + background() { + browser.test.onMessage.addListener(async msg => { + let res = {}; + switch (msg) { + case "write-data": + await browser.storage.local + .set({ + "test-key": "test-value", + // The value set on this key should be big enough to + // be stored as a separate file outside of the sqlite + // database, that file will be purposely removed by + // this test to simulate the database corruption issue + // that some users have been hitting in their Firefox + // profile. + "test-to-be-corrupted-key": new Array(100000).fill("x"), + }) + .catch(err => { + res.error = `${err}`; + }); + break; + case "read-data": + await browser.storage.local.get(null).catch(err => { + res.error = `${err}`; + }); + break; + case "clear-data": + await browser.storage.local.clear().catch(err => { + res.error = `${err}`; + }); + break; + default: + browser.test.fail(`Got unexpected test message: ${msg}`); + } + browser.test.sendMessage(`${msg}:done`, res); + }); + }, + }); + + await extension.startup(); + const { uuid } = extension; + + extension.sendMessage("write-data"); + let writeResult = await extension.awaitMessage("write-data:done"); + Assert.equal( + writeResult.error, + null, + "Expect no error to be hit while writing data into storage.local" + ); + + const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" + ); + + let addon = await AddonManager.getAddonByID(id); + await addon.disable(); + + const baseDataPath = PathUtils.join( + PathUtils.profileDir, + "storage", + "default", + `moz-extension+++${uuid}^userContextId=${WEBEXT_STORAGE_USER_CONTEXT_ID}`, + "idb" + ); + + let sqliteFilePath = (await IOUtils.getChildren(baseDataPath)).find( + filePath => filePath.endsWith(".sqlite") + ); + + let db = await Sqlite.openConnection({ path: sqliteFilePath }); + let rows = await db.execute( + "SELECT * FROM object_data WHERE file_ids IS NOT NULL;" + ); + // Sanity check. + Assert.equal( + rows[0]?.getResultByName("file_ids"), + ".1", + "object_data entry with associated file_ids expected to be found" + ); + await db.close(); + + let idbDataFileBasePath = (await IOUtils.getChildren(baseDataPath)).find( + filePath => filePath.endsWith(".files") + ); + await IOUtils.remove(PathUtils.join(idbDataFileBasePath, "1"), { + ignoreAbsent: false, + }); + + await addon.enable(); + await extension.awaitStartup(); + + // Confirm that the database is corrupted and the extension + // is unable to retrieve the data + info( + "Verify that reading and writing on the corrupted storage.local key fails as expected" + ); + extension.sendMessage("read-data"); + let readResult = await extension.awaitMessage("read-data:done"); + Assert.equal( + readResult.error, + "Error: An unexpected error occurred", + "Expect a rejection to be hit while retrieving data from the corrupted storage.local key" + ); + extension.sendMessage("write-data"); + writeResult = await extension.awaitMessage("write-data:done"); + Assert.equal( + writeResult.error, + "Error: An unexpected error occurred", + "Expect a rejection to be hit while writing data into the corrupted storage.local key" + ); + + info( + "Verify clearing the storage.local corrupted key fails as expected if auto-reset is disabled" + ); + // Disable automatically drop corrupted database. + Services.prefs.setBoolPref( + "extensions.webextensions.keepStorageOnCorrupted.storageLocal", + true + ); + extension.sendMessage("clear-data"); + let clearResult = await extension.awaitMessage("clear-data:done"); + Assert.equal( + clearResult.error, + "Error: An unexpected error occurred", + "Expect a rejection to be hit while clearing storage.local with corrupted key if auto-reset is disabled" + ); + + info( + "Verify clearing the storage.local corrupted key succeeded as expected if auto-reset is enabled" + ); + // Enable automatically drop corrupted database. + Services.prefs.setBoolPref( + "extensions.webextensions.keepStorageOnCorrupted.storageLocal", + false + ); + // Call storage.local.clear and confirm that it doesn't hit a rejection + // due to the underlying database corruption and then verify that + // storage.local.get and storage.local.set do not hit a rejection anymore. + extension.sendMessage("clear-data"); + clearResult = await extension.awaitMessage("clear-data:done"); + Assert.equal( + clearResult.error, + null, + "Expect no rejection to be hit while clearing the entire corrupted storage.local data" + ); + + extension.sendMessage("write-data"); + Assert.deepEqual( + await extension.awaitMessage("write-data:done"), + {}, + "Expect no rejection while writing storage.local data after successful storage.local.clear" + ); + + extension.sendMessage("read-data"); + Assert.deepEqual( + await extension.awaitMessage("read-data:done"), + {}, + "Expect no rejection while reading storage.local data after successful storage.local.clear" + ); + + await Services.fog.testFlushAllChildren(); + const gleanEvents = Glean.extensionsData.storageLocalCorruptedReset + .testGetValue() + ?.map(event => event.extra); + Assert.deepEqual( + gleanEvents ?? [], + [ + { + addon_id: extension.id, + reason: "RejectedClear:UnknownError", + }, + ], + "Got the expected telemetry event recorded when the UnknownError is being hit by storage.local.clear API calls" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common.toml b/toolkit/components/extensions/test/xpcshell/xpcshell-common.toml @@ -569,6 +569,7 @@ skip-if = [ skip-if = ["os == 'android' && debug"] ["test_ext_storage_local_corrupted_idb.js"] +skip-if = ["os == 'android'"] # Uncaught rejection failure while running in in-process-webextensions mode. ["test_ext_storage_managed.js"] skip-if = ["os == 'android'"]