commit 36e0bbeb3b907dad07233ed4c3b37fcbec64ac09
parent 9b1a02828ca2685d455014bf28ca8bcc466b040c
Author: David P. <daparks@mozilla.com>
Date: Wed, 15 Oct 2025 23:08:52 +0000
Bug 1994513: Do not back up session storage r=mconley
Differential Revision: https://phabricator.services.mozilla.com/D268753
Diffstat:
3 files changed, 298 insertions(+), 3 deletions(-)
diff --git a/browser/components/backup/resources/SessionStoreBackupResource.sys.mjs b/browser/components/backup/resources/SessionStoreBackupResource.sys.mjs
@@ -17,6 +17,13 @@ ChromeUtils.defineESModuleGetters(lazy, {
* Class representing Session store related files within a user profile.
*/
export class SessionStoreBackupResource extends BackupResource {
+ // Allow creator to provide a "SessionStore" object, so we can use mocks in
+ // testing. Passing `null` means use the real service.
+ constructor(sessionStore = null) {
+ super();
+ this._sessionStore = sessionStore;
+ }
+
static get key() {
return "sessionstore";
}
@@ -28,19 +35,41 @@ export class SessionStoreBackupResource extends BackupResource {
return false;
}
+ get #sessionStore() {
+ return this._sessionStore || lazy.SessionStore;
+ }
+
async backup(
stagingPath,
profilePath = PathUtils.profileDir,
_isEncrypting = false
) {
- let sessionStoreState = lazy.SessionStore.getCurrentState(true);
+ let sessionStoreState = this.#sessionStore.getCurrentState(true);
let sessionStorePath = PathUtils.join(stagingPath, "sessionstore.jsonlz4");
// Preserving session cookies in a backup used on a different machine
// may break behavior for websites. So we leave them out of the backup.
-
sessionStoreState.cookies = [];
+ // Remove session storage.
+ if (sessionStoreState.windows) {
+ sessionStoreState.windows.forEach(win => {
+ if (win.tabs) {
+ win.tabs.forEach(tab => delete tab.storage);
+ }
+ if (win._closedTabs) {
+ win._closedTabs.forEach(closedTab => delete closedTab.state.storage);
+ }
+ });
+ }
+ if (sessionStoreState.savedGroups) {
+ sessionStoreState.savedGroups.forEach(group => {
+ if (group.tabs) {
+ group.tabs.forEach(tab => delete tab.storage);
+ }
+ });
+ }
+
await IOUtils.writeJSON(sessionStorePath, sessionStoreState, {
compress: true,
});
@@ -63,7 +92,7 @@ export class SessionStoreBackupResource extends BackupResource {
async measure(profilePath = PathUtils.profileDir) {
// Get the current state of the session store JSON and
// measure it's uncompressed size.
- let sessionStoreJson = lazy.SessionStore.getCurrentState(true);
+ let sessionStoreJson = this.#sessionStore.getCurrentState(true);
let sessionStoreSize = new TextEncoder().encode(
JSON.stringify(sessionStoreJson)
).byteLength;
diff --git a/browser/components/backup/tests/xpcshell/test_SessionStoreBackupResource_mockSessionStore.js b/browser/components/backup/tests/xpcshell/test_SessionStoreBackupResource_mockSessionStore.js
@@ -0,0 +1,264 @@
+/* Any copyright is dedicated to the Public Domain.
+https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This tests that SessionStore backups contain the info that we want and do
+ * not contain info we don't want. This is separate from
+ * test_SessionStoreBackupResource because that uses the real SessionStore
+ * service and can only check what that includes. This test adds things that
+ * are not usually testable in xpcshell tests, like window session state
+ * serialization. It is based on test_SessionStoreBackupResource.js.
+ */
+
+const { SessionStoreBackupResource } = ChromeUtils.importESModule(
+ "resource:///modules/backup/SessionStoreBackupResource.sys.mjs"
+);
+
+const mockSessionStore = {
+ getCurrentState: _ignored => {
+ return {
+ cookies: [],
+ windows: [
+ {
+ tabs: [
+ {
+ someData: "hi I am data",
+ moreData: -3.7,
+ storage: {
+ message: "I don't get serialized!",
+ },
+ },
+ {
+ stillMoreData: -3.71,
+ storage: {
+ message: "I don't get serialized either!",
+ },
+ },
+ ],
+ _closedTabs: [
+ {
+ state: {
+ closedTabData: "hi I am a closed tab",
+ moreData: -3.7,
+ storage: {
+ message: "I don't get serialized!",
+ },
+ },
+ etc: {
+ dataNotInState: true,
+ },
+ },
+ {
+ state: {
+ storage: {
+ message: "I don't get serialized either!",
+ },
+ },
+ },
+ ],
+ },
+ {
+ tabs: [
+ {
+ someData: "hi I am window #2's data",
+ moreData: -3.7,
+ storage: {
+ message: "I don't get serialized!",
+ },
+ },
+ ],
+ _closedTabs: [
+ {
+ state: {
+ storage: {
+ message: "I don't get serialized either!",
+ },
+ },
+ notState: {
+ notStateData: "not state data",
+ },
+ },
+ ],
+ },
+ ],
+ savedGroups: [
+ {
+ tabs: [
+ {
+ savedGroupData: -3.7,
+ storage: {
+ message: "I don't get serialized!",
+ },
+ },
+ {
+ someData: "hi I am window #2's data",
+ moreData: -3.71,
+ // tab has no storage
+ },
+ ],
+ notTabData: "notTabData",
+ },
+ ],
+ };
+ },
+};
+
+// This is mockSessionStore but with the data that should not be saved removed.
+const filteredMockSessionData = mockSessionStore.getCurrentState(true);
+filteredMockSessionData.windows.forEach(win => {
+ win.tabs.forEach(tab => delete tab.storage);
+ win._closedTabs.forEach(closedTab => delete closedTab.state.storage);
+});
+filteredMockSessionData.savedGroups.forEach(group => {
+ group.tabs.forEach(tab => delete tab.storage);
+});
+
+/**
+ * Test that the backup method properly serializes window session state. This
+ * includes checking that it does NOT serialize window storage state.
+ */
+add_task(async function test_backups_have_correct_window_state() {
+ let sandbox = sinon.createSandbox();
+
+ let sessionStoreBackupResource = new SessionStoreBackupResource(
+ mockSessionStore
+ );
+ let sourcePath = await IOUtils.createUniqueDirectory(
+ PathUtils.tempDir,
+ "SessionStoreBackupResource-src"
+ );
+ let stagingPath = await IOUtils.createUniqueDirectory(
+ PathUtils.tempDir,
+ "SessionStoreBackupResource-stage"
+ );
+
+ // This "filtered" session store state is what we expect to write. It should
+ // not include any WebStorage items.
+ // Quick sanity-check that the filtering was done correctly and we will still
+ // serialize windows.
+ Assert.equal(
+ filteredMockSessionData.windows.length,
+ 2,
+ "will serialize 2 windows"
+ );
+ Assert.equal(
+ filteredMockSessionData.windows[0].tabs.length,
+ 2,
+ "will serialize 2 tabs for 1st window"
+ );
+ Assert.equal(
+ filteredMockSessionData.windows[0].tabs[0].storage,
+ undefined,
+ "does not contain win 0 tab storage"
+ );
+ Assert.equal(
+ filteredMockSessionData.windows[0]._closedTabs[0].storage,
+ undefined,
+ "does not contain win 0 closed tab storage"
+ );
+ Assert.equal(
+ filteredMockSessionData.savedGroups.length,
+ 1,
+ "will serialize 1 savedGroup"
+ );
+ Assert.equal(
+ filteredMockSessionData.savedGroups[0].tabs.length,
+ 2,
+ "will serialize 2 savedGroup tabs"
+ );
+
+ let manifestEntry = await sessionStoreBackupResource.backup(
+ stagingPath,
+ sourcePath,
+ false /* isEncrypted */
+ );
+ Assert.equal(
+ manifestEntry,
+ null,
+ "SessionStoreBackupResource.backup should return null as its ManifestEntry"
+ );
+
+ /**
+ * We don't expect the actual file sessionstore.jsonlz4 to exist in the profile directory before calling the backup method.
+ * Instead, verify that it is created by the backup method and exists in the staging folder right after.
+ */
+ await assertFilesExist(stagingPath, [{ path: "sessionstore.jsonlz4" }]);
+
+ /**
+ * Do a deep comparison between the filtered session state before backup
+ * and contents of the file made in the staging folder, to verify that
+ * information about session state was correctly written for backup.
+ */
+ let sessionStoreStateStaged = await IOUtils.readJSON(
+ PathUtils.join(stagingPath, "sessionstore.jsonlz4"),
+ { decompress: true }
+ );
+
+ Assert.deepEqual(
+ sessionStoreStateStaged,
+ filteredMockSessionData,
+ "sessionstore.jsonlz4 in the staging folder matches the recorded session state"
+ );
+
+ await maybeRemovePath(stagingPath);
+ await maybeRemovePath(sourcePath);
+
+ sandbox.restore();
+});
+
+/**
+ * Minor test that the recover method correctly copies the session store from
+ * the recovery directory into the destination profile directory.
+ */
+add_task(async function test_recover() {
+ let sessionStoreBackupResource = new SessionStoreBackupResource(
+ mockSessionStore
+ );
+ let recoveryPath = await IOUtils.createUniqueDirectory(
+ PathUtils.tempDir,
+ "SessionStoreBackupResource-recover"
+ );
+ let destProfilePath = await IOUtils.createUniqueDirectory(
+ PathUtils.tempDir,
+ "SessionStoreBackupResource-restored-profile"
+ );
+
+ // We backup a copy of sessionstore.jsonlz4, so ensure it exists in the recovery path
+ let sessionStoreBackupPath = PathUtils.join(
+ recoveryPath,
+ "sessionstore.jsonlz4"
+ );
+ await IOUtils.writeJSON(sessionStoreBackupPath, filteredMockSessionData, {
+ compress: true,
+ });
+
+ // The backup method is expected to have returned a null ManifestEntry
+ let postRecoveryEntry = await sessionStoreBackupResource.recover(
+ null /* manifestEntry */,
+ recoveryPath,
+ destProfilePath
+ );
+ Assert.equal(
+ postRecoveryEntry,
+ null,
+ "SessionStoreBackupResource.recover should return null as its post recovery entry"
+ );
+
+ await assertFilesExist(destProfilePath, [{ path: "sessionstore.jsonlz4" }]);
+
+ let sessionStateCopied = await IOUtils.readJSON(
+ PathUtils.join(destProfilePath, "sessionstore.jsonlz4"),
+ { decompress: true }
+ );
+
+ Assert.deepEqual(
+ sessionStateCopied,
+ filteredMockSessionData,
+ "sessionstore.jsonlz4 in the destination profile folder matches the backed up session state"
+ );
+
+ await maybeRemovePath(recoveryPath);
+ await maybeRemovePath(destProfilePath);
+});
diff --git a/browser/components/backup/tests/xpcshell/xpcshell.toml b/browser/components/backup/tests/xpcshell/xpcshell.toml
@@ -83,4 +83,6 @@ run-sequentially = ["true"] # Mock Windows registry interferes with normal opera
["test_SessionStoreBackupResource.js"]
+["test_SessionStoreBackupResource_mockSessionStore.js"]
+
["test_backupService_findBackupsInWellKnownLocations.js"]