Files
tubestation/browser/components/backup/resources/BackupResource.sys.mjs
Mike Conley 782c9958bd Bug 1886614 - Write a marionette test for the BackupService.createBackup method. r=backup-reviewers,kpatenio
This test can be run via:

./mach marionette-test browser/components/backup/test/marionette/test_backup.py

This also makes it so that BackupResource.copySqliteDatabases does not throw if
one of the passed in database files doesn't exist - it'll just ignore it and
move on. This required me to update some tests in order to create some fake
SQLite databases to ensure that the fake Sqlite connection was made and the
backup stub was called for the SQLite databases being copied.

Finally, this also fixes something I noticed - some of our BackupResource's
weren't returning null or objects as their ManifestEntry. This fixes that
and adds tests to cover that case.

Differential Revision: https://phabricator.services.mozilla.com/D207811
2024-04-23 02:54:49 +00:00

275 lines
8.5 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",
});
// 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 Error("BackupResource::key needs to be overridden.");
}
/**
* 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 Error(
"BackupResource::requiresEncryption needs to be overridden."
);
}
/**
* 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 Error("BackupResource::measure needs to be overridden.");
}
/**
* Perform a safe copy of the resource(s) 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.
*
* @returns {Promise<object|null>}
*/
// eslint-disable-next-line no-unused-vars
async backup(stagingPath, profilePath = null) {
throw new Error("BackupResource::backup must be overridden");
}
}
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
);