Files
tubestation/browser/components/backup/resources/BackupResource.sys.mjs
Stephen Thompson ad5c1d8ebf Bug 1906169 - Error plumbing for profile backup web worker r=backup-reviewers,mconley
Some of the Firefox profile backup code is executed within a web worker, so errors thrown in the worker do not automatically maintain their full context when caught in the main process.

This change creates a BackupError for throwing errors with causes specific to Firefox profile backup. The new error type is configured to work with the PromiseWorker machinery in the Firefox codebase in order to serialize and deserialize error details across the worker boundary.

Differential Revision: https://phabricator.services.mozilla.com/D215920
2024-07-08 17:49:45 +00:00

348 lines
12 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
BackupError: "resource:///modules/backup/BackupError.mjs",
ERRORS: "resource:///modules/backup/BackupConstants.mjs",
});
// Convert from bytes to kilobytes (not kibibytes).
export const BYTES_IN_KB = 1000;
/**
* Convert bytes to the nearest 10th kilobyte to make the measurements fuzzier.
*
* @param {number} bytes - size in bytes.
* @returns {number} - size in kilobytes rounded to the nearest 10th kilobyte.
*/
export function bytesToFuzzyKilobytes(bytes) {
let sizeInKb = Math.ceil(bytes / BYTES_IN_KB);
let nearestTenthKb = Math.round(sizeInKb / 10) * 10;
return Math.max(nearestTenthKb, 1);
}
/**
* An abstract class representing a set of data within a user profile
* that can be persisted to a separate backup archive file, and restored
* to a new user profile from that backup archive file.
*/
export class BackupResource {
/**
* This must be overridden to return a simple string identifier for the
* resource, for example "places" or "extensions". This key is used as
* a unique identifier for the resource.
*
* @type {string}
*/
static get key() {
throw new lazy.BackupError(
"BackupResource::key needs to be overridden.",
lazy.ERRORS.INTERNAL_ERROR
);
}
/**
* This must be overridden to return a boolean indicating whether the
* resource requires encryption when being backed up. Encryption should be
* required for particularly sensitive data, such as passwords / credentials,
* cookies, or payment methods. If you're not sure, talk to someone from the
* Privacy team.
*
* @type {boolean}
*/
static get requiresEncryption() {
throw new lazy.BackupError(
"BackupResource::requiresEncryption needs to be overridden.",
lazy.ERRORS.INTERNAL_ERROR
);
}
/**
* This can be overridden to return a number indicating the priority the
* resource should have in the backup order.
*
* Resources with a higher priority will be backed up first.
* The default priority of 0 indicates it can be processed in any order.
*
* @returns {number}
*/
static get priority() {
return 0;
}
/**
* Get the size of a file.
*
* @param {string} filePath - path to a file.
* @returns {Promise<number|null>} - the size of the file in kilobytes, or null if the
* file does not exist, the path is a directory or the size is unknown.
*/
static async getFileSize(filePath) {
if (!(await IOUtils.exists(filePath))) {
return null;
}
let { size } = await IOUtils.stat(filePath);
if (size < 0) {
return null;
}
let nearestTenthKb = bytesToFuzzyKilobytes(size);
return nearestTenthKb;
}
/**
* Get the total size of a directory.
*
* @param {string} directoryPath - path to a directory.
* @param {object} options - A set of additional optional parameters.
* @param {Function} [options.shouldExclude] - an optional callback which based on file path and file type should return true
* if the file should be excluded from the computed directory size.
* @returns {Promise<number|null>} - the size of all descendants of the directory in kilobytes, or null if the
* directory does not exist, the path is not a directory or the size is unknown.
*/
static async getDirectorySize(
directoryPath,
{ shouldExclude = () => false } = {}
) {
if (!(await IOUtils.exists(directoryPath))) {
return null;
}
let { type } = await IOUtils.stat(directoryPath);
if (type != "directory") {
return null;
}
let children = await IOUtils.getChildren(directoryPath, {
ignoreAbsent: true,
});
let size = 0;
for (const childFilePath of children) {
let { size: childSize, type: childType } = await IOUtils.stat(
childFilePath
);
if (shouldExclude(childFilePath, childType, directoryPath)) {
continue;
}
if (childSize >= 0) {
let nearestTenthKb = bytesToFuzzyKilobytes(childSize);
size += nearestTenthKb;
}
if (childType == "directory") {
let childDirectorySize = await this.getDirectorySize(childFilePath, {
shouldExclude,
});
if (Number.isInteger(childDirectorySize)) {
size += childDirectorySize;
}
}
}
return size;
}
/**
* Copy a set of SQLite databases safely from a source directory to a
* destination directory. A new read-only connection is opened for each
* database, and then a backup is created. If the source database does not
* exist, it is ignored.
*
* @param {string} sourcePath
* Path to the source directory of the SQLite databases.
* @param {string} destPath
* Path to the destination directory where the SQLite databases should be
* copied to.
* @param {Array<string>} sqliteDatabases
* An array of filenames of the SQLite databases to copy.
* @returns {Promise<undefined>}
*/
static async copySqliteDatabases(sourcePath, destPath, sqliteDatabases) {
for (let fileName of sqliteDatabases) {
let sourceFilePath = PathUtils.join(sourcePath, fileName);
if (!(await IOUtils.exists(sourceFilePath))) {
continue;
}
let destFilePath = PathUtils.join(destPath, fileName);
let connection;
try {
connection = await lazy.Sqlite.openConnection({
path: sourceFilePath,
readOnly: true,
});
await connection.backup(
destFilePath,
BackupResource.SQLITE_PAGES_PER_STEP,
BackupResource.SQLITE_STEP_DELAY_MS
);
} finally {
await connection?.close();
}
}
}
/**
* A helper function to copy a set of files from a source directory to a
* destination directory. Callers should ensure that the source files can be
* copied safely before invoking this function. Files that do not exist will
* be ignored. Callers that wish to copy SQLite databases should use
* copySqliteDatabases() instead.
*
* @param {string} sourcePath
* Path to the source directory of the files to be copied.
* @param {string} destPath
* Path to the destination directory where the files should be
* copied to.
* @param {string[]} fileNames
* An array of filenames of the files to copy.
* @returns {Promise<undefined>}
*/
static async copyFiles(sourcePath, destPath, fileNames) {
for (let fileName of fileNames) {
let sourceFilePath = PathUtils.join(sourcePath, fileName);
let destFilePath = PathUtils.join(destPath, fileName);
if (await IOUtils.exists(sourceFilePath)) {
await IOUtils.copy(sourceFilePath, destFilePath, { recursive: true });
}
}
}
constructor() {}
/**
* This must be overridden to record telemetry on the size of any
* data associated with this BackupResource.
*
* @param {string} profilePath - path to a profile directory.
* @returns {Promise<undefined>}
*/
// eslint-disable-next-line no-unused-vars
async measure(profilePath) {
throw new lazy.BackupError(
"BackupResource::measure needs to be overridden.",
lazy.ERRORS.INTERNAL_ERROR
);
}
/**
* Perform a safe copy of the datastores that this resource manages and write
* them into the backup database. The Promise should resolve with an object
* that can be serialized to JSON, as it will be written to the manifest file.
* This same object will be deserialized and passed to restore() when
* restoring the backup. This object can be null if no additional information
* is needed to restore the backup.
*
* @param {string} stagingPath
* The path to the staging folder where copies of the datastores for this
* BackupResource should be written to.
* @param {string} [profilePath=null]
* This is null if the backup is being run on the currently running user
* profile. If, however, the backup is being run on a different user profile
* (for example, it's being run from a BackgroundTask on a user profile that
* just shut down, or during test), then this is a string set to that user
* profile path.
* @param {boolean} [isEncrypting=false]
* True if the backup is being encrypted. A BackupResource may not require
* encryption, but might still choose to behave differently when encrypting,
* so this flag can be used to support that kind of behaviour.
*
* @returns {Promise<object|null>}
*/
// eslint-disable-next-line no-unused-vars
async backup(stagingPath, profilePath = null, isEncrypting = false) {
throw new lazy.BackupError(
"BackupResource::backup must be overridden",
lazy.ERRORS.INTERNAL_ERROR
);
}
/**
* Recovers the datastores that this resource manages from a backup archive
* that has been decompressed into the recoveryPath. A pre-existing unlocked
* user profile should be available to restore into, and destProfilePath
* should point at its location on the file system.
*
* This method is not expected to be running in an app connected to the
* destProfilePath. If the BackupResource needs to run some operations
* while attached to the recovery profile, it should do that work inside of
* postRecovery(). If data needs to be transferred to postRecovery(), it
* should be passed as a JSON serializable object in the return value of this
* method.
*
* @see BackupResource.postRecovery()
* @param {object|null} manifestEntry
* The object that was returned by the backup() method when the backup was
* created. This object can be null if no additional information was needed
* for recovery.
* @param {string} recoveryPath
* The path to the resource directory where the backup archive has been
* decompressed.
* @param {string} destProfilePath
* The path to the profile directory where the backup should be restored to.
* @returns {Promise<object|null>}
* This should return a JSON serializable object that will be passed to
* postRecovery() if any data needs to be passed to it. This object can be
* null if no additional information is needed for postRecovery().
*/
// eslint-disable-next-line no-unused-vars
async recover(manifestEntry, recoveryPath, destProfilePath) {
throw new lazy.BackupError(
"BackupResource::recover must be overridden",
lazy.ERRORS.INTERNAL_ERROR
);
}
/**
* Perform any post-recovery operations that need to be done after the
* recovery has been completed and the recovered profile has been attached
* to.
*
* This method is running in an app connected to the recovered profile. The
* profile is locked, but this postRecovery method can be used to insert
* data into connected datastores, or perform any other operations that can
* only occur within the context of the recovered profile.
*
* @see BackupResource.recover()
* @param {object|null} postRecoveryEntry
* The object that was returned by the recover() method when the recovery
* was originally done. This object can be null if no additional information
* is needed for post-recovery.
*/
// eslint-disable-next-line no-unused-vars
async postRecovery(postRecoveryEntry) {
// no-op by default
}
}
XPCOMUtils.defineLazyPreferenceGetter(
BackupResource,
"SQLITE_PAGES_PER_STEP",
"browser.backup.sqlite.pages_per_step",
5
);
XPCOMUtils.defineLazyPreferenceGetter(
BackupResource,
"SQLITE_STEP_DELAY_MS",
"browser.backup.sqlite.step_delay_ms",
250
);