tor-browser

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

BackupService.sys.mjs (168658B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 import * as DefaultBackupResources from "resource:///modules/backup/BackupResources.sys.mjs";
      6 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
      7 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      8 import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs";
      9 import {
     10  MeasurementUtils,
     11  BYTES_IN_KILOBYTE,
     12  BYTES_IN_MEGABYTE,
     13  BYTES_IN_MEBIBYTE,
     14 } from "resource:///modules/backup/MeasurementUtils.sys.mjs";
     15 
     16 import {
     17  ERRORS,
     18  STEPS,
     19  errorString,
     20 } from "chrome://browser/content/backup/backup-constants.mjs";
     21 import { BackupError } from "resource:///modules/backup/BackupError.mjs";
     22 
     23 const BACKUP_DIR_PREF_NAME = "browser.backup.location";
     24 const BACKUP_ERROR_CODE_PREF_NAME = "browser.backup.errorCode";
     25 const SCHEDULED_BACKUPS_ENABLED_PREF_NAME = "browser.backup.scheduled.enabled";
     26 const BACKUP_ARCHIVE_ENABLED_PREF_NAME = "browser.backup.archive.enabled";
     27 const BACKUP_ARCHIVE_ENABLED_OVERRIDE_PREF_NAME =
     28  "browser.backup.archive.overridePlatformCheck";
     29 const BACKUP_RESTORE_ENABLED_PREF_NAME = "browser.backup.restore.enabled";
     30 const BACKUP_RESTORE_ENABLED_OVERRIDE_PREF_NAME =
     31  "browser.backup.restore.overridePlatformCheck";
     32 const IDLE_THRESHOLD_SECONDS_PREF_NAME =
     33  "browser.backup.scheduled.idle-threshold-seconds";
     34 const MINIMUM_TIME_BETWEEN_BACKUPS_SECONDS_PREF_NAME =
     35  "browser.backup.scheduled.minimum-time-between-backups-seconds";
     36 const LAST_BACKUP_TIMESTAMP_PREF_NAME =
     37  "browser.backup.scheduled.last-backup-timestamp";
     38 const LAST_BACKUP_FILE_NAME_PREF_NAME =
     39  "browser.backup.scheduled.last-backup-file";
     40 const BACKUP_RETRY_LIMIT_PREF_NAME = "browser.backup.backup-retry-limit";
     41 const DISABLED_ON_IDLE_RETRY_PREF_NAME =
     42  "browser.backup.disabled-on-idle-backup-retry";
     43 const BACKUP_DEBUG_INFO_PREF_NAME = "browser.backup.backup-debug-info";
     44 const MAXIMUM_NUMBER_OF_UNREMOVABLE_STAGING_ITEMS_PREF_NAME =
     45  "browser.backup.max-num-unremovable-staging-items";
     46 const CREATED_MANAGED_PROFILES_PREF_NAME = "browser.profiles.created";
     47 const RESTORED_BACKUP_METADATA_PREF_NAME =
     48  "browser.backup.restored-backup-metadata";
     49 const SANITIZE_ON_SHUTDOWN_PREF_NAME = "privacy.sanitize.sanitizeOnShutdown";
     50 
     51 const SCHEMAS = Object.freeze({
     52  BACKUP_MANIFEST: 1,
     53  ARCHIVE_JSON_BLOCK: 2,
     54 });
     55 
     56 const lazy = {};
     57 
     58 ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
     59  return console.createInstance({
     60    prefix: "BackupService",
     61    maxLogLevel: Services.prefs.getBoolPref("browser.backup.log", false)
     62      ? "Debug"
     63      : "Warn",
     64  });
     65 });
     66 
     67 ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
     68  return ChromeUtils.importESModule(
     69    "resource://gre/modules/FxAccounts.sys.mjs"
     70  ).getFxAccountsSingleton();
     71 });
     72 
     73 ChromeUtils.defineESModuleGetters(lazy, {
     74  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
     75  ArchiveDecryptor: "resource:///modules/backup/ArchiveEncryption.sys.mjs",
     76  ArchiveEncryptionState:
     77    "resource:///modules/backup/ArchiveEncryptionState.sys.mjs",
     78  ArchiveUtils: "resource:///modules/backup/ArchiveUtils.sys.mjs",
     79  BasePromiseWorker: "resource://gre/modules/PromiseWorker.sys.mjs",
     80  ClientID: "resource://gre/modules/ClientID.sys.mjs",
     81  DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
     82  DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
     83  FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
     84  JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
     85  NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
     86  NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
     87  SelectableProfileService:
     88    "resource:///modules/profiles/SelectableProfileService.sys.mjs",
     89  UIState: "resource://services-sync/UIState.sys.mjs",
     90 });
     91 
     92 ChromeUtils.defineLazyGetter(lazy, "ZipWriter", () =>
     93  Components.Constructor("@mozilla.org/zipwriter;1", "nsIZipWriter", "open")
     94 );
     95 ChromeUtils.defineLazyGetter(lazy, "ZipReader", () =>
     96  Components.Constructor(
     97    "@mozilla.org/libjar/zip-reader;1",
     98    "nsIZipReader",
     99    "open"
    100  )
    101 );
    102 ChromeUtils.defineLazyGetter(lazy, "nsLocalFile", () =>
    103  Components.Constructor("@mozilla.org/file/local;1", "nsIFile", "initWithPath")
    104 );
    105 
    106 ChromeUtils.defineLazyGetter(lazy, "BinaryInputStream", () =>
    107  Components.Constructor(
    108    "@mozilla.org/binaryinputstream;1",
    109    "nsIBinaryInputStream",
    110    "setInputStream"
    111  )
    112 );
    113 
    114 ChromeUtils.defineLazyGetter(lazy, "gFluentStrings", function () {
    115  return new Localization(
    116    ["branding/brand.ftl", "browser/backupSettings.ftl"],
    117    true
    118  );
    119 });
    120 
    121 ChromeUtils.defineLazyGetter(lazy, "gDOMLocalization", function () {
    122  return new DOMLocalization([
    123    "branding/brand.ftl",
    124    "browser/backupSettings.ftl",
    125  ]);
    126 });
    127 
    128 XPCOMUtils.defineLazyPreferenceGetter(
    129  lazy,
    130  "scheduledBackupsPref",
    131  SCHEDULED_BACKUPS_ENABLED_PREF_NAME,
    132  false,
    133  function onUpdateScheduledBackups(_pref, _prevVal, newVal) {
    134    let bs = BackupService.get();
    135    if (bs) {
    136      bs.onUpdateScheduledBackups(newVal);
    137    }
    138  }
    139 );
    140 
    141 XPCOMUtils.defineLazyPreferenceGetter(
    142  lazy,
    143  "backupDirPref",
    144  BACKUP_DIR_PREF_NAME,
    145  /**
    146   * To avoid disk access upon startup, do not set DEFAULT_PARENT_DIR_PATH
    147   * as a fallback value here. Let registered widgets prompt BackupService
    148   * to update the parentDirPath.
    149   *
    150   * @see BackupService.state
    151   * @see DEFAULT_PARENT_DIR_PATH
    152   * @see setParentDirPath
    153   */
    154  null,
    155  async function onUpdateLocationDirPath(_pref, _prevVal, newVal) {
    156    let bs;
    157    try {
    158      bs = BackupService.get();
    159    } catch (e) {
    160      // This can throw if the BackupService hasn't initialized yet, which
    161      // is a case we're okay to ignore.
    162    }
    163    if (bs) {
    164      await bs.onUpdateLocationDirPath(newVal);
    165    }
    166  }
    167 );
    168 
    169 XPCOMUtils.defineLazyPreferenceGetter(
    170  lazy,
    171  "minimumTimeBetweenBackupsSeconds",
    172  MINIMUM_TIME_BETWEEN_BACKUPS_SECONDS_PREF_NAME,
    173  86400 /* 1 day */
    174 );
    175 
    176 XPCOMUtils.defineLazyPreferenceGetter(
    177  lazy,
    178  "backupRetryLimit",
    179  BACKUP_RETRY_LIMIT_PREF_NAME,
    180  100
    181 );
    182 
    183 XPCOMUtils.defineLazyPreferenceGetter(
    184  lazy,
    185  "isRetryDisabledOnIdle",
    186  DISABLED_ON_IDLE_RETRY_PREF_NAME,
    187  false
    188 );
    189 
    190 XPCOMUtils.defineLazyPreferenceGetter(
    191  lazy,
    192  "maximumNumberOfUnremovableStagingItems",
    193  MAXIMUM_NUMBER_OF_UNREMOVABLE_STAGING_ITEMS_PREF_NAME,
    194  5
    195 );
    196 
    197 XPCOMUtils.defineLazyPreferenceGetter(
    198  lazy,
    199  "backupErrorCode",
    200  BACKUP_ERROR_CODE_PREF_NAME,
    201  0,
    202  function onUpdateBackupErrorCode(_pref, _prevVal, newVal) {
    203    let bs = BackupService.get();
    204    if (bs) {
    205      bs.onUpdateBackupErrorCode(newVal);
    206    }
    207  }
    208 );
    209 
    210 XPCOMUtils.defineLazyPreferenceGetter(
    211  lazy,
    212  "lastBackupFileName",
    213  LAST_BACKUP_FILE_NAME_PREF_NAME,
    214  "",
    215  function onUpdateLastBackupFileName(_pref, _prevVal, newVal) {
    216    let bs;
    217    try {
    218      bs = BackupService.get();
    219    } catch (e) {
    220      // This can throw if the BackupService hasn't initialized yet, which
    221      // is a case we're okay to ignore.
    222    }
    223    if (bs) {
    224      bs.onUpdateLastBackupFileName(newVal);
    225    }
    226  }
    227 );
    228 
    229 XPCOMUtils.defineLazyServiceGetter(
    230  lazy,
    231  "idleService",
    232  "@mozilla.org/widget/useridleservice;1",
    233  Ci.nsIUserIdleService
    234 );
    235 
    236 XPCOMUtils.defineLazyServiceGetter(
    237  lazy,
    238  "nativeOSKeyStore",
    239  "@mozilla.org/security/oskeystore;1",
    240  Ci.nsIOSKeyStore
    241 );
    242 
    243 /**
    244 * A class that wraps a multipart/mixed stream converter instance, and streams
    245 * in the binary part of a single-file archive (which should be at the second
    246 * index of the attachments) as a ReadableStream.
    247 *
    248 * The bytes that are read in are text decoded, but are not guaranteed to
    249 * represent a "full chunk" of base64 data. Consumers should ensure to buffer
    250 * the strings emitted by this stream, and to search for `\n` characters, which
    251 * indicate the end of a (potentially encrypted and) base64 encoded block.
    252 */
    253 class BinaryReadableStream {
    254  #channel = null;
    255 
    256  /**
    257   * Constructs a BinaryReadableStream.
    258   *
    259   * @param {nsIChannel} channel
    260   *   The channel through which to begin the flow of bytes from the
    261   *   inputStream
    262   */
    263  constructor(channel) {
    264    this.#channel = channel;
    265  }
    266 
    267  /**
    268   * Implements `start` from the `underlyingSource` of a ReadableStream
    269   *
    270   * @param {ReadableStreamDefaultController} controller
    271   *   The controller for the ReadableStream to feed strings into.
    272   */
    273  start(controller) {
    274    let streamConv = Cc["@mozilla.org/streamConverters;1"].getService(
    275      Ci.nsIStreamConverterService
    276    );
    277 
    278    let textDecoder = new TextDecoder();
    279 
    280    // The attachment index that should contain the binary data.
    281    const EXPECTED_CONTENT_TYPE = "application/octet-stream";
    282 
    283    // This is fairly clumsy, but by using an object nsIStreamListener like
    284    // this, I can keep from stashing the `controller` somewhere, as it's
    285    // available in the closure.
    286    let multipartListenerForBinary = {
    287      /**
    288       * True once we've found an attachment matching our EXPECTED_CONTENT_TYPE.
    289       * Once this is true, bytes flowing into onDataAvailable will be
    290       * enqueued through the controller.
    291       *
    292       * @type {boolean}
    293       */
    294      _enabled: false,
    295 
    296      /**
    297       * True once onStopRequest has been called once the listener is enabled.
    298       * After this, the listener will not attempt to read any data passed
    299       * to it through onDataAvailable.
    300       *
    301       * @type {boolean}
    302       */
    303      _done: false,
    304 
    305      QueryInterface: ChromeUtils.generateQI([
    306        "nsIStreamListener",
    307        "nsIRequestObserver",
    308        "nsIMultiPartChannelListener",
    309      ]),
    310 
    311      /**
    312       * Called when we begin to load an attachment from the MIME message.
    313       *
    314       * @param {nsIRequest} request
    315       *   The request corresponding to the source of the data.
    316       */
    317      onStartRequest(request) {
    318        if (!(request instanceof Ci.nsIChannel)) {
    319          throw Components.Exception(
    320            "onStartRequest expected an nsIChannel request",
    321            Cr.NS_ERROR_UNEXPECTED
    322          );
    323        }
    324        this._enabled = request.contentType == EXPECTED_CONTENT_TYPE;
    325      },
    326 
    327      /**
    328       * Called when data is flowing in for an attachment.
    329       *
    330       * @param {nsIRequest} request
    331       *   The request corresponding to the source of the data.
    332       * @param {nsIInputStream} stream
    333       *   The input stream containing the data chunk.
    334       * @param {number} offset
    335       *   The number of bytes that were sent in previous onDataAvailable calls
    336       *   for this request. In other words, the sum of all previous count
    337       *   parameters.
    338       * @param {number} count
    339       *   The number of bytes available in the stream
    340       */
    341      onDataAvailable(request, stream, offset, count) {
    342        if (this._done) {
    343          // No need to load anything else - abort reading in more
    344          // attachments.
    345          throw Components.Exception(
    346            "Got binary block - cancelling loading the multipart stream.",
    347            Cr.NS_BINDING_ABORTED
    348          );
    349        }
    350        if (!this._enabled) {
    351          // We don't care about this data, just move on.
    352          return;
    353        }
    354 
    355        let binStream = new lazy.BinaryInputStream(stream);
    356        let bytes = new Uint8Array(count);
    357        binStream.readArrayBuffer(count, bytes.buffer);
    358        let string = textDecoder.decode(bytes);
    359        controller.enqueue(string);
    360      },
    361 
    362      /**
    363       * Called when the load of an attachment finishes.
    364       */
    365      onStopRequest() {
    366        if (this._enabled && !this._done) {
    367          this._enabled = false;
    368          this._done = true;
    369 
    370          controller.close();
    371        }
    372      },
    373 
    374      onAfterLastPart() {
    375        if (!this._done) {
    376          // We finished reading the parts before we found the binary block,
    377          // so the binary block is missing.
    378          controller.error(
    379            new BackupError(
    380              "Could not find binary block.",
    381              ERRORS.CORRUPTED_ARCHIVE
    382            )
    383          );
    384        }
    385      },
    386    };
    387 
    388    let conv = streamConv.asyncConvertData(
    389      "multipart/mixed",
    390      "*/*",
    391      multipartListenerForBinary,
    392      null
    393    );
    394 
    395    this.#channel.asyncOpen(conv);
    396  }
    397 }
    398 
    399 /**
    400 * A TransformStream class that takes in chunks of base64 encoded data,
    401 * decodes (and eventually, decrypts) them before passing the resulting
    402 * bytes along to the next step in the pipe.
    403 *
    404 * The BinaryReadableStream feeds strings into this TransformStream, but the
    405 * buffering of these streams means that we cannot be certain that the string
    406 * that was passed is the entirety of a base64 encoded block. ArchiveWorker
    407 * puts every block on its own line, meaning that we must simply look for
    408 * newlines to indicate when a break between full blocks is, and buffer chunks
    409 * until we see those breaks - only decoding once we have a full block.
    410 */
    411 export class DecoderDecryptorTransformer {
    412  #buffer = "";
    413  #decryptor = null;
    414 
    415  /**
    416   * Constructs the DecoderDecryptorTransformer.
    417   *
    418   * @param {ArchiveDecryptor|null} decryptor
    419   *   An initialized ArchiveDecryptor, if this stream of bytes is presumed to
    420   *   be encrypted.
    421   */
    422  constructor(decryptor) {
    423    this.#decryptor = decryptor;
    424  }
    425 
    426  /**
    427   * Consumes a single chunk of a base64 encoded string sent by
    428   * BinaryReadableStream.
    429   *
    430   * @param {string} chunkPart
    431   *   A part of a chunk of a base64 encoded string sent by
    432   *   BinaryReadableStream.
    433   * @param {TransformStreamDefaultController} controller
    434   *   The controller to send decoded bytes to.
    435   * @returns {Promise<undefined>}
    436   */
    437  async transform(chunkPart, controller) {
    438    // A small optimization, but considering the size of these strings, it's
    439    // likely worth it.
    440    if (this.#buffer) {
    441      this.#buffer += chunkPart;
    442    } else {
    443      this.#buffer = chunkPart;
    444    }
    445 
    446    // If the compressed archive was large enough, then it got split up over
    447    // several chunks. In that case, each chunk is separated by a newline. We
    448    // also filter out any extraneous newlines that might have been included
    449    // at the end.
    450    let chunks = this.#buffer.split("\n").filter(chunk => chunk != "");
    451 
    452    this.#buffer = chunks.pop();
    453    // If there were any remaining parts that we split out from the buffer,
    454    // they must constitute full blocks that we can decode.
    455    for (let chunk of chunks) {
    456      await this.#processChunk(controller, chunk);
    457    }
    458  }
    459 
    460  /**
    461   * Called once BinaryReadableStream signals that it has sent all of its
    462   * strings, in which case we know that whatever is in the buffer should be
    463   * a valid block.
    464   *
    465   * @param {TransformStreamDefaultController} controller
    466   *   The controller to send decoded bytes to.
    467   * @returns {Promise<undefined>}
    468   */
    469  async flush(controller) {
    470    await this.#processChunk(controller, this.#buffer, true);
    471    this.#buffer = "";
    472  }
    473 
    474  /**
    475   * Decodes (and potentially decrypts) a valid base64 encoded chunk into a
    476   * Uint8Array and sends it to the next step in the pipe.
    477   *
    478   * @param {TransformStreamDefaultController} controller
    479   *   The controller to send decoded bytes to.
    480   * @param {string} chunk
    481   *   The base64 encoded string to decode and potentially decrypt.
    482   * @param {boolean} [isLastChunk=false]
    483   *   True if this is the last chunk to be processed.
    484   * @returns {Promise<undefined>}
    485   */
    486  async #processChunk(controller, chunk, isLastChunk = false) {
    487    try {
    488      let bytes = lazy.ArchiveUtils.stringToArray(chunk);
    489 
    490      if (this.#decryptor) {
    491        let plaintextBytes = await this.#decryptor.decrypt(bytes, isLastChunk);
    492        controller.enqueue(plaintextBytes);
    493      } else {
    494        controller.enqueue(bytes);
    495      }
    496    } catch (e) {
    497      // Something went wrong base64 decoding or decrypting. Tell the controller
    498      // that we're done, so that it can destroy anything that was decoded /
    499      // decrypted already.
    500      controller.error("Corrupted archive.");
    501    }
    502  }
    503 }
    504 
    505 /**
    506 * A class that lets us construct a WritableStream that writes bytes to a file
    507 * on disk somewhere.
    508 */
    509 export class FileWriterStream {
    510  /**
    511   * @type {string}
    512   */
    513  #destPath = null;
    514 
    515  /**
    516   * @type {nsIOutputStream}
    517   */
    518  #outStream = null;
    519 
    520  /**
    521   * @type {nsIBinaryOutputStream}
    522   */
    523  #binStream = null;
    524 
    525  /**
    526   * @type {ArchiveDecryptor}
    527   */
    528  #decryptor = null;
    529 
    530  /**
    531   * Constructor for FileWriterStream.
    532   *
    533   * @param {string} destPath
    534   *   The path to write the incoming bytes to.
    535   * @param {ArchiveDecryptor|null} decryptor
    536   *   An initialized ArchiveDecryptor, if this stream of bytes is presumed to
    537   *   be encrypted.
    538   */
    539  constructor(destPath, decryptor) {
    540    this.#destPath = destPath;
    541    this.#decryptor = decryptor;
    542  }
    543 
    544  /**
    545   * Called once the first set of bytes comes in from the
    546   * DecoderDecryptorTransformer. This creates the file, and sets up the
    547   * underlying nsIOutputStream mechanisms to let us write bytes to the file.
    548   */
    549  async start() {
    550    let extractionDestFile = await IOUtils.getFile(this.#destPath);
    551    this.#outStream =
    552      lazy.FileUtils.openSafeFileOutputStream(extractionDestFile);
    553    this.#binStream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance(
    554      Ci.nsIBinaryOutputStream
    555    );
    556    this.#binStream.setOutputStream(this.#outStream);
    557  }
    558 
    559  /**
    560   * Writes bytes to the destination on the file system.
    561   *
    562   * @param {Uint8Array} chunk
    563   *   The bytes to stream to the destination file.
    564   */
    565  write(chunk) {
    566    this.#binStream.writeByteArray(chunk);
    567  }
    568 
    569  /**
    570   * Called once the stream of bytes finishes flowing in and closes the stream.
    571   *
    572   * @param {WritableStreamDefaultController} controller
    573   *   The controller for the WritableStream.
    574   */
    575  close(controller) {
    576    lazy.FileUtils.closeSafeFileOutputStream(this.#outStream);
    577    if (this.#decryptor && !this.#decryptor.isDone()) {
    578      lazy.logConsole.error(
    579        "Decryptor was not done when the stream was closed."
    580      );
    581      controller.error("Corrupted archive.");
    582    }
    583  }
    584 
    585  /**
    586   * Called if something went wrong while decoding / decrypting the stream of
    587   * bytes. This destroys any bytes that may have been decoded / decrypted
    588   * prior to the error.
    589   *
    590   * @param {string} reason
    591   *   The reported reason for aborting the decoding / decrpytion.
    592   */
    593  async abort(reason) {
    594    lazy.logConsole.error(`Writing to ${this.#destPath} failed: `, reason);
    595    lazy.FileUtils.closeSafeFileOutputStream(this.#outStream);
    596    await IOUtils.remove(this.#destPath, {
    597      ignoreAbsent: true,
    598      retryReadonly: true,
    599    });
    600  }
    601 }
    602 
    603 /**
    604 * The BackupService class orchestrates the scheduling and creation of profile
    605 * backups. It also does most of the heavy lifting for the restoration of a
    606 * profile backup.
    607 */
    608 export class BackupService extends EventTarget {
    609  /**
    610   * The BackupService singleton instance.
    611   *
    612   * @static
    613   * @type {BackupService|null}
    614   */
    615  static #instance = null;
    616 
    617  /**
    618   * Map of instantiated BackupResource classes.
    619   *
    620   * @type {Map<string, BackupResource>}
    621   */
    622  #resources = new Map();
    623 
    624  /**
    625   * The name of the backup folder. Should be localized.
    626   *
    627   * @see BACKUP_DIR_NAME
    628   */
    629  static #backupFolderName = "Restore Firefox";
    630 
    631  /**
    632   * The name of the backup archive file. Should be localized.
    633   *
    634   * @see BACKUP_FILE_NAME
    635   */
    636  static #backupFileName = null;
    637 
    638  /**
    639   * Number of retries that have occured in this session on error
    640   */
    641  static #errorRetries = 0;
    642 
    643  /**
    644   * Time to wait (in seconds) until the next backup attempt.
    645   *
    646   * This uses exponential backoff based on the number of consecutive
    647   * failed backup attempts since the last successful backup.
    648   *
    649   * Backoff formula:
    650   *   2^(retryCount) * 60
    651   *
    652   * Example:
    653   *   If 2 backup attempts have failed since the last successful backup,
    654   *   the next attempt will occur after:
    655   *
    656   *     2^2 * 60 = 240 seconds (4 minutes)
    657   *
    658   * This differs from minimumTimeBetweenBackupsSeconds, which is used to determine
    659   * the time between successful backups.
    660   */
    661  static backoffSeconds = () => Math.pow(2, BackupService.#errorRetries) * 60;
    662 
    663  /**
    664   * @typedef {object} EnabledStatus
    665   * @property {boolean} enabled
    666   *   True if the feature is enabled.
    667   * @property {string} [reason]
    668   *   Reason the feature is disabled if `enabled` is false.
    669   */
    670 
    671  /**
    672   * Context for whether creating a backup archive is enabled.
    673   *
    674   * @type {EnabledStatus}
    675   */
    676  get archiveEnabledStatus() {
    677    // Check if disabled by Nimbus killswitch.
    678    const archiveKillswitchTriggered =
    679      lazy.NimbusFeatures.backupService.getVariable("archiveKillswitch");
    680    const archiveOverrideEnabled = Services.prefs.getBoolPref(
    681      BACKUP_ARCHIVE_ENABLED_OVERRIDE_PREF_NAME,
    682      false
    683    );
    684    // Only disable feature if archiveKillswitch is true.
    685    if (archiveKillswitchTriggered && !archiveOverrideEnabled) {
    686      return {
    687        enabled: false,
    688        reason: "Archiving a profile disabled remotely.",
    689        internalReason: "nimbus",
    690      };
    691    }
    692 
    693    if (!Services.prefs.getBoolPref(BACKUP_ARCHIVE_ENABLED_PREF_NAME)) {
    694      if (Services.prefs.prefIsLocked(BACKUP_ARCHIVE_ENABLED_PREF_NAME)) {
    695        // If it's locked, assume it was set by an enterprise policy.
    696        return {
    697          enabled: false,
    698          reason: "Archiving a profile disabled by policy.",
    699          internalReason: "policy",
    700        };
    701      }
    702 
    703      return {
    704        enabled: false,
    705        reason: "Archiving a profile disabled by user pref.",
    706        internalReason: "pref",
    707      };
    708    }
    709 
    710    if (lazy.SelectableProfileService.hasCreatedSelectableProfiles()) {
    711      return {
    712        enabled: false,
    713        reason:
    714          "Archiving a profile is disabled because the user has created selectable profiles.",
    715        internalReason: "selectable profiles",
    716      };
    717    }
    718 
    719    if (
    720      !this.#osSupportsBackup &&
    721      !Services.prefs.getBoolPref(
    722        BACKUP_ARCHIVE_ENABLED_OVERRIDE_PREF_NAME,
    723        false
    724      )
    725    ) {
    726      return {
    727        enabled: false,
    728        reason: "Backup creation not enabled on this os version yet",
    729        internalReason: "os version",
    730      };
    731    }
    732 
    733    return { enabled: true };
    734  }
    735 
    736  /**
    737   * Context for whether restore from backup is enabled.
    738   *
    739   * @type {EnabledStatus}
    740   */
    741  get restoreEnabledStatus() {
    742    // Check if disabled by Nimbus killswitch.
    743    const restoreKillswitchTriggered =
    744      lazy.NimbusFeatures.backupService.getVariable("restoreKillswitch");
    745    const restoreOverrideEnabled = Services.prefs.getBoolPref(
    746      BACKUP_RESTORE_ENABLED_OVERRIDE_PREF_NAME,
    747      false
    748    );
    749 
    750    if (restoreKillswitchTriggered && !restoreOverrideEnabled) {
    751      return {
    752        enabled: false,
    753        reason: "Restore from backup disabled remotely.",
    754        internalReason: "nimbus",
    755      };
    756    }
    757 
    758    if (!Services.prefs.getBoolPref(BACKUP_RESTORE_ENABLED_PREF_NAME)) {
    759      if (Services.prefs.prefIsLocked(BACKUP_RESTORE_ENABLED_PREF_NAME)) {
    760        // If it's locked, assume it was set by an enterprise policy.
    761        return {
    762          enabled: false,
    763          reason: "Restoring a profile disabled by policy.",
    764          internalReason: "policy",
    765        };
    766      }
    767 
    768      return {
    769        enabled: false,
    770        reason: "Restoring a profile disabled by user pref.",
    771        internalReason: "pref",
    772      };
    773    }
    774 
    775    if (lazy.SelectableProfileService.hasCreatedSelectableProfiles()) {
    776      return {
    777        enabled: false,
    778        reason:
    779          "Restoring a profile is disabled because the user has created selectable profiles.",
    780        internalReason: "selectable profiles",
    781      };
    782    }
    783    if (
    784      !this.#osSupportsRestore &&
    785      !Services.prefs.getBoolPref(
    786        BACKUP_RESTORE_ENABLED_OVERRIDE_PREF_NAME,
    787        false
    788      )
    789    ) {
    790      return {
    791        enabled: false,
    792        reason: "Backup restore not enabled on this os version yet",
    793        internalReason: "os version",
    794      };
    795    }
    796 
    797    return { enabled: true };
    798  }
    799 
    800  /**
    801   * Set to true if a backup is currently in progress. Causes stateUpdate()
    802   * to be called.
    803   *
    804   * @see BackupService.stateUpdate()
    805   * @param {boolean} val
    806   *   True if a backup is in progress.
    807   */
    808  set #backupInProgress(val) {
    809    if (this.#_state.backupInProgress != val) {
    810      this.#_state.backupInProgress = val;
    811      this.stateUpdate();
    812    }
    813  }
    814 
    815  /**
    816   * True if a backup is currently in progress.
    817   *
    818   * @type {boolean}
    819   */
    820  get #backupInProgress() {
    821    return this.#_state.backupInProgress;
    822  }
    823 
    824  /**
    825   * Dispatches an event to let listeners know that the BackupService state
    826   * object has been updated.
    827   */
    828  stateUpdate() {
    829    this.dispatchEvent(new CustomEvent("BackupService:StateUpdate"));
    830  }
    831 
    832  /**
    833   * Sets the recovery error code and updates the state.
    834   *
    835   * @param {number} errorCode - The error code to set
    836   */
    837  setRecoveryError(errorCode) {
    838    this.#_state.recoveryErrorCode = errorCode;
    839    this.stateUpdate();
    840  }
    841 
    842  /**
    843   * Sets the persisted options between screens for embedded components.
    844   * This is specifically used in the Spotlight onboarding experience.
    845   *
    846   * This data is flushed upon creating a backup or exiting the backup flow.
    847   *
    848   * @param {object} data - data to persist between screens.
    849   */
    850  setEmbeddedComponentPersistentData(data) {
    851    this.#_state.embeddedComponentPersistentData = { ...data };
    852    this.stateUpdate();
    853  }
    854 
    855  /**
    856   * An object holding the current state of the BackupService instance, for
    857   * the purposes of representing it in the user interface. Ideally, this would
    858   * be named #state instead of #_state, but sphinx-js seems to be fairly
    859   * unhappy with that coupled with the ``state`` getter.
    860   *
    861   * @type {object}
    862   */
    863  #_state = {
    864    backupDirPath: lazy.backupDirPref,
    865    defaultParent: {},
    866    backupFileToRestore: null,
    867    backupFileInfo: null,
    868    backupInProgress: false,
    869    scheduledBackupsEnabled: lazy.scheduledBackupsPref,
    870    encryptionEnabled: false,
    871    /** @type {number?} Number of seconds since UNIX epoch */
    872    lastBackupDate: null,
    873    lastBackupFileName: lazy.lastBackupFileName,
    874    supportBaseLink: Services.urlFormatter.formatURLPref("app.support.baseURL"),
    875    recoveryInProgress: false,
    876    /**
    877     * Every file we load successfully is going to get a restore ID which is
    878     * basically the identifier for that profile restore event. If we actually
    879     * do restore it, this ID will end up being propagated into the restored
    880     * file and used to correlate this restore event with the profile that was
    881     * restored.
    882     */
    883    restoreID: null,
    884    /** Utilized by the spotlight to persist information between screens */
    885    embeddedComponentPersistentData: {},
    886    recoveryErrorCode: ERRORS.NONE,
    887    backupErrorCode: lazy.backupErrorCode,
    888  };
    889 
    890  /**
    891   * A Promise that will resolve once the postRecovery steps are done. It will
    892   * also resolve if postRecovery steps didn't need to run.
    893   *
    894   * @see BackupService.checkForPostRecovery()
    895   * @type {Promise<undefined>}
    896   */
    897  #postRecoveryPromise;
    898 
    899  /**
    900   * The resolving function for #postRecoveryPromise, which should be called
    901   * by checkForPostRecovery() before exiting.
    902   *
    903   * @type {Function}
    904   */
    905  #postRecoveryResolver;
    906 
    907  /**
    908   * The currently used ArchiveEncryptionState. Callers should use
    909   * loadEncryptionState() instead, to ensure that any pre-serialized
    910   * encryption state has been read in and deserialized.
    911   *
    912   * This member can be in 3 states:
    913   *
    914   * 1. undefined - no attempt has been made to load encryption state from
    915   *    disk yet.
    916   * 2. null - encryption is not enabled.
    917   * 3. ArchiveEncryptionState - encryption is enabled.
    918   *
    919   * @see BackupService.loadEncryptionState()
    920   * @type {ArchiveEncryptionState|null|undefined}
    921   */
    922  #encState = undefined;
    923 
    924  /**
    925   * The PlacesObserver instance used to monitor the Places database for
    926   * history and bookmark removals to determine if backups should be
    927   * regenerated.
    928   *
    929   * @type {PlacesObserver|null}
    930   */
    931  #placesObserver = null;
    932 
    933  /**
    934   * The AbortController used to abort any queued requests to create or delete
    935   * backups that might be waiting on the WRITE_BACKUP_LOCK_NAME lock.
    936   *
    937   * @type {AbortController}
    938   */
    939  #backupWriteAbortController = null;
    940 
    941  /**
    942   * A DeferredTask that will cause the last known backup to be deleted, and
    943   * a new backup to be created.
    944   *
    945   * See BackupService.#debounceRegeneration()
    946   *
    947   * @type {DeferredTask}
    948   */
    949  #regenerationDebouncer = null;
    950 
    951  /**
    952   * True if takeMeasurements has been called and various measurements related
    953   * to the BackupService have been taken.
    954   *
    955   * @type {boolean}
    956   */
    957  #takenMeasurements = false;
    958 
    959  /**
    960   * Stores whether backing up has been disabled at some point during this
    961   * session. If it has been, the archiveDisabledReason telemetry metric is set
    962   * on each backup. (It cannot be unset due to Glean limitations.)
    963   *
    964   * @type {boolean}
    965   */
    966  #wasArchivePreviouslyDisabled = false;
    967 
    968  /**
    969   * Stores whether restoring up has been disabled at some point during this
    970   * session. If it has been, the restoreDisabledReason telemetry metric is set
    971   * on each backup. (It cannot be unset due to Glean limitations.)
    972   *
    973   * @type {boolean}
    974   */
    975  #wasRestorePreviouslyDisabled = false;
    976 
    977  /**
    978   * Called when prefs or other conditions relevant to the status of the backup
    979   * service change. Unlike #observer, this does not wait for an idle tick.
    980   *
    981   * This callback doesn't take any parameters. It's here so it can be removed
    982   * by uninitStatusObservers, and also so its 'this' value remains accurate.
    983   * If null, the conditions are not currently being monitored.
    984   *
    985   * @type {Function?}
    986   */
    987  #statusPrefObserver = null;
    988 
    989  /**
    990   * The path of the default parent directory for saving backups.
    991   * The current default is the Documents directory.
    992   *
    993   * @returns {string} The path of the default parent directory
    994   */
    995  static get DEFAULT_PARENT_DIR_PATH() {
    996    return (
    997      BackupService.oneDriveFolderPath?.path ||
    998      BackupService.docsDirFolderPath?.path ||
    999      ""
   1000    );
   1001  }
   1002 
   1003  /**
   1004   * The localized name for the user's backup folder.
   1005   *
   1006   * @returns {string} The localized backup folder name
   1007   */
   1008  static get BACKUP_DIR_NAME() {
   1009    if (!BackupService.#backupFolderName) {
   1010      BackupService.#backupFolderName = lazy.DownloadPaths.sanitize(
   1011        lazy.gFluentStrings.formatValueSync("backup-folder-name")
   1012      );
   1013    }
   1014    return BackupService.#backupFolderName;
   1015  }
   1016 
   1017  /**
   1018   * The localized name for the user's backup archive file. This will have
   1019   * `.html` appended to it before writing the archive file.
   1020   *
   1021   * @returns {string} The localized backup file name
   1022   */
   1023  static get BACKUP_FILE_NAME() {
   1024    if (!BackupService.#backupFileName) {
   1025      BackupService.#backupFileName = lazy.DownloadPaths.sanitize(
   1026        lazy.gFluentStrings.formatValueSync("backup-file-name")
   1027      );
   1028    }
   1029    return BackupService.#backupFileName;
   1030  }
   1031 
   1032  /**
   1033   * The name of the folder within the profile folder where this service reads
   1034   * and writes state to.
   1035   *
   1036   * @type {string}
   1037   */
   1038  static get PROFILE_FOLDER_NAME() {
   1039    return "backups";
   1040  }
   1041 
   1042  /**
   1043   * The name of the folder within the PROFILE_FOLDER_NAME where the staging
   1044   * folder / prior backups will be stored.
   1045   *
   1046   * @type {string}
   1047   */
   1048  static get SNAPSHOTS_FOLDER_NAME() {
   1049    return "snapshots";
   1050  }
   1051 
   1052  /**
   1053   * The name of the backup manifest file.
   1054   *
   1055   * @type {string}
   1056   */
   1057  static get MANIFEST_FILE_NAME() {
   1058    return "backup-manifest.json";
   1059  }
   1060 
   1061  /**
   1062   * A promise that resolves to the schema for the backup manifest that this
   1063   * BackupService uses when creating a backup. This should be accessed via
   1064   * the `MANIFEST_SCHEMA` static getter.
   1065   *
   1066   * @type {Promise<object>}
   1067   */
   1068  static #manifestSchemaPromise = null;
   1069 
   1070  /**
   1071   * The current schema version of the backup manifest that this BackupService
   1072   * uses when creating a backup.
   1073   *
   1074   * @type {Promise<object>}
   1075   */
   1076  static get MANIFEST_SCHEMA() {
   1077    if (!BackupService.#manifestSchemaPromise) {
   1078      BackupService.#manifestSchemaPromise = BackupService.getSchemaForVersion(
   1079        SCHEMAS.BACKUP_MANIFEST,
   1080        lazy.ArchiveUtils.SCHEMA_VERSION
   1081      );
   1082    }
   1083 
   1084    return BackupService.#manifestSchemaPromise;
   1085  }
   1086 
   1087  /**
   1088   * The name of the post recovery file written into the newly created profile
   1089   * directory just after a profile is recovered from a backup.
   1090   *
   1091   * @type {string}
   1092   */
   1093  static get POST_RECOVERY_FILE_NAME() {
   1094    return "post-recovery.json";
   1095  }
   1096 
   1097  /**
   1098   * The name of the serialized ArchiveEncryptionState that is written to disk
   1099   * if encryption is enabled.
   1100   *
   1101   * @type {string}
   1102   */
   1103  static get ARCHIVE_ENCRYPTION_STATE_FILE() {
   1104    return "enc-state.json";
   1105  }
   1106 
   1107  /**
   1108   * Returns the SCHEMAS constants, which is a key/value store of constants.
   1109   *
   1110   * @type {object}
   1111   */
   1112  static get SCHEMAS() {
   1113    return SCHEMAS;
   1114  }
   1115 
   1116  /**
   1117   * Returns the filename used for the intermediary compressed ZIP file that
   1118   * is extracted from archives during recovery.
   1119   *
   1120   * @type {string}
   1121   */
   1122  static get RECOVERY_ZIP_FILE_NAME() {
   1123    return "recovery.zip";
   1124  }
   1125 
   1126  /**
   1127   * Prefs that should be monitored. When one of these prefs changes, the
   1128   * 'backup-service-status-changed' observers are notified and telemetry
   1129   * updates.
   1130   *
   1131   * @type {string[]}
   1132   */
   1133  static get STATUS_OBSERVER_PREFS() {
   1134    return [
   1135      BACKUP_ARCHIVE_ENABLED_PREF_NAME,
   1136      BACKUP_RESTORE_ENABLED_PREF_NAME,
   1137      SANITIZE_ON_SHUTDOWN_PREF_NAME,
   1138      CREATED_MANAGED_PROFILES_PREF_NAME,
   1139    ];
   1140  }
   1141 
   1142  /**
   1143   * Returns the schema for the schemaType for a given version.
   1144   *
   1145   * @param {number} schemaType
   1146   *   One of the constants from SCHEMAS.
   1147   * @param {number} version
   1148   *   The version of the schema to return.
   1149   * @returns {Promise<object>}
   1150   */
   1151  static async getSchemaForVersion(schemaType, version) {
   1152    let schemaURL;
   1153 
   1154    if (schemaType == SCHEMAS.BACKUP_MANIFEST) {
   1155      schemaURL = `chrome://browser/content/backup/BackupManifest.${version}.schema.json`;
   1156    } else if (schemaType == SCHEMAS.ARCHIVE_JSON_BLOCK) {
   1157      schemaURL = `chrome://browser/content/backup/ArchiveJSONBlock.${version}.schema.json`;
   1158    } else {
   1159      throw new BackupError(
   1160        `Did not recognize SCHEMAS constant: ${schemaType}`,
   1161        ERRORS.UNKNOWN
   1162      );
   1163    }
   1164 
   1165    let response = await fetch(schemaURL);
   1166    return response.json();
   1167  }
   1168 
   1169  /**
   1170   * The level of Zip compression to use on the zipped staging folder.
   1171   *
   1172   * @type {number}
   1173   */
   1174  static get COMPRESSION_LEVEL() {
   1175    return Ci.nsIZipWriter.COMPRESSION_BEST;
   1176  }
   1177 
   1178  /**
   1179   * Returns the chrome:// URI string for the template that should be used to
   1180   * construct the single-file archive.
   1181   *
   1182   * @type {string}
   1183   */
   1184  static get ARCHIVE_TEMPLATE() {
   1185    return "chrome://browser/content/backup/archive.template.html";
   1186  }
   1187 
   1188  /**
   1189   * The native OSKeyStore label used for the temporary recovery store. The
   1190   * temporary recovery store is initialized with the original OSKeyStore
   1191   * secret that was included in an encrypted backup, and then used by any
   1192   * BackupResource's that need to decrypt / re-encrypt OSKeyStore secrets for
   1193   * the current device.
   1194   *
   1195   * @type {string}
   1196   */
   1197  static get RECOVERY_OSKEYSTORE_LABEL() {
   1198    return AppConstants.MOZ_APP_BASENAME + " Backup Recovery Storage";
   1199  }
   1200 
   1201  /**
   1202   * The name of the exclusive Web Lock that will be requested and held when
   1203   * creating or deleting a backup.
   1204   *
   1205   * @type {string}
   1206   */
   1207  static get WRITE_BACKUP_LOCK_NAME() {
   1208    return "write-backup";
   1209  }
   1210 
   1211  /**
   1212   * The amount of time (in milliseconds) to wait for our backup regeneration
   1213   * debouncer to kick off a regeneration.
   1214   *
   1215   * @type {number}
   1216   */
   1217  static get REGENERATION_DEBOUNCE_RATE_MS() {
   1218    return 10000;
   1219  }
   1220 
   1221  /**
   1222   * The user's personal OneDrive folder, or null if none exists.
   1223   *
   1224   * @returns {nsIFile|null} The OneDrive folder or null
   1225   */
   1226  static get oneDriveFolderPath() {
   1227    try {
   1228      let oneDriveDir = Services.dirsvc.get("OneDrPD", Ci.nsIFile);
   1229      // This check should be redundant -- the OneDrive folder should exist.
   1230      return oneDriveDir.exists() ? oneDriveDir : null;
   1231    } catch {
   1232      // Ignore exceptions.  The OneDrive folder not existing is an exception.
   1233    }
   1234    return null;
   1235  }
   1236 
   1237  /**
   1238   * Gets the user's Documents folder.
   1239   * If it doesn't exist, return null.
   1240   *
   1241   * @returns {nsIFile|null} The Documents folder or null
   1242   */
   1243  static get docsDirFolderPath() {
   1244    try {
   1245      return Services.dirsvc.get("Docs", Ci.nsIFile);
   1246    } catch (e) {
   1247      lazy.logConsole.warn(
   1248        "There was an error while trying to get the Document's directory",
   1249        e
   1250      );
   1251    }
   1252    return null;
   1253  }
   1254 
   1255  /**
   1256   * Returns a reference to a BackupService singleton. If this is the first time
   1257   * that this getter is accessed, this causes the BackupService singleton to be
   1258   * instantiated.
   1259   *
   1260   * @static
   1261   * @param {object} BackupResources
   1262   *   Optional object containing BackupResource classes to initialize the instance with.
   1263   * @returns {BackupService}
   1264   *   The BackupService singleton instance.
   1265   */
   1266  static init(BackupResources = DefaultBackupResources) {
   1267    if (this.#instance) {
   1268      return this.#instance;
   1269    }
   1270 
   1271    // If there is unsent restore telemetry, send it now.
   1272    GleanPings.profileRestore.submit();
   1273 
   1274    this.#instance = new BackupService(BackupResources);
   1275 
   1276    this.#instance.checkForPostRecovery();
   1277    this.#instance.initBackupScheduler();
   1278    this.#instance.initStatusObservers();
   1279    return this.#instance;
   1280  }
   1281 
   1282  /**
   1283   * Clears the BackupService singleton instance.
   1284   * This should only be used in tests.
   1285   *
   1286   * @static
   1287   */
   1288  static uninit() {
   1289    if (this.#instance) {
   1290      lazy.logConsole.debug("Uninitting the BackupService");
   1291 
   1292      this.#instance.uninitBackupScheduler();
   1293      this.#instance.uninitStatusObservers();
   1294      this.#instance = null;
   1295    }
   1296  }
   1297 
   1298  /**
   1299   * Returns a reference to the BackupService singleton. If the singleton has
   1300   * not been initialized, an error is thrown.
   1301   *
   1302   * @static
   1303   * @returns {BackupService}
   1304   */
   1305  static get() {
   1306    if (!this.#instance) {
   1307      throw new BackupError(
   1308        "BackupService not initialized",
   1309        ERRORS.UNINITIALIZED
   1310      );
   1311    }
   1312    return this.#instance;
   1313  }
   1314 
   1315  static checkOsSupportsBackup(osParams) {
   1316    // Currently we only want to show Backup on Windows 10 devices.
   1317    // The first build of Windows 11 is 22000
   1318    return (
   1319      osParams.name == "Windows_NT" &&
   1320      osParams.version == "10.0" &&
   1321      osParams.build &&
   1322      Number(osParams.build) < 22000
   1323    );
   1324  }
   1325 
   1326  /**
   1327   * Create a BackupService instance.
   1328   *
   1329   * @param {object} [backupResources=DefaultBackupResources]
   1330   *   Object containing BackupResource classes to associate with this service.
   1331   */
   1332  constructor(backupResources = DefaultBackupResources) {
   1333    super();
   1334    lazy.logConsole.debug("Instantiated");
   1335 
   1336    for (const resourceName in backupResources) {
   1337      let resource = backupResources[resourceName];
   1338      this.#resources.set(resource.key, resource);
   1339    }
   1340 
   1341    let { promise, resolve } = Promise.withResolvers();
   1342    this.#postRecoveryPromise = promise;
   1343    this.#postRecoveryResolver = resolve;
   1344    this.#backupWriteAbortController = new AbortController();
   1345    this.#regenerationDebouncer = new lazy.DeferredTask(async () => {
   1346      if (
   1347        !this.#backupWriteAbortController.signal.aborted &&
   1348        this.archiveEnabledStatus.enabled
   1349      ) {
   1350        await this.createBackupOnIdleDispatch({
   1351          reason: "user deleted some data",
   1352        });
   1353      }
   1354    }, BackupService.REGENERATION_DEBOUNCE_RATE_MS);
   1355    this.#postRecoveryPromise.then(() => {
   1356      const payload = {
   1357        is_restored:
   1358          !!Services.prefs.getIntPref(
   1359            "browser.backup.profile-restoration-date",
   1360            0
   1361          ) &&
   1362          !Services.prefs.getBoolPref("browser.profiles.profile-copied", false),
   1363      };
   1364      if (payload.is_restored) {
   1365        let backupMetadata = {};
   1366        try {
   1367          backupMetadata = JSON.parse(
   1368            Services.prefs.getStringPref(
   1369              RESTORED_BACKUP_METADATA_PREF_NAME,
   1370              "{}"
   1371            )
   1372          );
   1373        } catch {}
   1374        payload.backup_timestamp = backupMetadata.date
   1375          ? new Date(backupMetadata.date).getTime()
   1376          : null;
   1377        payload.backup_app_name = backupMetadata.appName || null;
   1378        payload.backup_app_version = backupMetadata.appVersion || null;
   1379        payload.backup_build_id = backupMetadata.buildID || null;
   1380        payload.backup_os_name = backupMetadata.osName || null;
   1381        payload.backup_os_version = backupMetadata.osVersion || null;
   1382        payload.backup_legacy_client_id = backupMetadata.legacyClientID || null;
   1383      }
   1384      Glean.browserBackup.restoredProfileData.set(payload);
   1385    });
   1386    const osParams = {
   1387      name: Services.sysinfo.getProperty("name"),
   1388      version: Services.sysinfo.getProperty("version"),
   1389      build: Services.sysinfo.getProperty("build"),
   1390    };
   1391    this.#osSupportsBackup = BackupService.checkOsSupportsBackup(osParams);
   1392    this.#osSupportsRestore = true;
   1393    this.#lastSeenArchiveStatus = this.archiveEnabledStatus;
   1394    this.#lastSeenRestoreStatus = this.restoreEnabledStatus;
   1395  }
   1396 
   1397  // Backup is currently limited to Windows 10. Will be populated by constructor
   1398  #osSupportsBackup = false;
   1399  // Restore is not limited, but leaving this in place if restrictions are needed.
   1400  #osSupportsRestore = true;
   1401  // Remembering status allows us to notify observers when the status changes
   1402  #lastSeenArchiveStatus = false;
   1403  #lastSeenRestoreStatus = false;
   1404 
   1405  /**
   1406   * Returns a reference to a Promise that will resolve with undefined once
   1407   * postRecovery steps have had a chance to run. This will also be resolved
   1408   * with undefined if no postRecovery steps needed to be run.
   1409   *
   1410   * @see BackupService.checkForPostRecovery()
   1411   * @returns {Promise<undefined>}
   1412   */
   1413  get postRecoveryComplete() {
   1414    return this.#postRecoveryPromise;
   1415  }
   1416 
   1417  /**
   1418   * Returns a state object describing the state of the BackupService for the
   1419   * purposes of representing it in the user interface. The returned state
   1420   * object is immutable.
   1421   *
   1422   * @type {object}
   1423   */
   1424  get state() {
   1425    if (
   1426      !Object.keys(this.#_state.defaultParent).length ||
   1427      !this.#_state.defaultParent.path
   1428    ) {
   1429      let defaultPath = BackupService.DEFAULT_PARENT_DIR_PATH;
   1430      this.#_state.defaultParent = {
   1431        path: defaultPath,
   1432        fileName: defaultPath ? PathUtils.filename(defaultPath) : "",
   1433        iconURL: defaultPath ? this.getIconFromFilePath(defaultPath) : "",
   1434      };
   1435    }
   1436 
   1437    return Object.freeze(structuredClone(this.#_state));
   1438  }
   1439 
   1440  /**
   1441   * Attempts to find the right folder to write the single-file archive to, creating
   1442   * it if it does not exist yet.
   1443   *
   1444   * @param {string} configuredDestFolderPath
   1445   *   The currently configured destination folder for the archive.
   1446   * @returns {Promise<string, Error>}
   1447   */
   1448  async resolveArchiveDestFolderPath(configuredDestFolderPath) {
   1449    try {
   1450      await IOUtils.makeDirectory(configuredDestFolderPath, {
   1451        createAncestors: true,
   1452        ignoreExisting: true,
   1453      });
   1454      return configuredDestFolderPath;
   1455    } catch (e) {
   1456      lazy.logConsole.warn("Could not create configured destination path: ", e);
   1457      throw new BackupError(
   1458        "Could not resolve to a writable destination folder path.",
   1459        ERRORS.FILE_SYSTEM_ERROR
   1460      );
   1461    }
   1462  }
   1463 
   1464  /**
   1465   * Computes the appropriate link to place in the single-file archive for
   1466   * downloading a version of this application for the same update channel.
   1467   *
   1468   * When bug 1905909 lands, we'll first check to see if there are download
   1469   * links available in Remote Settings.
   1470   *
   1471   * If there aren't any, we will fallback by looking for preference values at
   1472   * browser.backup.template.fallback-download.${updateChannel}.
   1473   *
   1474   * If no such preference exists, a final "ultimate" fallback download link is
   1475   * chosen for the release channel.
   1476   *
   1477   * @param {string} updateChannel
   1478   *  The current update channel for the application, as provided by
   1479   *  AppConstants.MOZ_UPDATE_CHANNEL.
   1480   * @returns {Promise<string>}
   1481   */
   1482  async resolveDownloadLink(updateChannel) {
   1483    // If all else fails, this is the download link we'll put into the rendered
   1484    // template.
   1485    const ULTIMATE_FALLBACK_DOWNLOAD_URL =
   1486      "https://www.firefox.com/?utm_medium=firefox-desktop&utm_source=html-backup";
   1487    const FALLBACK_DOWNLOAD_URL = Services.prefs.getStringPref(
   1488      `browser.backup.template.fallback-download.${updateChannel}`,
   1489      ULTIMATE_FALLBACK_DOWNLOAD_URL
   1490    );
   1491 
   1492    // Bug 1905909: Once we set up the download links in RemoteSettings, we can
   1493    // query for them here.
   1494 
   1495    return FALLBACK_DOWNLOAD_URL;
   1496  }
   1497 
   1498  /**
   1499   * Creates a backup for a given profile into a staging foler.
   1500   *
   1501   * @param {string} profilePath The path to the profile to backup.
   1502   * @returns {Promsie<object>} An object containing the results of this function.
   1503   * @property {STEPS} currentStep The current step of the backup process.
   1504   * @property {string} backupDirPath The path to the folder containing backups.
   1505   *   Only included if this function completed successfully.
   1506   * @property {string} stagingPath The path to the staging folder.
   1507   *   Only included if this function completed successfully.
   1508   * @property {object} manifest An object containing meta data for the backup.
   1509   *   See the BackupManifest schema for the specific shape of the returned
   1510   *   manifest object.
   1511   * @property {Error} error An error. Only included if an error was thrown.
   1512   */
   1513  async createAndPopulateStagingFolder(profilePath) {
   1514    let currentStep, backupDirPath, renamedStagingPath, manifest;
   1515    try {
   1516      currentStep = STEPS.CREATE_BACKUP_CREATE_MANIFEST;
   1517      manifest = await this.#createBackupManifest();
   1518 
   1519      currentStep = STEPS.CREATE_BACKUP_CREATE_BACKUPS_FOLDER;
   1520      // First, check to see if a `backups` directory already exists in the
   1521      // profile.
   1522      backupDirPath = PathUtils.join(
   1523        profilePath,
   1524        BackupService.PROFILE_FOLDER_NAME,
   1525        BackupService.SNAPSHOTS_FOLDER_NAME
   1526      );
   1527      lazy.logConsole.debug("Creating backups folder");
   1528 
   1529      // ignoreExisting: true is the default, but we're being explicit that it's
   1530      // okay if this folder already exists.
   1531      await IOUtils.makeDirectory(backupDirPath, {
   1532        ignoreExisting: true,
   1533        createAncestors: true,
   1534      });
   1535 
   1536      currentStep = STEPS.CREATE_BACKUP_CREATE_STAGING_FOLDER;
   1537      let stagingPath = await this.#prepareStagingFolder(backupDirPath);
   1538 
   1539      // Sort resources be priority.
   1540      let sortedResources = Array.from(this.#resources.values()).sort(
   1541        (a, b) => {
   1542          return b.priority - a.priority;
   1543        }
   1544      );
   1545 
   1546      currentStep = STEPS.CREATE_BACKUP_LOAD_ENCSTATE;
   1547      let encState = await this.loadEncryptionState(profilePath);
   1548      let encryptionEnabled = !!encState;
   1549      lazy.logConsole.debug("Encryption enabled: ", encryptionEnabled);
   1550 
   1551      currentStep = STEPS.CREATE_BACKUP_RUN_BACKUP;
   1552      // Perform the backup for each resource.
   1553      for (let resourceClass of sortedResources) {
   1554        try {
   1555          lazy.logConsole.debug(
   1556            `Backing up resource with key ${resourceClass.key}. ` +
   1557              `Requires encryption: ${resourceClass.requiresEncryption}`
   1558          );
   1559 
   1560          if (resourceClass.requiresEncryption && !encryptionEnabled) {
   1561            lazy.logConsole.debug(
   1562              "Encryption is not currently enabled. Skipping."
   1563            );
   1564            continue;
   1565          }
   1566 
   1567          if (!resourceClass.canBackupResource) {
   1568            lazy.logConsole.debug(
   1569              `We cannot backup ${resourceClass.key}. Skipping.`
   1570            );
   1571            continue;
   1572          }
   1573 
   1574          let resourcePath = PathUtils.join(stagingPath, resourceClass.key);
   1575          await IOUtils.makeDirectory(resourcePath);
   1576 
   1577          // `backup` on each BackupResource should return us a ManifestEntry
   1578          // that we eventually write to a JSON manifest file, but for now,
   1579          // we're just going to log it.
   1580          let manifestEntry = await new resourceClass().backup(
   1581            resourcePath,
   1582            profilePath,
   1583            encryptionEnabled
   1584          );
   1585 
   1586          if (manifestEntry === undefined) {
   1587            lazy.logConsole.error(
   1588              `Backup of resource with key ${resourceClass.key} returned undefined
   1589                as its ManifestEntry instead of null or an object`
   1590            );
   1591          } else {
   1592            lazy.logConsole.debug(
   1593              `Backup of resource with key ${resourceClass.key} completed`,
   1594              manifestEntry
   1595            );
   1596            manifest.resources[resourceClass.key] = manifestEntry;
   1597          }
   1598        } catch (e) {
   1599          lazy.logConsole.error(
   1600            `Failed to backup resource: ${resourceClass.key}`,
   1601            e
   1602          );
   1603        }
   1604      }
   1605 
   1606      currentStep = STEPS.CREATE_BACKUP_VERIFY_MANIFEST;
   1607      // Ensure that the manifest abides by the current schema, and log
   1608      // an error if somehow it doesn't. We'll want to collect telemetry for
   1609      // this case to make sure it's not happening in the wild. We debated
   1610      // throwing an exception here too, but that's not meaningfully better
   1611      // than creating a backup that's not schema-compliant. At least in this
   1612      // case, a user so-inclined could theoretically repair the manifest
   1613      // to make it valid.
   1614      let manifestSchema = await BackupService.MANIFEST_SCHEMA;
   1615      let schemaValidationResult = lazy.JsonSchema.validate(
   1616        manifest,
   1617        manifestSchema
   1618      );
   1619      if (!schemaValidationResult.valid) {
   1620        lazy.logConsole.error(
   1621          "Backup manifest does not conform to schema:",
   1622          manifest,
   1623          manifestSchema,
   1624          schemaValidationResult
   1625        );
   1626        // TODO: Collect telemetry for this case. (bug 1891817)
   1627      }
   1628 
   1629      currentStep = STEPS.CREATE_BACKUP_WRITE_MANIFEST;
   1630      // Write the manifest to the staging folder.
   1631      let manifestPath = PathUtils.join(
   1632        stagingPath,
   1633        BackupService.MANIFEST_FILE_NAME
   1634      );
   1635      await IOUtils.writeJSON(manifestPath, manifest);
   1636 
   1637      currentStep = STEPS.CREATE_BACKUP_FINALIZE_STAGING;
   1638      renamedStagingPath = await this.#finalizeStagingFolder(stagingPath);
   1639      lazy.logConsole.log(
   1640        "Wrote backup to staging directory at ",
   1641        renamedStagingPath
   1642      );
   1643 
   1644      // Record the total size of the backup staging directory
   1645      let totalSizeKilobytes =
   1646        await BackupResource.getDirectorySize(renamedStagingPath);
   1647      let totalSizeBytesNearestMebibyte = MeasurementUtils.fuzzByteSize(
   1648        totalSizeKilobytes * BYTES_IN_KILOBYTE,
   1649        1 * BYTES_IN_MEBIBYTE
   1650      );
   1651      lazy.logConsole.debug(
   1652        "total staging directory size in bytes: " +
   1653          totalSizeBytesNearestMebibyte
   1654      );
   1655 
   1656      Glean.browserBackup.totalBackupSize.accumulate(
   1657        totalSizeBytesNearestMebibyte / BYTES_IN_MEBIBYTE
   1658      );
   1659    } catch (e) {
   1660      return { currentStep, error: e };
   1661    }
   1662 
   1663    return {
   1664      currentStep,
   1665      backupDirPath,
   1666      stagingPath: renamedStagingPath,
   1667      manifest,
   1668    };
   1669  }
   1670 
   1671  /**
   1672   * @typedef {object} CreateBackupResult
   1673   * @property {object} manifest
   1674   *   The backup manifest data of the created backup. See BackupManifest
   1675   *   schema for specific details.
   1676   * @property {string} archivePath
   1677   *   The path to the single file archive that was created.
   1678   */
   1679 
   1680  /**
   1681   * Create a backup of the user's profile.
   1682   *
   1683   * @param {object} [options]
   1684   *   Options for the backup.
   1685   * @param {string} [options.profilePath=PathUtils.profileDir]
   1686   *   The path to the profile to backup. By default, this is the current
   1687   *   profile.
   1688   * @param {string} [options.reason=unknown]
   1689   *   The reason for starting the backup. This is sent along with the
   1690   *   backup.backup_start event.
   1691   * @returns {Promise<CreateBackupResult|null>}
   1692   *   A promise that resolves to information about the backup that was
   1693   *   created, or null if the backup failed.
   1694   */
   1695  async createBackup({
   1696    profilePath = PathUtils.profileDir,
   1697    reason = "unknown",
   1698  } = {}) {
   1699    let status = this.archiveEnabledStatus;
   1700    if (!status.enabled) {
   1701      lazy.logConsole.debug(status.reason);
   1702      return null;
   1703    }
   1704 
   1705    // createBackup does not allow re-entry or concurrent backups.
   1706    if (this.#backupInProgress) {
   1707      lazy.logConsole.warn("Backup attempt already in progress");
   1708      return null;
   1709    }
   1710 
   1711    Glean.browserBackup.backupStart.record({ reason });
   1712 
   1713    return locks.request(
   1714      BackupService.WRITE_BACKUP_LOCK_NAME,
   1715      { signal: this.#backupWriteAbortController.signal },
   1716      async () => {
   1717        let currentStep = STEPS.CREATE_BACKUP_ENTRYPOINT;
   1718        this.#backupInProgress = true;
   1719        const backupTimer = Glean.browserBackup.totalBackupTime.start();
   1720 
   1721        // reset the error state prefs
   1722        Services.prefs.clearUserPref(BACKUP_DEBUG_INFO_PREF_NAME);
   1723        Services.prefs.setIntPref(BACKUP_ERROR_CODE_PREF_NAME, ERRORS.NONE);
   1724        // reset profile copied pref so the backup welcome messaging will show
   1725        Services.prefs.clearUserPref("browser.profiles.profile-copied");
   1726 
   1727        try {
   1728          lazy.logConsole.debug(
   1729            `Creating backup for profile at ${profilePath}`
   1730          );
   1731 
   1732          currentStep = STEPS.CREATE_BACKUP_RESOLVE_DESTINATION;
   1733          let archiveDestFolderPath = await this.resolveArchiveDestFolderPath(
   1734            lazy.backupDirPref
   1735          );
   1736          lazy.logConsole.debug(
   1737            `Destination for archive: ${archiveDestFolderPath}`
   1738          );
   1739 
   1740          let result = await this.createAndPopulateStagingFolder(profilePath);
   1741          currentStep = result.currentStep;
   1742          if (result.error) {
   1743            // Re-throw the error so we can catch it below for telemetry
   1744            throw result.error;
   1745          }
   1746 
   1747          let { backupDirPath, stagingPath, manifest } = result;
   1748 
   1749          currentStep = STEPS.CREATE_BACKUP_COMPRESS_STAGING;
   1750          let compressedStagingPath = await this.#compressStagingFolder(
   1751            stagingPath,
   1752            backupDirPath
   1753          ).finally(async () => {
   1754            // retryReadonly is needed in case there were read only files in
   1755            // the profile.
   1756            await IOUtils.remove(stagingPath, {
   1757              recursive: true,
   1758              retryReadonly: true,
   1759            });
   1760          });
   1761 
   1762          currentStep = STEPS.CREATE_BACKUP_CREATE_ARCHIVE;
   1763          // Now create the single-file archive. For now, we'll stash this in the
   1764          // backups folder while it gets written. Once that's done, we'll attempt
   1765          // to move it to the user's configured backup path.
   1766          let archiveTmpPath = PathUtils.join(backupDirPath, "archive.html");
   1767          lazy.logConsole.log(
   1768            "Exporting single-file archive to ",
   1769            archiveTmpPath
   1770          );
   1771          await this.createArchive(
   1772            archiveTmpPath,
   1773            BackupService.ARCHIVE_TEMPLATE,
   1774            compressedStagingPath,
   1775            this.#encState,
   1776            manifest.meta
   1777          ).finally(async () => {
   1778            await IOUtils.remove(compressedStagingPath, {
   1779              retryReadonly: true,
   1780            });
   1781          });
   1782 
   1783          // Record the size of the complete single-file archive
   1784          let archiveSizeKilobytes =
   1785            await BackupResource.getFileSize(archiveTmpPath);
   1786          let archiveSizeBytesNearestMebibyte = MeasurementUtils.fuzzByteSize(
   1787            archiveSizeKilobytes * BYTES_IN_KILOBYTE,
   1788            1 * BYTES_IN_MEBIBYTE
   1789          );
   1790          lazy.logConsole.debug(
   1791            "backup archive size in bytes: " + archiveSizeBytesNearestMebibyte
   1792          );
   1793 
   1794          Glean.browserBackup.compressedArchiveSize.accumulate(
   1795            archiveSizeBytesNearestMebibyte / BYTES_IN_MEBIBYTE
   1796          );
   1797 
   1798          currentStep = STEPS.CREATE_BACKUP_FINALIZE_ARCHIVE;
   1799          let archivePath = await this.finalizeSingleFileArchive(
   1800            archiveTmpPath,
   1801            archiveDestFolderPath,
   1802            manifest.meta
   1803          );
   1804 
   1805          let nowSeconds = Math.floor(Date.now() / 1000);
   1806          Services.prefs.setIntPref(
   1807            LAST_BACKUP_TIMESTAMP_PREF_NAME,
   1808            nowSeconds
   1809          );
   1810          this.#_state.lastBackupDate = nowSeconds;
   1811          Glean.browserBackup.totalBackupTime.stopAndAccumulate(backupTimer);
   1812 
   1813          Glean.browserBackup.created.record({
   1814            encrypted: this.#_state.encryptionEnabled,
   1815            location: this.classifyLocationForTelemetry(archiveDestFolderPath),
   1816            size: archiveSizeBytesNearestMebibyte,
   1817          });
   1818 
   1819          // we should reset any values that were set for retry error handling
   1820          Services.prefs.clearUserPref(DISABLED_ON_IDLE_RETRY_PREF_NAME);
   1821          BackupService.#errorRetries = 0;
   1822 
   1823          return { manifest, archivePath };
   1824        } catch (e) {
   1825          Glean.browserBackup.totalBackupTime.cancel(backupTimer);
   1826          Glean.browserBackup.error.record({
   1827            error_code: String(e.cause || ERRORS.UNKNOWN),
   1828            backup_step: String(currentStep),
   1829          });
   1830 
   1831          // TODO: show more specific error messages to the user
   1832          Services.prefs.setIntPref(
   1833            BACKUP_ERROR_CODE_PREF_NAME,
   1834            ERRORS.UNKNOWN
   1835          );
   1836 
   1837          Services.prefs.setStringPref(
   1838            BACKUP_DEBUG_INFO_PREF_NAME,
   1839            JSON.stringify({
   1840              lastBackupAttempt: Math.floor(Date.now() / 1000),
   1841              errorCode: e instanceof BackupError ? e : ERRORS.UNKNOWN,
   1842              lastRunStep: currentStep,
   1843            })
   1844          );
   1845 
   1846          this.stateUpdate();
   1847          throw e;
   1848        } finally {
   1849          this.#backupInProgress = false;
   1850        }
   1851      }
   1852    );
   1853  }
   1854 
   1855  /**
   1856   * Creates a coarse name corresponding to the location where the backup will
   1857   * be stored. This is sent by telemetry, and aims to anonymize the data.
   1858   *
   1859   * Normally, the path should end in 'Restore Firefox'; if it doesn't, you
   1860   * might be passing the wrong path and will get the wrong result.
   1861   *
   1862   * This isn't private so it can be used by the tests; avoid relying on this
   1863   * code from elsewhere.
   1864   *
   1865   * @param {string} path The absolute path that contains the backup file.
   1866   * @returns {string} A coarse location to send with the telemetry.
   1867   */
   1868  classifyLocationForTelemetry(path) {
   1869    let knownLocations = {
   1870      onedrive: "OneDrPD",
   1871      documents: "Docs",
   1872    };
   1873 
   1874    let location;
   1875    try {
   1876      // By default, the backup will go into a folder called 'Restore Firefox',
   1877      // so we actually want the parent directory.
   1878      location = lazy.nsLocalFile(path).parent;
   1879    } catch (e) {
   1880      // initWithPath (at least on Windows) is _really_ picky; e.g.
   1881      // "C:/Windows/system32" will fail. Bail out if something went wrong so
   1882      // this doesn't affect the backup.
   1883      return `Error: ${e.name ?? "Unknown error"}`;
   1884    }
   1885 
   1886    for (let label of Object.keys(knownLocations)) {
   1887      try {
   1888        let candidate = Services.dirsvc.get(knownLocations[label], Ci.nsIFile);
   1889        if (candidate.equals(location)) {
   1890          return label;
   1891        }
   1892      } catch (e) {
   1893        // ignore (maybe it wasn't found?)
   1894      }
   1895    }
   1896 
   1897    return "other";
   1898  }
   1899 
   1900  /**
   1901   * Generates a string from a Date in the form of:
   1902   *
   1903   * YYYYMMDD-HHMM
   1904   *
   1905   * @param {Date} date
   1906   *   The date to convert into the archive date suffix.
   1907   * @returns {string}
   1908   */
   1909  generateArchiveDateSuffix(date) {
   1910    let year = date.getFullYear().toString();
   1911 
   1912    // In all cases, months or days with single digits are expected to start
   1913    // with a 0.
   1914 
   1915    // Note that getMonth() is 0-indexed for some reason, so we increment by 1.
   1916    let month = `${date.getMonth() + 1}`.padStart(2, "0");
   1917 
   1918    let day = `${date.getDate()}`.padStart(2, "0");
   1919    let hours = `${date.getHours()}`.padStart(2, "0");
   1920    let minutes = `${date.getMinutes()}`.padStart(2, "0");
   1921 
   1922    return `${year}${month}${day}-${hours}${minutes}`;
   1923  }
   1924 
   1925  /**
   1926   * Moves the single-file archive into its configured location with a filename
   1927   * that is sanitized and contains a timecode. This also removes any existing
   1928   * single-file archives in that same folder after the move completes.
   1929   *
   1930   * @param {string} sourcePath
   1931   *   The file system location of the single-file archive prior to the move.
   1932   * @param {string} destFolder
   1933   *   The folder that the single-file archive is configured to be eventually
   1934   *   written to.
   1935   * @param {object} metadata
   1936   *   The metadata for the backup. See the BackupManifest schema for details.
   1937   * @returns {Promise<string>}
   1938   *   Resolves with the path that the single-file archive was moved to.
   1939   */
   1940  async finalizeSingleFileArchive(sourcePath, destFolder, metadata) {
   1941    let archiveDateSuffix = this.generateArchiveDateSuffix(
   1942      new Date(metadata.date)
   1943    );
   1944 
   1945    let existingChildren = await IOUtils.getChildren(destFolder);
   1946 
   1947    const FILENAME_PREFIX = `${BackupService.BACKUP_FILE_NAME}_${metadata.profileName}`;
   1948    const FILENAME = `${FILENAME_PREFIX}_${archiveDateSuffix}.html`;
   1949    let destPath = PathUtils.join(destFolder, FILENAME);
   1950    lazy.logConsole.log("Moving single-file archive to ", destPath);
   1951    await IOUtils.move(sourcePath, destPath);
   1952 
   1953    Services.prefs.setStringPref(LAST_BACKUP_FILE_NAME_PREF_NAME, FILENAME);
   1954 
   1955    for (let childFilePath of existingChildren) {
   1956      let childFileName = PathUtils.filename(childFilePath);
   1957      // We check both the prefix and the suffix, because the prefix encodes
   1958      // the profile name in it. If there are other profiles from the same
   1959      // application performing backup, we don't want to accidentally remove
   1960      // those.
   1961      if (
   1962        childFileName.startsWith(FILENAME_PREFIX) &&
   1963        childFileName.endsWith(".html")
   1964      ) {
   1965        if (childFileName == FILENAME) {
   1966          // Since filenames don't include seconds, this might occur if a
   1967          // backup was created seconds after the last one during the same
   1968          // minute. That tends not to happen in practice, but might occur
   1969          // during testing, in which case, we'll skip clearing this file.
   1970          lazy.logConsole.warn(
   1971            "Collided with a pre-existing archive name, so not clearing: ",
   1972            FILENAME
   1973          );
   1974          continue;
   1975        }
   1976        lazy.logConsole.debug("Getting rid of ", childFilePath);
   1977        await IOUtils.remove(childFilePath);
   1978      }
   1979    }
   1980 
   1981    return destPath;
   1982  }
   1983 
   1984  /**
   1985   * Constructs the staging folder for the backup in the passed in backup
   1986   * folder. If the backup (snapshots) folder isn't empty, it will be cleared
   1987   * out.  If that process fails to remove more than
   1988   * lazy.maximumNumberOfUnremovableStagingItems then the backup is aborted.
   1989   *
   1990   * @param {string} backupDirPath
   1991   *   The path to the backup folder.
   1992   * @returns {Promise<string>}
   1993   *   The path to the empty staging folder.
   1994   */
   1995  async #prepareStagingFolder(backupDirPath) {
   1996    lazy.logConsole.debug(`Clearing snapshot folder ${backupDirPath}`);
   1997    let numUnremovableStagingItems = 0;
   1998    let folder = await IOUtils.getFile(backupDirPath);
   1999    let folderEntries = folder.directoryEntries;
   2000    if (folderEntries) {
   2001      let unremovableContents = [];
   2002      for (let folderItem of folderEntries) {
   2003        try {
   2004          lazy.logConsole.debug(`Removing ${folderItem.path}`);
   2005          await IOUtils.remove(folderItem, {
   2006            recursive: true,
   2007            retryReadonly: true,
   2008          });
   2009        } catch (e) {
   2010          lazy.logConsole.warn(
   2011            `Failed to remove stale snapshot item ${folderItem.path}.  Exception: ${e}`
   2012          );
   2013          // Whatever the problem was with removing the snapshot dir contents
   2014          // (presumably a staging dir or archive), keep going until
   2015          // maximumNumberOfUnremovableStagingItems + 1 have failed to be
   2016          // removed, at which point we abandon the backup, in order to avoid
   2017          // filling drive space.
   2018          numUnremovableStagingItems++;
   2019          unremovableContents.push(folderItem.path);
   2020          if (
   2021            numUnremovableStagingItems >
   2022            lazy.maximumNumberOfUnremovableStagingItems
   2023          ) {
   2024            let error = new BackupError(
   2025              `Failed to remove ${numUnremovableStagingItems} items from ${backupDirPath}`,
   2026              ERRORS.FILE_SYSTEM_ERROR
   2027            );
   2028            error.stack = e.stack;
   2029            error.unremovableContents = unremovableContents;
   2030            throw error;
   2031          }
   2032        }
   2033      }
   2034    }
   2035 
   2036    lazy.logConsole.debug(
   2037      `${numUnremovableStagingItems} unremovable staging items found.  Proceeding with backup.  Determining staging folder.`
   2038    );
   2039    let stagingPath;
   2040    for (let i = 0; i < lazy.maximumNumberOfUnremovableStagingItems + 1; i++) {
   2041      // Attempt to use "staging-i" as the name of the staging folder.
   2042      let potentialStagingPath = PathUtils.join(backupDirPath, "staging-" + i);
   2043      if (!(await IOUtils.exists(potentialStagingPath))) {
   2044        stagingPath = potentialStagingPath;
   2045        await IOUtils.makeDirectory(stagingPath);
   2046        break;
   2047      }
   2048    }
   2049 
   2050    if (!stagingPath) {
   2051      // Should be impossible.  We determined there were no more than
   2052      // maximumNumberOfUnremovableStagingItems items but we then found
   2053      // maximumNumberOfUnremovableStagingItems + 1 staging folders.
   2054      throw new BackupError(
   2055        `Internal error in attempt to create staging folder`,
   2056        ERRORS.FILE_SYSTEM_ERROR
   2057      );
   2058    }
   2059 
   2060    lazy.logConsole.debug(`Staging folder ${stagingPath} is prepared`);
   2061    return stagingPath;
   2062  }
   2063 
   2064  /**
   2065   * Compresses a staging folder into a Zip file. If a pre-existing Zip file
   2066   * for a staging folder resides in destFolderPath, it is overwritten. The
   2067   * Zip file will have the same name as the stagingPath folder, with `.zip`
   2068   * as the extension.
   2069   *
   2070   * @param {string} stagingPath
   2071   *   The path to the staging folder to be compressed.
   2072   * @param {string} destFolderPath
   2073   *   The parent folder to write the Zip file to.
   2074   * @returns {Promise<string>}
   2075   *   Resolves with the path to the created Zip file.
   2076   */
   2077  async #compressStagingFolder(stagingPath, destFolderPath) {
   2078    const PR_RDWR = 0x04;
   2079    const PR_CREATE_FILE = 0x08;
   2080    const PR_TRUNCATE = 0x20;
   2081 
   2082    let archivePath = PathUtils.join(
   2083      destFolderPath,
   2084      `${PathUtils.filename(stagingPath)}.zip`
   2085    );
   2086    let archiveFile = await IOUtils.getFile(archivePath);
   2087 
   2088    let writer = new lazy.ZipWriter(
   2089      archiveFile,
   2090      PR_RDWR | PR_CREATE_FILE | PR_TRUNCATE
   2091    );
   2092 
   2093    lazy.logConsole.log("Compressing staging folder to ", archivePath);
   2094    let rootPathNSIFile = await IOUtils.getDirectory(stagingPath);
   2095    await this.#compressChildren(rootPathNSIFile, stagingPath, writer);
   2096    await new Promise(resolve => {
   2097      let observer = {
   2098        onStartRequest(_request) {
   2099          lazy.logConsole.debug("Starting to write out archive file");
   2100        },
   2101        onStopRequest(_request, status) {
   2102          lazy.logConsole.log("Done writing archive file");
   2103          resolve(status);
   2104        },
   2105      };
   2106      writer.processQueue(observer, null);
   2107    });
   2108    writer.close();
   2109 
   2110    return archivePath;
   2111  }
   2112 
   2113  /**
   2114   * A helper function for #compressStagingFolder that iterates through a
   2115   * directory, and adds each file to a nsIZipWriter. For each directory it
   2116   * finds, it recurses.
   2117   *
   2118   * @param {nsIFile} rootPathNSIFile
   2119   *   An nsIFile pointing at the root of the folder being compressed.
   2120   * @param {string} parentPath
   2121   *   The path to the folder whose children should be iterated.
   2122   * @param {nsIZipWriter} writer
   2123   *   The writer to add all of the children to.
   2124   * @returns {Promise<undefined>}
   2125   */
   2126  async #compressChildren(rootPathNSIFile, parentPath, writer) {
   2127    let children = await IOUtils.getChildren(parentPath);
   2128    for (let childPath of children) {
   2129      let childState = await IOUtils.stat(childPath);
   2130      if (childState.type == "directory") {
   2131        await this.#compressChildren(rootPathNSIFile, childPath, writer);
   2132      } else {
   2133        let childFile = await IOUtils.getFile(childPath);
   2134        // nsIFile.getRelativePath returns paths using the "/" separator,
   2135        // regardless of which platform we're on. That's handy, because this
   2136        // is the same separator that nsIZipWriter expects for entries.
   2137        let pathRelativeToRoot = childFile.getRelativePath(rootPathNSIFile);
   2138        writer.addEntryFile(
   2139          pathRelativeToRoot,
   2140          BackupService.COMPRESSION_LEVEL,
   2141          childFile,
   2142          true
   2143        );
   2144      }
   2145    }
   2146  }
   2147 
   2148  /**
   2149   * Decompressed a compressed recovery file into recoveryFolderDestPath.
   2150   *
   2151   * @param {string} recoveryFilePath
   2152   *   The path to the compressed recovery file to decompress.
   2153   * @param {string} recoveryFolderDestPath
   2154   *   The path to the folder that the compressed recovery file should be
   2155   *   decompressed within.
   2156   * @returns {Promise<undefined>}
   2157   */
   2158  async decompressRecoveryFile(recoveryFilePath, recoveryFolderDestPath) {
   2159    let recoveryFile = await IOUtils.getFile(recoveryFilePath);
   2160    let recoveryArchive = new lazy.ZipReader(recoveryFile);
   2161    lazy.logConsole.log(
   2162      "Decompressing recovery folder to ",
   2163      recoveryFolderDestPath
   2164    );
   2165    try {
   2166      // null is passed to test if we're meant to CRC test the entire
   2167      // ZIP file. If an exception is thrown, this means we failed the CRC
   2168      // check. See the nsIZipReader.idl documentation for details.
   2169      recoveryArchive.test(null);
   2170    } catch (e) {
   2171      recoveryArchive.close();
   2172      lazy.logConsole.error("Compressed recovery file was corrupt.");
   2173      await IOUtils.remove(recoveryFilePath, {
   2174        retryReadonly: true,
   2175      });
   2176      throw new BackupError("Corrupt archive.", ERRORS.CORRUPTED_ARCHIVE);
   2177    }
   2178 
   2179    await this.#decompressChildren(recoveryFolderDestPath, "", recoveryArchive);
   2180    recoveryArchive.close();
   2181  }
   2182 
   2183  /**
   2184   * A helper method that recursively decompresses any children within a folder
   2185   * within a compressed archive.
   2186   *
   2187   * @param {string} rootPath
   2188   *   The path to the root folder that is being decompressed into.
   2189   * @param {string} parentEntryName
   2190   *   The name of the parent folder within the compressed archive that is
   2191   *   having its children decompressed.
   2192   * @param {nsIZipReader} reader
   2193   *   The nsIZipReader for the compressed archive.
   2194   * @returns {Promise<undefined>}
   2195   */
   2196  async #decompressChildren(rootPath, parentEntryName, reader) {
   2197    // nsIZipReader.findEntries has an interesting querying language that is
   2198    // documented in the nsIZipReader IDL file, in case you're curious about
   2199    // what these symbols mean.
   2200    let childEntryNames = reader.findEntries(
   2201      parentEntryName + "?*~" + parentEntryName + "?*/?*"
   2202    );
   2203 
   2204    for (let childEntryName of childEntryNames) {
   2205      let childEntry = reader.getEntry(childEntryName);
   2206      if (childEntry.isDirectory) {
   2207        await this.#decompressChildren(rootPath, childEntryName, reader);
   2208      } else {
   2209        let inputStream = reader.getInputStream(childEntryName);
   2210        // ZIP files all use `/` as their path separators, regardless of
   2211        // platform.
   2212        let fileNameParts = childEntryName.split("/");
   2213        let outputFilePath = PathUtils.join(rootPath, ...fileNameParts);
   2214        let outputFile = await IOUtils.getFile(outputFilePath);
   2215        let outputStream = Cc[
   2216          "@mozilla.org/network/file-output-stream;1"
   2217        ].createInstance(Ci.nsIFileOutputStream);
   2218 
   2219        outputStream.init(
   2220          outputFile,
   2221          -1,
   2222          -1,
   2223          Ci.nsIFileOutputStream.DEFER_OPEN
   2224        );
   2225 
   2226        await new Promise(resolve => {
   2227          lazy.logConsole.debug("Writing ", outputFilePath);
   2228          lazy.NetUtil.asyncCopy(inputStream, outputStream, () => {
   2229            lazy.logConsole.debug("Done writing ", outputFilePath);
   2230            outputStream.close();
   2231            resolve();
   2232          });
   2233        });
   2234      }
   2235    }
   2236  }
   2237 
   2238  /**
   2239   * Given a URI to an HTML template for the single-file backup archive,
   2240   * produces the static markup that will then be used as the beginning of that
   2241   * single-file backup archive.
   2242   *
   2243   * @param {string} templateURI
   2244   *   A URI pointing at a template for the HTML content for the page. This is
   2245   *   what is visible if the file is loaded in a web browser.
   2246   * @param {boolean} isEncrypted
   2247   *   True if the template should indicate that the backup is encrypted.
   2248   * @param {object} backupMetadata
   2249   *   The metadata for the backup, which is also stored in the backup manifest
   2250   *   of the compressed backup snapshot.
   2251   * @returns {Promise<string>}
   2252   */
   2253  async renderTemplate(templateURI, isEncrypted, backupMetadata) {
   2254    const ARCHIVE_STYLES = "chrome://browser/content/backup/archive.css";
   2255    const ARCHIVE_SCRIPT = "chrome://browser/content/backup/archive.js";
   2256    const LOGO = "chrome://branding/content/icon128.png";
   2257 
   2258    let templateResponse = await fetch(templateURI);
   2259    let templateString = await templateResponse.text();
   2260    let templateDOM = new DOMParser().parseFromString(
   2261      templateString,
   2262      "text/html"
   2263    );
   2264 
   2265    // Set the lang attribute on the <html> element
   2266    templateDOM.documentElement.setAttribute(
   2267      "lang",
   2268      Services.locale.appLocaleAsBCP47
   2269    );
   2270 
   2271    let downloadLink = templateDOM.querySelector("#download-moz-browser");
   2272    downloadLink.href = await this.resolveDownloadLink(
   2273      AppConstants.MOZ_UPDATE_CHANNEL
   2274    );
   2275 
   2276    let supportURI = new URL(
   2277      "firefox-backup",
   2278      Services.urlFormatter.formatURLPref("app.support.baseURL")
   2279    );
   2280    supportURI.searchParams.set("utm_medium", "firefox-desktop");
   2281    supportURI.searchParams.set("utm_source", "html-backup");
   2282    supportURI.searchParams.set("utm_campaign", "fx-backup-restore");
   2283 
   2284    let supportLink = templateDOM.querySelector("#support-link");
   2285    supportLink.href = supportURI.href;
   2286 
   2287    // Now insert the logo as a dataURL, since we want the single-file backup
   2288    // archive to be entirely self-contained.
   2289    let logoResponse = await fetch(LOGO);
   2290    let logoBlob = await logoResponse.blob();
   2291    let logoDataURL = await new Promise((resolve, reject) => {
   2292      let reader = new FileReader();
   2293      reader.addEventListener("load", () => resolve(reader.result));
   2294      reader.addEventListener("error", reject);
   2295      reader.readAsDataURL(logoBlob);
   2296    });
   2297 
   2298    let logoNode = templateDOM.querySelector("#logo");
   2299    logoNode.src = logoDataURL;
   2300 
   2301    let encStateNode = templateDOM.querySelector("#encryption-state-value");
   2302    lazy.gDOMLocalization.setAttributes(
   2303      encStateNode,
   2304      isEncrypted
   2305        ? "backup-file-encryption-state-value-encrypted"
   2306        : "backup-file-encryption-state-value-not-encrypted"
   2307    );
   2308 
   2309    let createdDateNode = templateDOM.querySelector("#creation-date-value");
   2310    lazy.gDOMLocalization.setArgs(createdDateNode, {
   2311      // It's very unlikely that backupMetadata.date isn't a valid Date string,
   2312      // but if it _is_, then Fluent will cause us to crash in debug builds.
   2313      // We fallback to the current date if all else fails.
   2314      date: new Date(backupMetadata.date).getTime() || new Date().getTime(),
   2315    });
   2316 
   2317    let creationDeviceNode = templateDOM.querySelector(
   2318      "#creation-device-value"
   2319    );
   2320    creationDeviceNode.textContent = backupMetadata.machineName;
   2321 
   2322    try {
   2323      await lazy.gDOMLocalization.translateFragment(
   2324        templateDOM.documentElement
   2325      );
   2326    } catch (_) {
   2327      // This shouldn't happen, but we don't want a missing locale string to
   2328      // cause backup creation to fail.
   2329    }
   2330 
   2331    // We have to insert styles and scripts after we serialize to XML, otherwise
   2332    // the XMLSerializer will escape things like descendent selectors in CSS
   2333    // with &gt;.
   2334    let stylesResponse = await fetch(ARCHIVE_STYLES);
   2335    let scriptResponse = await fetch(ARCHIVE_SCRIPT);
   2336 
   2337    // These days, we don't really support CSS preprocessor directives, so we
   2338    // can't ifdef out the MPL license header in styles before writing it into
   2339    // the archive file. Instead, we'll ensure that the license header is there,
   2340    // and then manually remove it here at runtime.
   2341    let stylesText = await stylesResponse.text();
   2342    const MPL_LICENSE = `/**
   2343 * This Source Code Form is subject to the terms of the Mozilla Public
   2344 * License, v. 2.0. If a copy of the MPL was not distributed with this
   2345 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
   2346 */`;
   2347    if (!stylesText.includes(MPL_LICENSE)) {
   2348      throw new BackupError(
   2349        "Expected the MPL license block within archive.css",
   2350        ERRORS.UNKNOWN
   2351      );
   2352    }
   2353 
   2354    stylesText = stylesText.replace(MPL_LICENSE, "");
   2355 
   2356    let serializer = new XMLSerializer();
   2357    return serializer
   2358      .serializeToString(templateDOM)
   2359      .replace("{{styles}}", stylesText)
   2360      .replace("{{script}}", await scriptResponse.text());
   2361  }
   2362 
   2363  /**
   2364   * Creates a portable, potentially encrypted single-file archive containing
   2365   * a compressed backup snapshot. The single-file archive is a specially
   2366   * crafted HTML file that embeds the compressed backup snapshot and
   2367   * backup metadata.
   2368   *
   2369   * @param {string} archivePath
   2370   *   The path to write the single-file archive to.
   2371   * @param {string} templateURI
   2372   *   A URI pointing at a template for the HTML content for the page. This is
   2373   *   what is visible if the file is loaded in a web browser.
   2374   * @param {string} compressedBackupSnapshotPath
   2375   *   The path on the file system where the compressed backup snapshot exists.
   2376   * @param {ArchiveEncryptionState|null} encState
   2377   *   The ArchiveEncryptionState to encrypt the backup with, if encryption is
   2378   *   enabled. If null is passed, the backup will not be encrypted.
   2379   * @param {object} backupMetadata
   2380   *   The metadata for the backup, which is also stored in the backup manifest
   2381   *   of the compressed backup snapshot.
   2382   * @param {object} options
   2383   *   Options to pass to the worker, mainly for testing.
   2384   * @param {object} [options.chunkSize=ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE]
   2385   *   The chunk size to break the bytes into.
   2386   */
   2387  async createArchive(
   2388    archivePath,
   2389    templateURI,
   2390    compressedBackupSnapshotPath,
   2391    encState,
   2392    backupMetadata,
   2393    options = {}
   2394  ) {
   2395    let markup = await this.renderTemplate(
   2396      templateURI,
   2397      !!encState,
   2398      backupMetadata
   2399    );
   2400 
   2401    let worker = new lazy.BasePromiseWorker(
   2402      "resource:///modules/backup/Archive.worker.mjs",
   2403      { type: "module" }
   2404    );
   2405    worker.ExceptionHandlers[BackupError.name] = BackupError.fromMsg;
   2406 
   2407    let chunkSize =
   2408      options.chunkSize || lazy.ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE;
   2409 
   2410    try {
   2411      let encryptionArgs = encState
   2412        ? {
   2413            publicKey: encState.publicKey,
   2414            salt: encState.salt,
   2415            nonce: encState.nonce,
   2416            backupAuthKey: encState.backupAuthKey,
   2417            wrappedSecrets: encState.wrappedSecrets,
   2418          }
   2419        : null;
   2420 
   2421      await worker
   2422        .post("constructArchive", [
   2423          {
   2424            archivePath,
   2425            markup,
   2426            backupMetadata,
   2427            compressedBackupSnapshotPath,
   2428            encryptionArgs,
   2429            chunkSize,
   2430          },
   2431        ])
   2432        .catch(e => {
   2433          lazy.logConsole.error(e);
   2434          if (!(e instanceof BackupError)) {
   2435            throw new BackupError("Failed to create archive", ERRORS.UNKNOWN);
   2436          }
   2437          throw e;
   2438        });
   2439    } finally {
   2440      worker.terminate();
   2441    }
   2442  }
   2443 
   2444  /**
   2445   * Constructs an nsIChannel that serves the bytes from an nsIInputStream -
   2446   * specifically, a nsIInputStream of bytes being streamed from a file.
   2447   *
   2448   * @see BackupService.#extractMetadataFromArchive()
   2449   * @param {nsIInputStream} inputStream
   2450   *   The nsIInputStream to create the nsIChannel for.
   2451   * @param {string} contentType
   2452   *   The content type for the nsIChannel. This is provided by
   2453   *   BackupService.#extractMetadataFromArchive().
   2454   * @returns {nsIChannel}
   2455   */
   2456  #createExtractionChannel(inputStream, contentType) {
   2457    let uri = "http://localhost";
   2458    let httpChan = lazy.NetUtil.newChannel({
   2459      uri,
   2460      loadUsingSystemPrincipal: true,
   2461    });
   2462 
   2463    let channel = Cc["@mozilla.org/network/input-stream-channel;1"]
   2464      .createInstance(Ci.nsIInputStreamChannel)
   2465      .QueryInterface(Ci.nsIChannel);
   2466 
   2467    channel.setURI(httpChan.URI);
   2468    channel.loadInfo = httpChan.loadInfo;
   2469 
   2470    channel.contentStream = inputStream;
   2471    channel.contentType = contentType;
   2472    return channel;
   2473  }
   2474 
   2475  /**
   2476   * A helper for BackupService.extractCompressedSnapshotFromArchive() that
   2477   * reads in the JSON block from the MIME message embedded within an
   2478   * archiveFile.
   2479   *
   2480   * @see BackupService.extractCompressedSnapshotFromArchive()
   2481   * @param {nsIFile} archiveFile
   2482   *   The file to read the MIME message out from.
   2483   * @param {number} startByteOffset
   2484   *   The start byte offset of the MIME message.
   2485   * @param {string} contentType
   2486   *   The Content-Type of the MIME message.
   2487   * @returns {Promise<object>}
   2488   */
   2489  async #extractJSONFromArchive(archiveFile, startByteOffset, contentType) {
   2490    let fileInputStream = Cc[
   2491      "@mozilla.org/network/file-input-stream;1"
   2492    ].createInstance(Ci.nsIFileInputStream);
   2493    fileInputStream.init(
   2494      archiveFile,
   2495      -1,
   2496      -1,
   2497      Ci.nsIFileInputStream.CLOSE_ON_EOF
   2498    );
   2499    fileInputStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, startByteOffset);
   2500 
   2501    const EXPECTED_CONTENT_TYPE = "application/json";
   2502 
   2503    let extractionChannel = this.#createExtractionChannel(
   2504      fileInputStream,
   2505      contentType
   2506    );
   2507    let textDecoder = new TextDecoder();
   2508    return new Promise((resolve, reject) => {
   2509      let streamConv = Cc["@mozilla.org/streamConverters;1"].getService(
   2510        Ci.nsIStreamConverterService
   2511      );
   2512      let multipartListenerForJSON = {
   2513        /**
   2514         * True once we've found an attachment matching our
   2515         * EXPECTED_CONTENT_TYPE. Once this is true, bytes flowing into
   2516         * onDataAvailable will be enqueued through the controller.
   2517         *
   2518         * @type {boolean}
   2519         */
   2520        _enabled: false,
   2521 
   2522        /**
   2523         * True once onStopRequest has been called once the listener is enabled.
   2524         * After this, the listener will not attempt to read any data passed
   2525         * to it through onDataAvailable.
   2526         *
   2527         * @type {boolean}
   2528         */
   2529        _done: false,
   2530 
   2531        /**
   2532         * A buffer with which we will cobble together the JSON string that
   2533         * will get parsed once the attachment finishes being read in.
   2534         *
   2535         * @type {string}
   2536         */
   2537        _buffer: "",
   2538 
   2539        QueryInterface: ChromeUtils.generateQI([
   2540          "nsIStreamListener",
   2541          "nsIRequestObserver",
   2542          "nsIMultiPartChannelListener",
   2543        ]),
   2544 
   2545        /**
   2546         * Called when we begin to load an attachment from the MIME message.
   2547         *
   2548         * @param {nsIRequest} request
   2549         *   The request corresponding to the source of the data.
   2550         */
   2551        onStartRequest(request) {
   2552          if (!(request instanceof Ci.nsIChannel)) {
   2553            throw Components.Exception(
   2554              "onStartRequest expected an nsIChannel request",
   2555              Cr.NS_ERROR_UNEXPECTED
   2556            );
   2557          }
   2558          this._enabled = request.contentType == EXPECTED_CONTENT_TYPE;
   2559        },
   2560 
   2561        /**
   2562         * Called when data is flowing in for an attachment.
   2563         *
   2564         * @param {nsIRequest} request
   2565         *   The request corresponding to the source of the data.
   2566         * @param {nsIInputStream} stream
   2567         *   The input stream containing the data chunk.
   2568         * @param {number} offset
   2569         *   The number of bytes that were sent in previous onDataAvailable
   2570         *   calls for this request. In other words, the sum of all previous
   2571         *   count parameters.
   2572         * @param {number} count
   2573         *   The number of bytes available in the stream
   2574         */
   2575        onDataAvailable(request, stream, offset, count) {
   2576          if (this._done) {
   2577            // No need to load anything else - abort reading in more
   2578            // attachments.
   2579            throw Components.Exception(
   2580              "Got JSON block. Aborting further reads.",
   2581              Cr.NS_BINDING_ABORTED
   2582            );
   2583          }
   2584          if (!this._enabled) {
   2585            // We don't care about this data, just move on.
   2586            return;
   2587          }
   2588 
   2589          let binStream = new lazy.BinaryInputStream(stream);
   2590          let arrBuffer = new ArrayBuffer(count);
   2591          binStream.readArrayBuffer(count, arrBuffer);
   2592          let jsonBytes = new Uint8Array(arrBuffer);
   2593          this._buffer += textDecoder.decode(jsonBytes);
   2594        },
   2595 
   2596        /**
   2597         * Called when the load of an attachment finishes.
   2598         */
   2599        onStopRequest() {
   2600          if (this._enabled && !this._done) {
   2601            this._enabled = false;
   2602            this._done = true;
   2603 
   2604            try {
   2605              let archiveMetadata = JSON.parse(this._buffer);
   2606              resolve(archiveMetadata);
   2607            } catch (e) {
   2608              reject(
   2609                new BackupError(
   2610                  "Could not parse archive metadata.",
   2611                  ERRORS.CORRUPTED_ARCHIVE
   2612                )
   2613              );
   2614            }
   2615          }
   2616        },
   2617 
   2618        onAfterLastPart() {
   2619          if (!this._done) {
   2620            // We finished reading the parts before we found the JSON block, so
   2621            // the JSON block is missing.
   2622            reject(
   2623              new BackupError(
   2624                "Could not find JSON block.",
   2625                ERRORS.CORRUPTED_ARCHIVE
   2626              )
   2627            );
   2628          }
   2629        },
   2630      };
   2631      let conv = streamConv.asyncConvertData(
   2632        "multipart/mixed",
   2633        "*/*",
   2634        multipartListenerForJSON,
   2635        null
   2636      );
   2637 
   2638      extractionChannel.asyncOpen(conv);
   2639    });
   2640  }
   2641 
   2642  /**
   2643   * A helper for BackupService.#extractCompressedSnapshotFromArchive that
   2644   * constructs a BinaryReadableStream for a single-file archive on the
   2645   * file system. The BinaryReadableStream will be used to read out the binary
   2646   * attachment from the archive.
   2647   *
   2648   * @param {nsIFile} archiveFile
   2649   *   The single-file archive to create the BinaryReadableStream for.
   2650   * @param {number} startByteOffset
   2651   *   The start byte offset of the MIME message.
   2652   * @param {string} contentType
   2653   *   The Content-Type of the MIME message.
   2654   * @returns {ReadableStream}
   2655   */
   2656  async createBinaryReadableStream(archiveFile, startByteOffset, contentType) {
   2657    let fileInputStream = Cc[
   2658      "@mozilla.org/network/file-input-stream;1"
   2659    ].createInstance(Ci.nsIFileInputStream);
   2660    fileInputStream.init(
   2661      archiveFile,
   2662      -1,
   2663      -1,
   2664      Ci.nsIFileInputStream.CLOSE_ON_EOF
   2665    );
   2666    fileInputStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, startByteOffset);
   2667 
   2668    let extractionChannel = this.#createExtractionChannel(
   2669      fileInputStream,
   2670      contentType
   2671    );
   2672 
   2673    return new ReadableStream(new BinaryReadableStream(extractionChannel));
   2674  }
   2675 
   2676  /**
   2677   * @typedef {object} SampleArchiveResult
   2678   * @property {boolean} isEncrypted
   2679   *   True if the archive claims to be encrypted, and has the necessary data
   2680   *   within the JSON block to attempt to initialize an ArchiveDecryptor.
   2681   * @property {number} startByteOffset
   2682   *   The start byte offset of the MIME message.
   2683   * @property {string} contentType
   2684   *   The Content-Type of the MIME message.
   2685   * @property {object} archiveJSON
   2686   *   The deserialized JSON block from the archive. See the ArchiveJSONBlock
   2687   *   schema for details of its structure.
   2688   */
   2689 
   2690  /**
   2691   * Reads from a file to determine if it seems to be a backup archive, and if
   2692   * so, resolves with some information about the archive without actually
   2693   * unpacking it. The returned Promise may reject if the file does not appear
   2694   * to be a backup archive, or the backup archive appears to have been
   2695   * corrupted somehow.
   2696   *
   2697   * @param {string} archivePath
   2698   *   The path to the archive file to sample.
   2699   * @returns {Promise<SampleArchiveResult, Error>}
   2700   */
   2701  async sampleArchive(archivePath) {
   2702    let worker = new lazy.BasePromiseWorker(
   2703      "resource:///modules/backup/Archive.worker.mjs",
   2704      { type: "module" }
   2705    );
   2706    worker.ExceptionHandlers[BackupError.name] = BackupError.fromMsg;
   2707 
   2708    if (!(await IOUtils.exists(archivePath))) {
   2709      throw new BackupError(
   2710        "Archive file does not exist at path " + archivePath,
   2711        ERRORS.UNKNOWN
   2712      );
   2713    }
   2714 
   2715    try {
   2716      let { startByteOffset, contentType } = await worker
   2717        .post("parseArchiveHeader", [archivePath])
   2718        .catch(e => {
   2719          lazy.logConsole.error(e);
   2720          if (!(e instanceof BackupError)) {
   2721            throw new BackupError(
   2722              "Failed to parse archive header",
   2723              ERRORS.CORRUPTED_ARCHIVE
   2724            );
   2725          }
   2726          throw e;
   2727        });
   2728      let archiveFile = await IOUtils.getFile(archivePath);
   2729      let archiveJSON;
   2730      try {
   2731        archiveJSON = await this.#extractJSONFromArchive(
   2732          archiveFile,
   2733          startByteOffset,
   2734          contentType
   2735        );
   2736 
   2737        if (!archiveJSON.version) {
   2738          throw new BackupError(
   2739            "Missing version in the archive JSON block.",
   2740            ERRORS.CORRUPTED_ARCHIVE
   2741          );
   2742        }
   2743        if (archiveJSON.version > lazy.ArchiveUtils.SCHEMA_VERSION) {
   2744          throw new BackupError(
   2745            `Archive JSON block is a version newer than we can interpret: ${archiveJSON.version}`,
   2746            ERRORS.UNSUPPORTED_BACKUP_VERSION
   2747          );
   2748        }
   2749 
   2750        let archiveJSONSchema = await BackupService.getSchemaForVersion(
   2751          SCHEMAS.ARCHIVE_JSON_BLOCK,
   2752          archiveJSON.version
   2753        );
   2754 
   2755        let manifestSchema = await BackupService.getSchemaForVersion(
   2756          SCHEMAS.BACKUP_MANIFEST,
   2757          archiveJSON.version
   2758        );
   2759 
   2760        let validator = new lazy.JsonSchema.Validator(archiveJSONSchema);
   2761        validator.addSchema(manifestSchema);
   2762 
   2763        let schemaValidationResult = validator.validate(archiveJSON);
   2764        if (!schemaValidationResult.valid) {
   2765          lazy.logConsole.error(
   2766            "Archive JSON block does not conform to schema:",
   2767            archiveJSON,
   2768            archiveJSONSchema,
   2769            schemaValidationResult
   2770          );
   2771 
   2772          // TODO: Collect telemetry for this case. (bug 1891817)
   2773          throw new BackupError(
   2774            `Archive JSON block does not conform to schema version ${archiveJSON.version}`,
   2775            ERRORS.CORRUPTED_ARCHIVE
   2776          );
   2777        }
   2778      } catch (e) {
   2779        lazy.logConsole.error(e);
   2780        throw e;
   2781      }
   2782 
   2783      lazy.logConsole.debug("Read out archive JSON: ", archiveJSON);
   2784 
   2785      return {
   2786        isEncrypted: !!archiveJSON.encConfig,
   2787        startByteOffset,
   2788        contentType,
   2789        archiveJSON,
   2790      };
   2791    } catch (e) {
   2792      lazy.logConsole.error(e);
   2793      throw e;
   2794    } finally {
   2795      worker.terminate();
   2796    }
   2797  }
   2798 
   2799  /**
   2800   * Attempts to extract the compressed backup snapshot from a single-file
   2801   * archive, and write the extracted file to extractionDestPath. This may
   2802   * reject if the single-file archive appears malformed or cannot be
   2803   * properly decrypted. If the backup was encrypted, a native nsIOSKeyStore
   2804   * is also initialized with label BackupService.RECOVERY_OSKEYSTORE_LABEL
   2805   * with the secret used on the original backup machine. Callers are
   2806   * responsible for clearing this secret after any decryptions with it are
   2807   * completed.
   2808   *
   2809   * NOTE: Currently, this base64 decoding currently occurs on the main thread.
   2810   * We may end up moving all of this into the Archive Worker if we can modify
   2811   * IOUtils to allow writing via a stream.
   2812   *
   2813   * @param {string} archivePath
   2814   *   The single-file archive that contains the backup.
   2815   * @param {string} extractionDestPath
   2816   *   The path to write the extracted file to.
   2817   * @param {string} [recoveryCode=null]
   2818   *   The recovery code to decrypt an encrypted backup with.
   2819   * @returns {Promise<undefined, Error>}
   2820   */
   2821  async extractCompressedSnapshotFromArchive(
   2822    archivePath,
   2823    extractionDestPath,
   2824    recoveryCode = null
   2825  ) {
   2826    let { isEncrypted, startByteOffset, contentType, archiveJSON } =
   2827      await this.sampleArchive(archivePath);
   2828 
   2829    let decryptor = null;
   2830    if (isEncrypted) {
   2831      if (!recoveryCode) {
   2832        throw new BackupError(
   2833          "A recovery code is required to decrypt this archive.",
   2834          ERRORS.UNAUTHORIZED
   2835        );
   2836      }
   2837      decryptor = await lazy.ArchiveDecryptor.initialize(
   2838        recoveryCode,
   2839        archiveJSON
   2840      );
   2841    }
   2842 
   2843    await IOUtils.remove(extractionDestPath, {
   2844      ignoreAbsent: true,
   2845      retryReadonly: true,
   2846    });
   2847 
   2848    let archiveFile = await IOUtils.getFile(archivePath);
   2849    let archiveStream = await this.createBinaryReadableStream(
   2850      archiveFile,
   2851      startByteOffset,
   2852      contentType
   2853    );
   2854 
   2855    let binaryDecoder = new TransformStream(
   2856      new DecoderDecryptorTransformer(decryptor)
   2857    );
   2858    let fileWriter = new WritableStream(
   2859      new FileWriterStream(extractionDestPath, decryptor)
   2860    );
   2861    await archiveStream.pipeThrough(binaryDecoder).pipeTo(fileWriter);
   2862 
   2863    if (decryptor) {
   2864      await lazy.nativeOSKeyStore.asyncRecoverSecret(
   2865        BackupService.RECOVERY_OSKEYSTORE_LABEL,
   2866        decryptor.OSKeyStoreSecret
   2867      );
   2868    }
   2869  }
   2870 
   2871  /**
   2872   * Renames the staging folder to an ISO 8601 date string with dashes replacing colons and fractional seconds stripped off.
   2873   * The ISO date string should be formatted from YYYY-MM-DDTHH:mm:ss.sssZ to YYYY-MM-DDTHH-mm-ssZ
   2874   *
   2875   * @param {string} stagingPath
   2876   *   The path to the populated staging folder.
   2877   * @returns {Promise<string|null>}
   2878   *   The path to the renamed staging folder, or null if the stagingPath was
   2879   *   not pointing to a valid folder.
   2880   */
   2881  async #finalizeStagingFolder(stagingPath) {
   2882    if (!(await IOUtils.exists(stagingPath))) {
   2883      // If we somehow can't find the specified staging folder, cancel this step.
   2884      lazy.logConsole.error(
   2885        `Failed to finalize staging folder. Cannot find ${stagingPath}.`
   2886      );
   2887      return null;
   2888    }
   2889 
   2890    try {
   2891      lazy.logConsole.debug("Finalizing and renaming staging folder");
   2892      let currentDateISO = new Date().toISOString();
   2893      // First strip the fractional seconds
   2894      let dateISOStripped = currentDateISO.replace(/\.\d+\Z$/, "Z");
   2895      // Now replace all colons with dashes
   2896      let dateISOFormatted = dateISOStripped.replaceAll(":", "-");
   2897 
   2898      let stagingPathParent = PathUtils.parent(stagingPath);
   2899      let renamedBackupPath = PathUtils.join(
   2900        stagingPathParent,
   2901        dateISOFormatted
   2902      );
   2903      await IOUtils.move(stagingPath, renamedBackupPath);
   2904 
   2905      let existingBackups = await IOUtils.getChildren(stagingPathParent);
   2906 
   2907      /**
   2908       * Bug 1892532: for now, we only support a single backup file.
   2909       * If there are other pre-existing backup folders, delete them - but don't
   2910       * delete anything that doesn't match the backup folder naming scheme.
   2911       */
   2912      let expectedFormatRegex = /\d{4}(-\d{2}){2}T(\d{2}-){2}\d{2}Z/;
   2913      for (let existingBackupPath of existingBackups) {
   2914        if (
   2915          existingBackupPath !== renamedBackupPath &&
   2916          existingBackupPath.match(expectedFormatRegex)
   2917        ) {
   2918          try {
   2919            // If any copied source files were read-only then we need to remove
   2920            // read-only status from them to delete the staging folder.
   2921            await IOUtils.remove(existingBackupPath, {
   2922              recursive: true,
   2923              retryReadonly: true,
   2924            });
   2925          } catch (e) {
   2926            // Ignore any failures in removing staging items.
   2927            lazy.logConsole.debug(
   2928              `Failed to remove staging item ${existingBackupPath}. Exception ${e}`
   2929            );
   2930          }
   2931        }
   2932      }
   2933      return renamedBackupPath;
   2934    } catch (e) {
   2935      lazy.logConsole.error(
   2936        `Something went wrong while finalizing the staging folder. ${e}`
   2937      );
   2938      throw new BackupError(
   2939        "Failed to finalize staging folder",
   2940        ERRORS.FILE_SYSTEM_ERROR
   2941      );
   2942    }
   2943  }
   2944 
   2945  /**
   2946   * Creates and resolves with a backup manifest object with an empty resources
   2947   * property. See the BackupManifest schema for the specific shape of the
   2948   * returned manifest object.
   2949   *
   2950   * @returns {Promise<object>}
   2951   */
   2952  async #createBackupManifest() {
   2953    let profileSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService(
   2954      Ci.nsIToolkitProfileService
   2955    );
   2956    let profileName;
   2957    if (!profileSvc.currentProfile) {
   2958      // We're probably running on a local build or in some special configuration.
   2959      // Let's pull in a profile name from the profile directory.
   2960      let profileFolder = PathUtils.split(PathUtils.profileDir).at(-1);
   2961      profileName = profileFolder.substring(profileFolder.indexOf(".") + 1);
   2962    } else {
   2963      profileName = profileSvc.currentProfile.name;
   2964    }
   2965 
   2966    let meta = {
   2967      date: new Date().toISOString(),
   2968      appName: AppConstants.MOZ_APP_NAME,
   2969      appVersion: AppConstants.MOZ_APP_VERSION,
   2970      buildID: AppConstants.MOZ_BUILDID,
   2971      profileName,
   2972      deviceName: Services.sysinfo.get("device") || Services.dns.myHostName,
   2973      machineName: lazy.fxAccounts.device.getLocalName(),
   2974      osName: Services.sysinfo.getProperty("name"),
   2975      osVersion: Services.sysinfo.getProperty("version"),
   2976      legacyClientID: await lazy.ClientID.getClientID(),
   2977      profileGroupID: await lazy.ClientID.getProfileGroupID(),
   2978      healthTelemetryEnabled: Services.prefs.getBoolPref(
   2979        "datareporting.healthreport.uploadEnabled",
   2980        false
   2981      ),
   2982      usageTelemetryEnabled: Services.prefs.getBoolPref(
   2983        "datareporting.usage.uploadEnabled",
   2984        false
   2985      ),
   2986    };
   2987 
   2988    let fxaState = lazy.UIState.get();
   2989    if (fxaState.status == lazy.UIState.STATUS_SIGNED_IN) {
   2990      meta.accountID = fxaState.uid;
   2991      meta.accountEmail = fxaState.email;
   2992    }
   2993 
   2994    return {
   2995      version: lazy.ArchiveUtils.SCHEMA_VERSION,
   2996      meta,
   2997      resources: {},
   2998    };
   2999  }
   3000 
   3001  /**
   3002   * Given a backup archive at archivePath, this method does the
   3003   * following:
   3004   *
   3005   * 1. Potentially decrypts, and then extracts the compressed backup snapshot
   3006   *    from the archive to a file named BackupService.RECOVERY_ZIP_FILE_NAME in
   3007   *    the PROFILE_FOLDER_NAME folder.
   3008   * 2. Decompresses that file into a subdirectory of PROFILE_FOLDER_NAME named
   3009   *    "recovery".
   3010   * 3. Deletes the BackupService.RECOVERY_ZIP_FILE_NAME file.
   3011   * 4. Calls into recoverFromSnapshotFolder on the decompressed "recovery"
   3012   *    folder.
   3013   * 5. Optionally launches the newly created profile.
   3014   * 6. Returns the name of the newly created profile directory.
   3015   *
   3016   * @see BackupService.recoverFromSnapshotFolder
   3017   * @param {string} archivePath
   3018   *   The path to the single-file backup archive on the file system.
   3019   * @param {string|null} recoveryCode
   3020   *   The recovery code to use to attempt to decrypt the archive if it was
   3021   *   encrypted.
   3022   * @param {boolean} [shouldLaunch=false]
   3023   *   An optional argument that specifies whether an instance of the app
   3024   *   should be launched with the newly recovered profile after recovery is
   3025   *   complete.
   3026   * @param {boolean} [profilePath=PathUtils.profileDir]
   3027   *   The profile path where the recovery files will be written to within the
   3028   *   PROFILE_FOLDER_NAME. This is only used for testing.
   3029   * @param {string} [profileRootPath=null]
   3030   *   An optional argument that specifies the root directory where the new
   3031   *   profile directory should be created. If not provided, the default
   3032   *   profile root directory will be used. This is primarily meant for
   3033   *   testing.
   3034   * @returns {Promise<nsIToolkitProfile>}
   3035   *   The nsIToolkitProfile that was created for the recovered profile.
   3036   * @throws {Exception}
   3037   *   In the event that unpacking the archive, decompressing the snapshot, or
   3038   *   recovery from the snapshot somehow failed.
   3039   */
   3040  async recoverFromBackupArchive(
   3041    archivePath,
   3042    recoveryCode = null,
   3043    shouldLaunch = false,
   3044    profilePath = PathUtils.profileDir,
   3045    profileRootPath = null
   3046  ) {
   3047    const status = this.restoreEnabledStatus;
   3048    if (!status.enabled) {
   3049      throw new Error(status.reason);
   3050    }
   3051 
   3052    // No concurrent recoveries.
   3053    if (this.#_state.recoveryInProgress) {
   3054      lazy.logConsole.warn("Recovery attempt already in progress");
   3055      return null;
   3056    }
   3057 
   3058    Glean.browserBackup.restoreStarted.record({
   3059      restore_id: this.#_state.restoreID,
   3060    });
   3061 
   3062    try {
   3063      this.#_state.recoveryInProgress = true;
   3064      this.#_state.recoveryErrorCode = 0;
   3065      this.stateUpdate();
   3066      const RECOVERY_FILE_DEST_PATH = PathUtils.join(
   3067        profilePath,
   3068        BackupService.PROFILE_FOLDER_NAME,
   3069        BackupService.RECOVERY_ZIP_FILE_NAME
   3070      );
   3071      await this.extractCompressedSnapshotFromArchive(
   3072        archivePath,
   3073        RECOVERY_FILE_DEST_PATH,
   3074        recoveryCode
   3075      );
   3076 
   3077      let encState = null;
   3078      if (recoveryCode) {
   3079        // We were passed a recovery code and made it to this line. That implies
   3080        // that the backup was encrypted, and the recovery code was the correct
   3081        // one to decrypt it. We now generate a new ArchiveEncryptionState with
   3082        // that recovery code to write into the recovered profile.
   3083        ({ instance: encState } =
   3084          await lazy.ArchiveEncryptionState.initialize(recoveryCode));
   3085      }
   3086 
   3087      const RECOVERY_FOLDER_DEST_PATH = PathUtils.join(
   3088        profilePath,
   3089        BackupService.PROFILE_FOLDER_NAME,
   3090        "recovery"
   3091      );
   3092      await this.decompressRecoveryFile(
   3093        RECOVERY_FILE_DEST_PATH,
   3094        RECOVERY_FOLDER_DEST_PATH
   3095      );
   3096 
   3097      // Now that we've decompressed it, reclaim some disk space by getting rid of
   3098      // the ZIP file.
   3099      try {
   3100        await IOUtils.remove(RECOVERY_FILE_DEST_PATH, { retryReadonly: true });
   3101      } catch (_) {
   3102        lazy.logConsole.warn("Could not remove ", RECOVERY_FILE_DEST_PATH);
   3103      }
   3104 
   3105      try {
   3106        // We're using a try/finally here to clean up the temporary OSKeyStore.
   3107        // We need to make sure that cleanup occurs _after_ the recovery has
   3108        // either fully succeeded, or fully failed. We await the return value
   3109        // of recoverFromSnapshotFolder so that the finally will not execute
   3110        // until after recoverFromSnapshotFolder has finished resolving or
   3111        // rejecting.
   3112        let newProfile = await this.recoverFromSnapshotFolder(
   3113          RECOVERY_FOLDER_DEST_PATH,
   3114          shouldLaunch,
   3115          profileRootPath,
   3116          encState
   3117        );
   3118 
   3119        Glean.browserBackup.restoreComplete.record({
   3120          restore_id: this.#_state.restoreID,
   3121        });
   3122        // We are probably about to shutdown, so we want to submit this ASAP.
   3123        // But this will also clear out the data in this ping, which is a bit
   3124        // of a problem for testing. So fire off an event first that tests can
   3125        // listen for.
   3126        Services.obs.notifyObservers(null, "browser-backup-restore-complete");
   3127        GleanPings.profileRestore.submit();
   3128 
   3129        return newProfile;
   3130      } finally {
   3131        // If we had decrypted a backup, we would have created the temporary
   3132        // recovery OSKeyStore row with the label
   3133        // BackupService.RECOVERY_OSKEYSTORE_LABEL, which we will now delete,
   3134        // no matter if we succeeded or failed to recover.
   3135        //
   3136        // Note that according to nsIOSKeyStore, this is a no-op in the event that
   3137        // no secret exists at BackupService.RECOVERY_OSKEYSTORE_LABEL, so we're
   3138        // fine to do this even if we were recovering from an unencrypted
   3139        // backup.
   3140        if (recoveryCode) {
   3141          await lazy.nativeOSKeyStore.asyncDeleteSecret(
   3142            BackupService.RECOVERY_OSKEYSTORE_LABEL
   3143          );
   3144        }
   3145      }
   3146    } catch (ex) {
   3147      Glean.browserBackup.restoreFailed.record({
   3148        restore_id: this.#_state.restoreID,
   3149        error_type: errorString(ex.cause),
   3150      });
   3151      throw ex;
   3152    } finally {
   3153      this.#_state.recoveryInProgress = false;
   3154      this.stateUpdate();
   3155    }
   3156  }
   3157 
   3158  /**
   3159   * Given a recovery path, read in the backup manifest from the archive and
   3160   * ensures that it is valid. Will throw an error for an invalid manifest.
   3161   *
   3162   * @param {string} recoveryPath The path to the decompressed backup archive
   3163   *   on the file system.
   3164   * @returns {object} See the BackupManifest schema for the specific shape of the
   3165   * returned manifest object.
   3166   */
   3167  async #readAndValidateManifest(recoveryPath) {
   3168    // Read in the backup manifest.
   3169    let manifestPath = PathUtils.join(
   3170      recoveryPath,
   3171      BackupService.MANIFEST_FILE_NAME
   3172    );
   3173 
   3174    let manifest = await IOUtils.readJSON(manifestPath);
   3175    if (!manifest.version) {
   3176      throw new BackupError(
   3177        "Backup manifest version not found",
   3178        ERRORS.CORRUPTED_ARCHIVE
   3179      );
   3180    }
   3181 
   3182    if (manifest.version > lazy.ArchiveUtils.SCHEMA_VERSION) {
   3183      throw new BackupError(
   3184        "Cannot recover from a manifest newer than the current schema version",
   3185        ERRORS.UNSUPPORTED_BACKUP_VERSION
   3186      );
   3187    }
   3188 
   3189    // Make sure that it conforms to the schema.
   3190    let manifestSchema = await BackupService.getSchemaForVersion(
   3191      SCHEMAS.BACKUP_MANIFEST,
   3192      manifest.version
   3193    );
   3194    let schemaValidationResult = lazy.JsonSchema.validate(
   3195      manifest,
   3196      manifestSchema
   3197    );
   3198    if (!schemaValidationResult.valid) {
   3199      lazy.logConsole.error(
   3200        "Backup manifest does not conform to schema:",
   3201        manifest,
   3202        manifestSchema,
   3203        schemaValidationResult
   3204      );
   3205      // TODO: Collect telemetry for this case. (bug 1891817)
   3206      throw new BackupError(
   3207        "Cannot recover from an invalid backup manifest",
   3208        ERRORS.CORRUPTED_ARCHIVE
   3209      );
   3210    }
   3211 
   3212    // In the future, if we ever bump the ArchiveUtils.SCHEMA_VERSION and need
   3213    // to do any special behaviours to interpret older schemas, this is where
   3214    // we can do that, and we can remove this comment.
   3215 
   3216    let meta = manifest.meta;
   3217 
   3218    if (meta.appName != AppConstants.MOZ_APP_NAME) {
   3219      throw new BackupError(
   3220        `Cannot recover a backup from ${meta.appName} in ${AppConstants.MOZ_APP_NAME}`,
   3221        ERRORS.UNSUPPORTED_APPLICATION
   3222      );
   3223    }
   3224 
   3225    if (
   3226      Services.vc.compare(AppConstants.MOZ_APP_VERSION, meta.appVersion) < 0
   3227    ) {
   3228      throw new BackupError(
   3229        `Cannot recover a backup created on version ${meta.appVersion} in ${AppConstants.MOZ_APP_VERSION}`,
   3230        ERRORS.UNSUPPORTED_BACKUP_VERSION
   3231      );
   3232    }
   3233 
   3234    return manifest;
   3235  }
   3236 
   3237  /**
   3238   * Iterates over each resource in the manifest and calls the recover() method
   3239   * on each found BackupResource, passing in the associated ManifestEntry from
   3240   * the backup manifest, and collects any post-recovery data from those
   3241   * resources.
   3242   *
   3243   * @param {object} manifest See the BackupManifest schema for the specific
   3244   *   shape of the returned manifest object.
   3245   * @param {string} recoveryPath The path to the decompressed backup archive
   3246   *   on the file system.
   3247   * @param {string} profilePath The path of the newly recovered profile
   3248   * @returns {object}
   3249   *   An object containing post recovery data for each resource.
   3250   */
   3251  async #recoverResources(manifest, recoveryPath, profilePath) {
   3252    let postRecovery = {};
   3253 
   3254    // Iterate over each resource in the manifest and call recover() on each
   3255    // associated BackupResource.
   3256    for (let resourceKey in manifest.resources) {
   3257      let manifestEntry = manifest.resources[resourceKey];
   3258      let resourceClass = this.#resources.get(resourceKey);
   3259      if (!resourceClass) {
   3260        lazy.logConsole.error(`No BackupResource found for key ${resourceKey}`);
   3261        continue;
   3262      }
   3263 
   3264      try {
   3265        lazy.logConsole.debug(
   3266          `Restoring resource with key ${resourceKey}. ` +
   3267            `Requires encryption: ${resourceClass.requiresEncryption}`
   3268        );
   3269        let resourcePath = PathUtils.join(recoveryPath, resourceKey);
   3270        let postRecoveryEntry = await new resourceClass().recover(
   3271          manifestEntry,
   3272          resourcePath,
   3273          profilePath
   3274        );
   3275        postRecovery[resourceKey] = postRecoveryEntry;
   3276      } catch (e) {
   3277        lazy.logConsole.error(`Failed to recover resource: ${resourceKey}`, e);
   3278        throw e;
   3279      }
   3280    }
   3281 
   3282    return postRecovery;
   3283  }
   3284 
   3285  /**
   3286   * If the encState exists, write the encrypted state object to the
   3287   * ARCHIVE_ENCRYPTION_STATE_FILE.
   3288   *
   3289   * @param {ArchiveEncryptionState|null} encState Set if the backup being
   3290   *   recovered was encrypted. This implies that the profile being recovered
   3291   *   was configured to create encrypted backups. This ArchiveEncryptionState
   3292   *   is therefore needed to generate the ARCHIVE_ENCRYPTION_STATE_FILE for
   3293   *   the recovered profile (since the original ARCHIVE_ENCRYPTION_STATE_FILE
   3294   *   was intentionally not backed up, as the recovery device might have a
   3295   *   different OSKeyStore secret).
   3296   * @param {string} profilePath The path of the newly recovered profile
   3297   */
   3298  async #maybeWriteEncryptedStateObject(encState, profilePath) {
   3299    if (encState) {
   3300      // The backup we're recovering was originally encrypted, meaning that
   3301      // the recovered profile is configured to create encrypted backups. Our
   3302      // caller passed us a _new_ ArchiveEncryptionState generated for this
   3303      // device with the backup's recovery code so that we can serialize the
   3304      // ArchiveEncryptionState for the recovered profile.
   3305      let encStatePath = PathUtils.join(
   3306        profilePath,
   3307        BackupService.PROFILE_FOLDER_NAME,
   3308        BackupService.ARCHIVE_ENCRYPTION_STATE_FILE
   3309      );
   3310      let encStateObject = await encState.serialize();
   3311      await IOUtils.writeJSON(encStatePath, encStateObject);
   3312    }
   3313  }
   3314 
   3315  /**
   3316   * Write the post recovery data to the newly recovered profile.
   3317   *
   3318   * @param {object} postRecoveryData An object containing post recovery data
   3319   *   from each resource recovered.
   3320   * @param {string} profilePath The path of the newly recovered profile
   3321   */
   3322  async #writePostRecoveryData(postRecoveryData, profilePath) {
   3323    let postRecoveryPath = PathUtils.join(
   3324      profilePath,
   3325      BackupService.POST_RECOVERY_FILE_NAME
   3326    );
   3327    await IOUtils.writeJSON(postRecoveryPath, postRecoveryData);
   3328  }
   3329 
   3330  /**
   3331   * Given a decompressed backup archive at recoveryPath, this method does the
   3332   * following:
   3333   *
   3334   * 1. Reads in the backup manifest from the archive and ensures that it is
   3335   *    valid.
   3336   * 2. Creates a new named profile directory using the same name as the one
   3337   *    found in the backup manifest, but with a different prefix.
   3338   * 3. Iterates over each resource in the manifest and calls the recover()
   3339   *    method on each found BackupResource, passing in the associated
   3340   *    ManifestEntry from the backup manifest, and collects any post-recovery
   3341   *    data from those resources.
   3342   * 4. Writes a `post-recovery.json` file into the newly created profile
   3343   *    directory.
   3344   * 5. Returns the name of the newly created profile directory.
   3345   * 6. Regardless of whether or not recovery succeeded, clears the native
   3346   *    OSKeyStore of any secret labeled with
   3347   *    BackupService.RECOVERY_OSKEYSTORE_LABEL.
   3348   *
   3349   * @param {string} recoveryPath
   3350   *   The path to the decompressed backup archive on the file system.
   3351   * @param {boolean} [shouldLaunch=false]
   3352   *   An optional argument that specifies whether an instance of the app
   3353   *   should be launched with the newly recovered profile after recovery is
   3354   *   complete.
   3355   * @param {string} [profileRootPath=null]
   3356   *   An optional argument that specifies the root directory where the new
   3357   *   profile directory should be created. If not provided, the default
   3358   *   profile root directory will be used. This is primarily meant for
   3359   *   testing.
   3360   * @param {ArchiveEncryptionState} [encState=null]
   3361   *   Set if the backup being recovered was encrypted. This implies that the
   3362   *   profile being recovered was configured to create encrypted backups. This
   3363   *   ArchiveEncryptionState is therefore needed to generate the
   3364   *   ARCHIVE_ENCRYPTION_STATE_FILE for the recovered profile (since the
   3365   *   original ARCHIVE_ENCRYPTION_STATE_FILE was intentionally not backed up,
   3366   *   as the recovery device might have a different OSKeyStore secret).
   3367   * @returns {Promise<nsIToolkitProfile>}
   3368   *   The nsIToolkitProfile that was created for the recovered profile.
   3369   * @throws {Exception}
   3370   *   In the event that recovery somehow failed.
   3371   */
   3372  async recoverFromSnapshotFolder(
   3373    recoveryPath,
   3374    shouldLaunch = false,
   3375    profileRootPath = null,
   3376    encState = null
   3377  ) {
   3378    lazy.logConsole.debug("Recovering from backup at ", recoveryPath);
   3379 
   3380    try {
   3381      let manifest = await this.#readAndValidateManifest(recoveryPath);
   3382 
   3383      // Okay, we have a valid backup-manifest.json. Let's create a new profile
   3384      // and start invoking the recover() method on each BackupResource.
   3385      let profileSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService(
   3386        Ci.nsIToolkitProfileService
   3387      );
   3388      let profile = profileSvc.createUniqueProfile(
   3389        profileRootPath ? await IOUtils.getDirectory(profileRootPath) : null,
   3390        manifest.meta.profileName
   3391      );
   3392 
   3393      let postRecovery = await this.#recoverResources(
   3394        manifest,
   3395        recoveryPath,
   3396        profile.rootDir.path
   3397      );
   3398 
   3399      try {
   3400        postRecovery.backupServiceInternal = {
   3401          // Indicates that this is not a result of a profile copy (which uses the
   3402          // same mechanism, but doesn't go through this function).
   3403          isProfileRestore: true,
   3404          restoreID: this.#_state.restoreID,
   3405          backupMetadata: {
   3406            date: this.#_state.backupFileInfo.date,
   3407            appName: this.#_state.backupFileInfo.appName,
   3408            appVersion: this.#_state.backupFileInfo.appVersion,
   3409            buildID: this.#_state.backupFileInfo.buildID,
   3410            osName: this.#_state.backupFileInfo.osName,
   3411            osVersion: this.#_state.backupFileInfo.osVersion,
   3412            legacyClientID: this.#_state.backupFileInfo.legacyClientID,
   3413          },
   3414        };
   3415      } catch {}
   3416 
   3417      await this.#maybeWriteEncryptedStateObject(
   3418        encState,
   3419        profile.rootDir.path
   3420      );
   3421 
   3422      await this.#writePostRecoveryData(postRecovery, profile.rootDir.path);
   3423 
   3424      // In a release scenario, this should always be true
   3425      // this makes it easier to get around setting up profiles for testing other functionality
   3426      if (profileSvc.currentProfile) {
   3427        // if our current profile was default, let's make the new one default
   3428        if (profileSvc.currentProfile === profileSvc.defaultProfile) {
   3429          profileSvc.defaultProfile = profile;
   3430        }
   3431 
   3432        // If the profile already has an [old-] prefix, let's skip adding new prefixes
   3433        if (!profileSvc.currentProfile.name.startsWith("old-")) {
   3434          // Looks like this is a new restoration of this profile,
   3435          // add the prefix old-[profile_name]
   3436          profileSvc.currentProfile.name = `old-${profileSvc.currentProfile.name}`;
   3437        }
   3438      }
   3439 
   3440      await profileSvc.asyncFlush();
   3441 
   3442      if (shouldLaunch) {
   3443        // Launch with the user's default homepage instead of the last selected tab
   3444        // to avoid problems with the messaging system (see Bug 2002732)
   3445        Services.startup.createInstanceWithProfile(profile, [
   3446          "--url",
   3447          "about:home",
   3448        ]);
   3449      }
   3450 
   3451      return profile;
   3452    } catch (e) {
   3453      lazy.logConsole.error(
   3454        "Failed to recover from backup at ",
   3455        recoveryPath,
   3456        e
   3457      );
   3458      throw e;
   3459    }
   3460  }
   3461 
   3462  /**
   3463   * Given a decompressed backup archive at recoveryPath, this method does the
   3464   * following:
   3465   *
   3466   * 1. Reads in the backup manifest from the archive and ensures that it is
   3467   *    valid.
   3468   * 2. Creates a new SelectableProfile profile directory using the same name
   3469   *    as the one found in the backup manifest, but with a different prefix.
   3470   * 3. Iterates over each resource in the manifest and calls the recover()
   3471   *    method on each found BackupResource, passing in the associated
   3472   *    ManifestEntry from the backup manifest, and collects any post-recovery
   3473   *    data from those resources.
   3474   * 4. Writes a `post-recovery.json` file into the newly created profile
   3475   *    directory.
   3476   * 5. Returns the name of the newly created profile directory.
   3477   * 6. Regardless of whether or not recovery succeeded, clears the native
   3478   *    OSKeyStore of any secret labeled with
   3479   *    BackupService.RECOVERY_OSKEYSTORE_LABEL.
   3480   *
   3481   * @param {string} recoveryPath
   3482   *   The path to the decompressed backup archive on the file system.
   3483   * @param {boolean} [shouldLaunch=false]
   3484   *   An optional argument that specifies whether an instance of the app
   3485   *   should be launched with the newly recovered profile after recovery is
   3486   *   complete.
   3487   * @param {ArchiveEncryptionState} [encState=null]
   3488   *   Set if the backup being recovered was encrypted. This implies that the
   3489   *   profile being recovered was configured to create encrypted backups. This
   3490   *   ArchiveEncryptionState is therefore needed to generate the
   3491   *   ARCHIVE_ENCRYPTION_STATE_FILE for the recovered profile (since the
   3492   *   original ARCHIVE_ENCRYPTION_STATE_FILE was intentionally not backed up,
   3493   *   as the recovery device might have a different OSKeyStore secret).
   3494   * @param {SelectableProfile} [copiedProfile=null]
   3495   *   If the profile we are recovering is a "copied" profile, we don't want to
   3496   *   inherit the client ID as this profile will be a new profile in the
   3497   *   profile group. If we are copying a profile, we will use
   3498   *   copiedProfile.name to show that the new profile is a copy of
   3499   *   copiedProfile on about:editprofile.
   3500   * @returns {Promise<SelectableProfile>}
   3501   *   The SelectableProfile that was created for the recovered profile.
   3502   * @throws {Exception}
   3503   *   In the event that recovery somehow failed.
   3504   */
   3505  async recoverFromSnapshotFolderIntoSelectableProfile(
   3506    recoveryPath,
   3507    shouldLaunch = false,
   3508    encState = null,
   3509    copiedProfile = null
   3510  ) {
   3511    lazy.logConsole.debug(
   3512      "Recovering SelectableProfile from backup at ",
   3513      recoveryPath
   3514    );
   3515 
   3516    try {
   3517      let manifest = await this.#readAndValidateManifest(recoveryPath);
   3518 
   3519      // Okay, we have a valid backup-manifest.json. Let's create a new profile
   3520      // and start invoking the recover() method on each BackupResource.
   3521      let profile = await lazy.SelectableProfileService.createNewProfile(false);
   3522 
   3523      let postRecovery = await this.#recoverResources(
   3524        manifest,
   3525        recoveryPath,
   3526        profile.path
   3527      );
   3528 
   3529      await this.#maybeWriteEncryptedStateObject(encState, profile.path);
   3530 
   3531      await this.#writePostRecoveryData(postRecovery, profile.path);
   3532 
   3533      if (shouldLaunch) {
   3534        lazy.SelectableProfileService.launchInstance(
   3535          profile,
   3536          // Using URL Search Params on this about: page didn't work because
   3537          // the RPM communication so we use the hash and parse that instead.
   3538          [
   3539            "about:editprofile" +
   3540              (copiedProfile ? `#copiedProfileName=${copiedProfile.name}` : ""),
   3541          ]
   3542        );
   3543      }
   3544 
   3545      return profile;
   3546    } catch (e) {
   3547      lazy.logConsole.error(
   3548        "Failed to recover SelectableProfile from backup at ",
   3549        recoveryPath,
   3550        e
   3551      );
   3552      throw e;
   3553    }
   3554  }
   3555 
   3556  /**
   3557   * Checks for the POST_RECOVERY_FILE_NAME in the current profile directory.
   3558   * If one exists, instantiates any relevant BackupResource's, and calls
   3559   * postRecovery() on them with the appropriate entry from the file. Once
   3560   * this is done, deletes the file.
   3561   *
   3562   * The file is deleted even if one of the postRecovery() steps rejects or
   3563   * fails.
   3564   *
   3565   * This function resolves silently if the POST_RECOVERY_FILE_NAME file does
   3566   * not exist, which should be the majority of cases.
   3567   *
   3568   * @param {string} [profilePath=PathUtils.profileDir]
   3569   *  The profile path to look for the POST_RECOVERY_FILE_NAME file. Defaults
   3570   *  to the current profile.
   3571   * @returns {Promise<undefined>}
   3572   */
   3573  async checkForPostRecovery(profilePath = PathUtils.profileDir) {
   3574    lazy.logConsole.debug(`Checking for post-recovery file in ${profilePath}`);
   3575    let postRecoveryFile = PathUtils.join(
   3576      profilePath,
   3577      BackupService.POST_RECOVERY_FILE_NAME
   3578    );
   3579 
   3580    if (!(await IOUtils.exists(postRecoveryFile))) {
   3581      lazy.logConsole.debug("Did not find post-recovery file.");
   3582      this.#postRecoveryResolver();
   3583      return;
   3584    }
   3585 
   3586    lazy.logConsole.debug("Found post-recovery file. Loading...");
   3587 
   3588    try {
   3589      let postRecovery = await IOUtils.readJSON(postRecoveryFile);
   3590      for (let resourceKey in postRecovery) {
   3591        let postRecoveryEntry = postRecovery[resourceKey];
   3592        if (
   3593          resourceKey == "backupServiceInternal" &&
   3594          postRecoveryEntry.isProfileRestore
   3595        ) {
   3596          Services.prefs.setStringPref(
   3597            RESTORED_BACKUP_METADATA_PREF_NAME,
   3598            JSON.stringify(postRecoveryEntry.backupMetadata)
   3599          );
   3600          Glean.browserBackup.restoredProfileLaunched.record({
   3601            restore_id: postRecoveryEntry.restoreID,
   3602          });
   3603          // This will clear out the data in this ping, which is a bit of a problem
   3604          // for testing. So fire off an event first that tests can listen for.
   3605          Services.obs.notifyObservers(
   3606            null,
   3607            "browser-backup-restored-profile-telemetry-set"
   3608          );
   3609          GleanPings.postProfileRestore.submit();
   3610        } else {
   3611          let resourceClass = this.#resources.get(resourceKey);
   3612          if (!resourceClass) {
   3613            lazy.logConsole.error(
   3614              `Invalid resource for post-recovery step: ${resourceKey}`
   3615            );
   3616            continue;
   3617          }
   3618 
   3619          lazy.logConsole.debug(
   3620            `Running post-recovery step for ${resourceKey}`
   3621          );
   3622          await new resourceClass().postRecovery(postRecoveryEntry);
   3623          lazy.logConsole.debug(`Done post-recovery step for ${resourceKey}`);
   3624        }
   3625      }
   3626    } finally {
   3627      await IOUtils.remove(postRecoveryFile, {
   3628        ignoreAbsent: true,
   3629        retryReadonly: true,
   3630      });
   3631      this.#postRecoveryResolver();
   3632    }
   3633  }
   3634 
   3635  /**
   3636   * Sets the parent directory of the backups folder. Calling this function will update
   3637   * browser.backup.location.
   3638   *
   3639   * @param {string} parentDirPath directory path
   3640   */
   3641  setParentDirPath(parentDirPath) {
   3642    try {
   3643      let filename = parentDirPath ? PathUtils.filename(parentDirPath) : null;
   3644      if (!filename) {
   3645        throw new BackupError(
   3646          "Parent directory path is invalid.",
   3647          ERRORS.FILE_SYSTEM_ERROR
   3648        );
   3649      }
   3650 
   3651      let fullPath = parentDirPath;
   3652      if (filename != BackupService.BACKUP_DIR_NAME) {
   3653        // Recreate the backups path with the new parent directory.
   3654        fullPath = PathUtils.join(parentDirPath, BackupService.BACKUP_DIR_NAME);
   3655      }
   3656 
   3657      Services.prefs.setStringPref(BACKUP_DIR_PREF_NAME, fullPath);
   3658    } catch (e) {
   3659      lazy.logConsole.error(
   3660        `Failed to set parent directory ${parentDirPath}. ${e}`
   3661      );
   3662      throw e;
   3663    }
   3664  }
   3665 
   3666  /**
   3667   * Updates backupDirPath in the backup service state. Should be called every time the value
   3668   * for browser.backup.location changes.
   3669   *
   3670   * @param {string} newDirPath the new directory path for storing backups
   3671   */
   3672  async onUpdateLocationDirPath(newDirPath) {
   3673    lazy.logConsole.debug(`Updating backup location to ${newDirPath}`);
   3674 
   3675    Glean.browserBackup.changeLocation.record();
   3676 
   3677    this.#_state.backupDirPath = newDirPath;
   3678    this.stateUpdate();
   3679  }
   3680 
   3681  /**
   3682   * Updates backupErrorCode in the backup service state. Should be called every time
   3683   * the value for browser.backup.errorCode changes.
   3684   *
   3685   * @param {number} newErrorCode
   3686   *    Any of the ERROR code's from backup-constants.mjs
   3687   */
   3688  onUpdateBackupErrorCode(newErrorCode) {
   3689    lazy.logConsole.debug(`Updating backup error code to ${newErrorCode}`);
   3690 
   3691    this.#_state.backupErrorCode = newErrorCode;
   3692    this.stateUpdate();
   3693  }
   3694 
   3695  /**
   3696   * Updates lastBackupFileName in the backup service state. Should be called every time
   3697   * the value for browser.backup.scheduled.last-backup-file changes.
   3698   *
   3699   * @param {string} newLastBackupFileName
   3700   *    Name of the last known backup file
   3701   */
   3702  onUpdateLastBackupFileName(newLastBackupFileName) {
   3703    lazy.logConsole.debug(
   3704      `The last backup file name is being updated to ${newLastBackupFileName}`
   3705    );
   3706 
   3707    this.#_state.lastBackupFileName = newLastBackupFileName;
   3708 
   3709    if (!newLastBackupFileName) {
   3710      lazy.logConsole.debug(
   3711        `Looks like we've cleared the last backup file name, let's also clear the last backup date`
   3712      );
   3713 
   3714      this.#_state.lastBackupDate = null;
   3715      Services.prefs.clearUserPref(LAST_BACKUP_TIMESTAMP_PREF_NAME);
   3716    }
   3717 
   3718    this.stateUpdate();
   3719  }
   3720 
   3721  /**
   3722   * Returns the moz-icon URL of a file. To get the moz-icon URL, the
   3723   * file path is convered to a fileURI. If there is a problem retreiving
   3724   * the moz-icon due to an invalid file path, return null instead.
   3725   *
   3726   * @param {string} path Path of the file to read its icon from.
   3727   * @returns {string|null} The moz-icon URL of the specified file, or
   3728   *  null if the icon cannot be retreived.
   3729   */
   3730  getIconFromFilePath(path) {
   3731    if (!path) {
   3732      return null;
   3733    }
   3734 
   3735    try {
   3736      let fileURI = PathUtils.toFileURI(path);
   3737      return `moz-icon:${fileURI}?size=16`;
   3738    } catch (e) {
   3739      return null;
   3740    }
   3741  }
   3742 
   3743  /**
   3744   * Sets browser.backup.scheduled.enabled to true or false.
   3745   *
   3746   * @param { boolean } shouldEnableScheduledBackups true if scheduled backups should be enabled. Else, false.
   3747   */
   3748  setScheduledBackups(shouldEnableScheduledBackups) {
   3749    Services.prefs.setBoolPref(
   3750      SCHEDULED_BACKUPS_ENABLED_PREF_NAME,
   3751      shouldEnableScheduledBackups
   3752    );
   3753 
   3754    if (shouldEnableScheduledBackups) {
   3755      // reset the error states when reenabling backup
   3756      Services.prefs.setIntPref(BACKUP_ERROR_CODE_PREF_NAME, ERRORS.NONE);
   3757 
   3758      // flush the embedded component's persistent data
   3759      this.setEmbeddedComponentPersistentData({});
   3760    } else {
   3761      // set user-disabled pref if backup is being disabled
   3762      Services.prefs.setBoolPref(
   3763        "browser.backup.scheduled.user-disabled",
   3764        true
   3765      );
   3766    }
   3767  }
   3768 
   3769  /**
   3770   * Updates scheduledBackupsEnabled in the backup service state. Should be called every time
   3771   * the value for browser.backup.scheduled.enabled changes.
   3772   *
   3773   * @param {boolean} isScheduledBackupsEnabled True if scheduled backups are enabled. Else false.
   3774   */
   3775  onUpdateScheduledBackups(isScheduledBackupsEnabled) {
   3776    if (this.#_state.scheduledBackupsEnabled != isScheduledBackupsEnabled) {
   3777      if (isScheduledBackupsEnabled) {
   3778        Glean.browserBackup.toggleOn.record({
   3779          encrypted: this.#_state.encryptionEnabled,
   3780          location: this.classifyLocationForTelemetry(lazy.backupDirPref),
   3781        });
   3782      } else {
   3783        Glean.browserBackup.toggleOff.record();
   3784      }
   3785 
   3786      lazy.logConsole.debug(
   3787        "Updating scheduled backups",
   3788        isScheduledBackupsEnabled
   3789      );
   3790      this.#_state.scheduledBackupsEnabled = isScheduledBackupsEnabled;
   3791      this.stateUpdate();
   3792    }
   3793  }
   3794 
   3795  /**
   3796   * Take measurements of the current profile state for Telemetry.
   3797   *
   3798   * @returns {Promise<undefined>}
   3799   */
   3800  async takeMeasurements() {
   3801    lazy.logConsole.debug("Taking Telemetry measurements");
   3802 
   3803    // We'll start by taking some basic BackupService state measurements.
   3804    Glean.browserBackup.enabled.set(true);
   3805    Glean.browserBackup.schedulerEnabled.set(lazy.scheduledBackupsPref);
   3806 
   3807    await this.loadEncryptionState();
   3808    Glean.browserBackup.pswdEncrypted.set(this.#_state.encryptionEnabled);
   3809 
   3810    const USING_DEFAULT_DIR_PATH =
   3811      lazy.backupDirPref ==
   3812      PathUtils.join(
   3813        BackupService.DEFAULT_PARENT_DIR_PATH,
   3814        BackupService.BACKUP_DIR_NAME
   3815      );
   3816    Glean.browserBackup.locationOnDevice.set(USING_DEFAULT_DIR_PATH ? 1 : 2);
   3817 
   3818    // Next, we'll measure the available disk space on the storage
   3819    // device that the profile directory is on.
   3820    let profileDir = await IOUtils.getFile(PathUtils.profileDir);
   3821 
   3822    let profDDiskSpaceBytes = profileDir.diskSpaceAvailable;
   3823 
   3824    // Make the measurement fuzzier by rounding to the nearest 10MB.
   3825    let profDDiskSpaceFuzzed = MeasurementUtils.fuzzByteSize(
   3826      profDDiskSpaceBytes,
   3827      10 * BYTES_IN_MEGABYTE
   3828    );
   3829 
   3830    // And then record the value in kilobytes, since that's what everything
   3831    // else is going to be measured in.
   3832    Glean.browserBackup.profDDiskSpace.set(
   3833      profDDiskSpaceFuzzed / BYTES_IN_KILOBYTE
   3834    );
   3835 
   3836    // Measure the size of each file we are going to backup.
   3837    for (let resourceClass of this.#resources.values()) {
   3838      try {
   3839        await new resourceClass().measure(PathUtils.profileDir);
   3840      } catch (e) {
   3841        lazy.logConsole.error(
   3842          `Failed to measure for resource: ${resourceClass.key}`,
   3843          e
   3844        );
   3845      }
   3846    }
   3847  }
   3848 
   3849  /**
   3850   * The internal promise that is created on the first call to
   3851   * loadEncryptionState.
   3852   *
   3853   * @type {Promise}
   3854   */
   3855  #loadEncryptionStatePromise = null;
   3856 
   3857  /**
   3858   * Returns the current ArchiveEncryptionState. This method will only attempt
   3859   * to read the state from the disk the first time it is called.
   3860   *
   3861   * @param {string} [profilePath=PathUtils.profileDir]
   3862   *   The profile path where the encryption state might exist. This is only
   3863   *   used for testing.
   3864   * @returns {Promise<ArchiveEncryptionState>}
   3865   */
   3866  loadEncryptionState(profilePath = PathUtils.profileDir) {
   3867    if (this.#encState !== undefined) {
   3868      return Promise.resolve(this.#encState);
   3869    }
   3870 
   3871    // This little dance makes it so that we only attempt to read the state off
   3872    // of the disk the first time `loadEncryptionState` is called. Any
   3873    // subsequent calls will await this same promise, OR, after the state has
   3874    // been read in, they'll just get the #encState which is set after the
   3875    // state has been read in.
   3876    if (!this.#loadEncryptionStatePromise) {
   3877      this.#loadEncryptionStatePromise = (async () => {
   3878        // Default this to null here - that way, if we fail to read it in,
   3879        // the null will indicate that we have at least _tried_ to load the
   3880        // state.
   3881        let encState = null;
   3882        let encStateFile = PathUtils.join(
   3883          profilePath,
   3884          BackupService.PROFILE_FOLDER_NAME,
   3885          BackupService.ARCHIVE_ENCRYPTION_STATE_FILE
   3886        );
   3887 
   3888        // Try to read in any pre-existing encryption state. If that fails,
   3889        // we fallback to not encrypting, and only backing up non-sensitive data.
   3890        try {
   3891          if (await IOUtils.exists(encStateFile)) {
   3892            let stateObject = await IOUtils.readJSON(encStateFile);
   3893            ({ instance: encState } =
   3894              await lazy.ArchiveEncryptionState.initialize(stateObject));
   3895          }
   3896        } catch (e) {
   3897          lazy.logConsole.error(
   3898            "Failed to read / deserialize archive encryption state file: ",
   3899            e
   3900          );
   3901          // TODO: This kind of error might be worth collecting telemetry on.
   3902        }
   3903 
   3904        this.#_state.encryptionEnabled = !!encState;
   3905        this.stateUpdate();
   3906 
   3907        this.#encState = encState;
   3908        return encState;
   3909      })();
   3910    }
   3911 
   3912    return this.#loadEncryptionStatePromise;
   3913  }
   3914 
   3915  /**
   3916   * Enables encryption for backups, allowing sensitive data to be backed up.
   3917   * After enabling encryption, the state is written to disk.
   3918   *
   3919   * @throws Exception
   3920   * @param {string} password
   3921   *   A non-blank password ("recovery code") that can be used to derive keys
   3922   *   for encrypting the backup.
   3923   * @param {string} [profilePath=PathUtils.profileDir]
   3924   *   The profile path where the encryption state will be written. This is only
   3925   *   used for testing.
   3926   */
   3927  async enableEncryption(password, profilePath = PathUtils.profileDir) {
   3928    lazy.logConsole.debug("Enabling encryption.");
   3929    if (!password) {
   3930      throw new BackupError(
   3931        "Cannot supply a blank password.",
   3932        ERRORS.INVALID_PASSWORD
   3933      );
   3934    }
   3935 
   3936    if (password.length < 8) {
   3937      throw new BackupError(
   3938        "Password must be at least 8 characters.",
   3939        ERRORS.INVALID_PASSWORD
   3940      );
   3941    }
   3942 
   3943    let { instance: encState } =
   3944      await lazy.ArchiveEncryptionState.initialize(password);
   3945    if (!encState) {
   3946      throw new BackupError(
   3947        "Failed to construct ArchiveEncryptionState",
   3948        ERRORS.UNKNOWN
   3949      );
   3950    }
   3951 
   3952    this.#encState = encState;
   3953 
   3954    let encStateFile = PathUtils.join(
   3955      profilePath,
   3956      BackupService.PROFILE_FOLDER_NAME,
   3957      BackupService.ARCHIVE_ENCRYPTION_STATE_FILE
   3958    );
   3959 
   3960    let stateObj = await encState.serialize();
   3961    await IOUtils.writeJSON(encStateFile, stateObj);
   3962 
   3963    this.#_state.encryptionEnabled = true;
   3964    this.stateUpdate();
   3965  }
   3966 
   3967  /**
   3968   * Disables encryption of backups.
   3969   *
   3970   * @throws Exception
   3971   * @param {string} [profilePath=PathUtils.profileDir]
   3972   *   The profile path where the encryption state exists. This is only used for
   3973   *   testing.
   3974   * @returns {Promise<undefined>}
   3975   */
   3976  async disableEncryption(profilePath = PathUtils.profileDir) {
   3977    lazy.logConsole.debug("Disabling encryption.");
   3978    let encStateFile = PathUtils.join(
   3979      profilePath,
   3980      BackupService.PROFILE_FOLDER_NAME,
   3981      BackupService.ARCHIVE_ENCRYPTION_STATE_FILE
   3982    );
   3983    await IOUtils.remove(encStateFile, {
   3984      ignoreAbsent: true,
   3985      retryReadonly: true,
   3986    });
   3987 
   3988    this.#encState = null;
   3989    this.#_state.encryptionEnabled = false;
   3990    this.stateUpdate();
   3991  }
   3992 
   3993  /**
   3994   * The value of IDLE_THRESHOLD_SECONDS_PREF_NAME at the time that
   3995   * initBackupScheduler was called. This is recorded so that if the preference
   3996   * changes at runtime, that we properly remove the idle observer in
   3997   * uninitBackupScheduler, since it's mapped to the idle time value.
   3998   *
   3999   * @see BackupService.initBackupScheduler()
   4000   * @see BackupService.uninitBackupScheduler()
   4001   * @type {number}
   4002   */
   4003  #idleThresholdSeconds = null;
   4004 
   4005  /**
   4006   * An ES6 class that extends EventTarget cannot, apparently, be coerced into
   4007   * a nsIObserver, even when we define QueryInterface. We work around this
   4008   * limitation by having the observer be a function that we define at
   4009   * registration time. We hold a reference to the observer so that we can
   4010   * properly unregister.
   4011   *
   4012   * @see BackupService.initBackupScheduler()
   4013   * @type {Function}
   4014   */
   4015  #observer = null;
   4016 
   4017  /**
   4018   * True if the backup scheduler system has been initted via
   4019   * initBackupScheduler().
   4020   *
   4021   * @see BackupService.initBackupScheduler()
   4022   * @type {boolean}
   4023   */
   4024  #backupSchedulerInitted = false;
   4025 
   4026  /**
   4027   * Initializes the backup scheduling system. This should be done shortly
   4028   * after startup. It is exposed as a public method mainly for ease in testing.
   4029   *
   4030   * The scheduler will automatically uninitialize itself on the
   4031   * quit-application-granted observer notification.
   4032   */
   4033  initBackupScheduler() {
   4034    if (this.#backupSchedulerInitted) {
   4035      lazy.logConsole.warn(
   4036        "BackupService scheduler already initting or initted."
   4037      );
   4038      return;
   4039    }
   4040 
   4041    this.#backupSchedulerInitted = true;
   4042 
   4043    let lastBackupPrefValue = Services.prefs.getIntPref(
   4044      LAST_BACKUP_TIMESTAMP_PREF_NAME,
   4045      0
   4046    );
   4047 
   4048    this.#_state.lastBackupDate = lastBackupPrefValue || null;
   4049 
   4050    this.stateUpdate();
   4051 
   4052    // We'll default to 5 minutes of idle time unless otherwise configured.
   4053    const FIVE_MINUTES_IN_SECONDS = 5 * 60;
   4054 
   4055    this.#idleThresholdSeconds = Services.prefs.getIntPref(
   4056      IDLE_THRESHOLD_SECONDS_PREF_NAME,
   4057      FIVE_MINUTES_IN_SECONDS
   4058    );
   4059    this.#observer = (subject, topic, data) => {
   4060      this.onObserve(subject, topic, data);
   4061    };
   4062    lazy.logConsole.debug(
   4063      `Registering idle observer for ${
   4064        this.#idleThresholdSeconds
   4065      } seconds of idle time`
   4066    );
   4067    lazy.idleService.addIdleObserver(
   4068      this.#observer,
   4069      this.#idleThresholdSeconds
   4070    );
   4071    lazy.logConsole.debug("Idle observer registered.");
   4072 
   4073    lazy.logConsole.debug(`Registering Places observer`);
   4074 
   4075    this.#placesObserver = new PlacesWeakCallbackWrapper(
   4076      this.onPlacesEvents.bind(this)
   4077    );
   4078    PlacesObservers.addListener(
   4079      ["history-cleared", "page-removed", "bookmark-removed"],
   4080      this.#placesObserver
   4081    );
   4082 
   4083    lazy.AddonManager.addAddonListener(this);
   4084 
   4085    Services.obs.addObserver(this.#observer, "passwordmgr-storage-changed");
   4086    Services.obs.addObserver(this.#observer, "formautofill-storage-changed");
   4087    Services.obs.addObserver(this.#observer, "sanitizer-sanitization-complete");
   4088    Services.obs.addObserver(this.#observer, "perm-changed");
   4089    Services.obs.addObserver(this.#observer, "cookie-changed");
   4090    Services.obs.addObserver(this.#observer, "session-cookie-changed");
   4091    Services.obs.addObserver(this.#observer, "newtab-linkBlocked");
   4092    Services.obs.addObserver(this.#observer, "quit-application-granted");
   4093    Services.prefs.addObserver(SANITIZE_ON_SHUTDOWN_PREF_NAME, this.#observer);
   4094  }
   4095 
   4096  /**
   4097   * Uninitializes the backup scheduling system.
   4098   */
   4099  uninitBackupScheduler() {
   4100    if (!this.#backupSchedulerInitted) {
   4101      lazy.logConsole.warn(
   4102        "Tried to uninitBackupScheduler when it wasn't yet enabled."
   4103      );
   4104      return;
   4105    }
   4106 
   4107    lazy.idleService.removeIdleObserver(
   4108      this.#observer,
   4109      this.#idleThresholdSeconds
   4110    );
   4111 
   4112    PlacesObservers.removeListener(
   4113      ["history-cleared", "page-removed", "bookmark-removed"],
   4114      this.#placesObserver
   4115    );
   4116 
   4117    lazy.AddonManager.removeAddonListener(this);
   4118 
   4119    Services.obs.removeObserver(this.#observer, "passwordmgr-storage-changed");
   4120    Services.obs.removeObserver(this.#observer, "formautofill-storage-changed");
   4121    Services.obs.removeObserver(
   4122      this.#observer,
   4123      "sanitizer-sanitization-complete"
   4124    );
   4125    Services.obs.removeObserver(this.#observer, "perm-changed");
   4126    Services.obs.removeObserver(this.#observer, "cookie-changed");
   4127    Services.obs.removeObserver(this.#observer, "session-cookie-changed");
   4128    Services.obs.removeObserver(this.#observer, "newtab-linkBlocked");
   4129    Services.obs.removeObserver(this.#observer, "quit-application-granted");
   4130    Services.prefs.removeObserver(
   4131      SANITIZE_ON_SHUTDOWN_PREF_NAME,
   4132      this.#observer
   4133    );
   4134    this.#observer = null;
   4135 
   4136    this.#regenerationDebouncer.disarm();
   4137    this.#backupWriteAbortController.abort();
   4138  }
   4139 
   4140  /**
   4141   * Called by this.#observer on idle from the nsIUserIdleService or
   4142   * quit-application-granted from the nsIObserverService. Exposed as a public
   4143   * method mainly for ease in testing.
   4144   *
   4145   * @param {nsISupports|null} subject
   4146   *   The nsIUserIdleService for the idle notification, and null for the
   4147   *   quit-application-granted topic.
   4148   * @param {string} topic
   4149   *   The topic that the notification belongs to.
   4150   * @param {string} data
   4151   *   Optional data that was included with the notification.
   4152   */
   4153  onObserve(subject, topic, data) {
   4154    switch (topic) {
   4155      case "idle": {
   4156        this.onIdle();
   4157        break;
   4158      }
   4159      case "quit-application-granted": {
   4160        this.uninitBackupScheduler();
   4161        this.uninitStatusObservers();
   4162        break;
   4163      }
   4164      case "passwordmgr-storage-changed": {
   4165        if (data == "removeLogin" || data == "removeAllLogins") {
   4166          this.#debounceRegeneration();
   4167        }
   4168        break;
   4169      }
   4170      case "formautofill-storage-changed": {
   4171        if (
   4172          data == "remove" &&
   4173          (subject.wrappedJSObject.collectionName == "creditCards" ||
   4174            subject.wrappedJSObject.collectionName == "addresses")
   4175        ) {
   4176          this.#debounceRegeneration();
   4177        }
   4178        break;
   4179      }
   4180      case "newtab-linkBlocked":
   4181      // Intentional fall-through
   4182      case "sanitizer-sanitization-complete": {
   4183        this.#debounceRegeneration();
   4184        break;
   4185      }
   4186      case "perm-changed": {
   4187        if (data == "deleted") {
   4188          this.#debounceRegeneration();
   4189        }
   4190        break;
   4191      }
   4192      case "cookie-changed":
   4193      // Intentional fall-through
   4194      case "session-cookie-changed": {
   4195        let notification = subject.QueryInterface(Ci.nsICookieNotification);
   4196        // A browsingContextId value of 0 means that this deletion was caused by
   4197        // chrome UI cookie deletion, which is what we care about. If it's not
   4198        // 0, then a site deleted its own cookie, which we ignore.
   4199        if (
   4200          (notification.action == Ci.nsICookieNotification.COOKIE_DELETED ||
   4201            notification.action ==
   4202              Ci.nsICookieNotification.ALL_COOKIES_CLEARED) &&
   4203          !notification.browsingContextId
   4204        ) {
   4205          this.#debounceRegeneration();
   4206        }
   4207        break;
   4208      }
   4209      case "nsPref:changed": {
   4210        if (data == SANITIZE_ON_SHUTDOWN_PREF_NAME) {
   4211          this.#debounceRegeneration();
   4212        }
   4213      }
   4214    }
   4215  }
   4216 
   4217  /**
   4218   * Makes this instance responsible for monitoring the conditions that can
   4219   * cause backups or restores to be unavailable.
   4220   *
   4221   * When one arrives, observers of the 'backup-service-status-changed' topic
   4222   * will be notified and telemetry will be emitted.
   4223   *
   4224   * This is not done by default since that would cause N emissions of that
   4225   * topic per change for N instances, which can be a problem with testing. The
   4226   * global BackupService has status observers by default.
   4227   */
   4228  initStatusObservers() {
   4229    if (this.#statusPrefObserver != null) {
   4230      return;
   4231    }
   4232 
   4233    // We don't use this.#observer since any changes to the prefs or nimbus should
   4234    // immediately reflect across any observers, instead of waiting on idle.
   4235    this.#statusPrefObserver = () => {
   4236      // Wrap in an arrow function so 'this' is preserved.
   4237      this.#handleStatusChange();
   4238    };
   4239 
   4240    for (let pref of BackupService.STATUS_OBSERVER_PREFS) {
   4241      Services.prefs.addObserver(pref, this.#statusPrefObserver);
   4242    }
   4243    lazy.NimbusFeatures.backupService.onUpdate(this.#statusPrefObserver);
   4244    this.#handleStatusChange();
   4245  }
   4246 
   4247  /**
   4248   * Removes the observers configured by initStatusObservers.
   4249   *
   4250   * This is done automatically on shutdown, but you can do it earlier if you'd
   4251   * like that instance to stop emitting events.
   4252   */
   4253  uninitStatusObservers() {
   4254    if (this.#statusPrefObserver == null) {
   4255      return;
   4256    }
   4257 
   4258    for (let pref of BackupService.STATUS_OBSERVER_PREFS) {
   4259      Services.prefs.removeObserver(pref, this.#statusPrefObserver);
   4260    }
   4261    lazy.NimbusFeatures.backupService.offUpdate(this.#statusPrefObserver);
   4262    this.#statusPrefObserver = null;
   4263  }
   4264 
   4265  /**
   4266   * Performs tasks required whenever archive or restore change their status
   4267   *
   4268   * 1. Notifies any observers that a change has taken place
   4269   * 2. If archive is disabled, clean up any backup files
   4270   */
   4271  #handleStatusChange() {
   4272    const archiveStatus = this.archiveEnabledStatus;
   4273    const restoreStatus = this.restoreEnabledStatus;
   4274    // Update the BackupService state before notifying observers about the
   4275    // state change
   4276    this.#_state.archiveEnabledStatus = this.archiveEnabledStatus.enabled;
   4277    this.#_state.restoreEnabledStatus = this.restoreEnabledStatus.enabled;
   4278 
   4279    this.#updateGleanEnablement(archiveStatus, restoreStatus);
   4280    if (
   4281      archiveStatus.enabled != this.#lastSeenArchiveStatus ||
   4282      restoreStatus.enabled != this.#lastSeenRestoreStatus
   4283    ) {
   4284      this.#lastSeenArchiveStatus = archiveStatus.enabled;
   4285      this.#lastSeenRestoreStatus = restoreStatus.enabled;
   4286      this.#notifyStatusObservers();
   4287    }
   4288    if (!archiveStatus.enabled) {
   4289      // We won't wait for this promise to accept/reject since rejections are
   4290      // ignored anyways
   4291      this.cleanupBackupFiles();
   4292    }
   4293  }
   4294 
   4295  #updateGleanEnablement(archiveStatus, restoreStatus) {
   4296    Glean.browserBackup.archiveEnabled.set(archiveStatus.enabled);
   4297    Glean.browserBackup.restoreEnabled.set(restoreStatus.enabled);
   4298    if (!archiveStatus.enabled) {
   4299      this.#wasArchivePreviouslyDisabled = true;
   4300      Glean.browserBackup.archiveDisabledReason.set(
   4301        archiveStatus.internalReason
   4302      );
   4303    } else if (this.#wasArchivePreviouslyDisabled) {
   4304      Glean.browserBackup.archiveDisabledReason.set("reenabled");
   4305    }
   4306    if (!restoreStatus.enabled) {
   4307      this.#wasRestorePreviouslyDisabled = true;
   4308      Glean.browserBackup.restoreDisabledReason.set(
   4309        restoreStatus.internalReason
   4310      );
   4311    } else if (this.#wasRestorePreviouslyDisabled) {
   4312      Glean.browserBackup.restoreDisabledReason.set("reenabled");
   4313    }
   4314  }
   4315 
   4316  /**
   4317   * Notify any listeners about the availability of the backup service, then
   4318   * update relevant telemetry metrics.
   4319   */
   4320  #notifyStatusObservers() {
   4321    lazy.logConsole.log(
   4322      "Notifying observers about a BackupService state change"
   4323    );
   4324 
   4325    Services.obs.notifyObservers(null, "backup-service-status-updated");
   4326  }
   4327 
   4328  async cleanupBackupFiles() {
   4329    lazy.logConsole.debug("Cleaning up backup data");
   4330    try {
   4331      if (this.state.encryptionEnabled) {
   4332        await this.disableEncryption();
   4333      }
   4334      this.deleteLastBackup();
   4335    } catch (e) {
   4336      // Ignore any exceptions
   4337      lazy.logConsole.error(
   4338        "There was an error when cleaning up backup files: ",
   4339        e
   4340      );
   4341    }
   4342  }
   4343 
   4344  /**
   4345   * Called when the last known backup should be deleted and a new one
   4346   * created. This uses the #regenerationDebouncer to debounce clusters of
   4347   * events that might cause such a regeneration to occur.
   4348   */
   4349  #debounceRegeneration() {
   4350    this.#regenerationDebouncer.disarm();
   4351    this.#regenerationDebouncer.arm();
   4352  }
   4353 
   4354  /**
   4355   * Called when the nsIUserIdleService reports that user input events have
   4356   * not been sent to the application for at least
   4357   * IDLE_THRESHOLD_SECONDS_PREF_NAME seconds.
   4358   */
   4359  async onIdle() {
   4360    lazy.logConsole.debug("Saw idle callback");
   4361    if (!this.#takenMeasurements) {
   4362      this.takeMeasurements();
   4363      this.#takenMeasurements = true;
   4364    }
   4365 
   4366    if (lazy.scheduledBackupsPref && this.archiveEnabledStatus.enabled) {
   4367      lazy.logConsole.debug("Scheduled backups enabled.");
   4368      let now = Math.floor(Date.now() / 1000);
   4369      let lastBackupDate = this.#_state.lastBackupDate;
   4370      if (lastBackupDate && lastBackupDate > now) {
   4371        lazy.logConsole.error(
   4372          "Last backup was somehow in the future. Resetting the preference."
   4373        );
   4374        lastBackupDate = null;
   4375        this.#_state.lastBackupDate = null;
   4376        this.stateUpdate();
   4377      }
   4378 
   4379      if (!lastBackupDate) {
   4380        lazy.logConsole.debug("No last backup time recorded in prefs.");
   4381      } else {
   4382        lazy.logConsole.debug(
   4383          "Last backup was: ",
   4384          new Date(lastBackupDate * 1000)
   4385        );
   4386      }
   4387 
   4388      if (
   4389        !lastBackupDate ||
   4390        now - lastBackupDate > lazy.minimumTimeBetweenBackupsSeconds
   4391      ) {
   4392        lazy.logConsole.debug(
   4393          "Last backup exceeded minimum time between backups. Queueing a " +
   4394            "backup via idleDispatch."
   4395        );
   4396 
   4397        // Just because the user hasn't sent us events in a while doesn't mean
   4398        // that the browser itself isn't busy. It might be, for example, playing
   4399        // video or doing a complex calculation that the user is actively
   4400        // waiting to complete, and we don't want to draw resources from that.
   4401        // Instead, we'll use ChromeUtils.idleDispatch to wait until the event
   4402        // loop in the parent process isn't so busy with higher priority things.
   4403        let expectedBackupTime =
   4404          lastBackupDate + lazy.minimumTimeBetweenBackupsSeconds;
   4405        try {
   4406          await this.createBackupOnIdleDispatch({
   4407            reason:
   4408              expectedBackupTime < this._startupTimeUnixSeconds
   4409                ? "missed"
   4410                : "idle",
   4411          });
   4412        } catch (e) {
   4413          lazy.logConsole.error(
   4414            "createBackupOnIdleDispatch promise rejected",
   4415            e
   4416          );
   4417        }
   4418      } else {
   4419        lazy.logConsole.debug(
   4420          "Last backup was too recent. Not creating one for now."
   4421        );
   4422      }
   4423    }
   4424  }
   4425 
   4426  /**
   4427   * Gets the time that Firefox started as milliseconds since the Unix epoch.
   4428   *
   4429   * This is in a getter to make it easier for tests to stub it out.
   4430   */
   4431  get _startupTimeUnixSeconds() {
   4432    let startupTimeMs = Services.startup.getStartupInfo().process.getTime();
   4433    return Math.floor(startupTimeMs / 1000);
   4434  }
   4435 
   4436  /**
   4437   * Decide whether we should attempt a backup now.
   4438   *
   4439   * @returns {boolean}
   4440   */
   4441  shouldAttemptBackup() {
   4442    let now = Math.floor(Date.now() / 1000);
   4443    const debugInfoStr = Services.prefs.getStringPref(
   4444      BACKUP_DEBUG_INFO_PREF_NAME,
   4445      ""
   4446    );
   4447 
   4448    let parsed = null;
   4449    if (debugInfoStr) {
   4450      try {
   4451        parsed = JSON.parse(debugInfoStr);
   4452      } catch (e) {
   4453        lazy.logConsole.warn(
   4454          "Invalid backup debug-info pref; ignoring and allowing backup attempt.",
   4455          e
   4456        );
   4457        parsed = null;
   4458      }
   4459    }
   4460 
   4461    const lastBackupAttempt = parsed?.lastBackupAttempt;
   4462    const hasErroredLastAttempt = Number.isFinite(lastBackupAttempt);
   4463 
   4464    if (!hasErroredLastAttempt) {
   4465      lazy.logConsole.debug(
   4466        `There have been no errored last attempts, let's do a backup`
   4467      );
   4468      return true;
   4469    }
   4470 
   4471    const secondsSinceLastAttempt = now - lastBackupAttempt;
   4472 
   4473    if (lazy.isRetryDisabledOnIdle) {
   4474      // Let's add a buffer before restarting the retries. Dividing by 2
   4475      // since currently minimumTimeBetweenBackupsSeconds is set to 24 hours
   4476      // We want to approximately keep a backup for each day, so let's retry
   4477      // in about 12 hours again.
   4478      if (secondsSinceLastAttempt < lazy.minimumTimeBetweenBackupsSeconds / 2) {
   4479        lazy.logConsole.debug(
   4480          `Retrying is disabled, we have to wait for ${lazy.minimumTimeBetweenBackupsSeconds / 2}s to retry`
   4481        );
   4482        return false;
   4483      }
   4484      // Looks like we've waited enough, reset the retry states and try to create
   4485      // a backup again.
   4486      BackupService.#errorRetries = 0;
   4487      Services.prefs.clearUserPref(DISABLED_ON_IDLE_RETRY_PREF_NAME);
   4488 
   4489      return true;
   4490    }
   4491 
   4492    // Exponential backoff guard, avoids throttling the same error again and again
   4493    if (secondsSinceLastAttempt < BackupService.backoffSeconds()) {
   4494      lazy.logConsole.debug(
   4495        `backoff: elapsed ${secondsSinceLastAttempt}s < backoff ${BackupService.backoffSeconds()}s`
   4496      );
   4497      return false;
   4498    }
   4499 
   4500    return true;
   4501  }
   4502 
   4503  /**
   4504   * Calls BackupService.createBackup at the next moment when the event queue
   4505   * is not busy with higher priority events. This is intentionally broken out
   4506   * into its own method to make it easier to stub out in tests.
   4507   *
   4508   * @param {object} [options]
   4509   * @param {boolean} [options.deletePreviousBackup]
   4510   * @param {string} [options.reason]
   4511   *
   4512   * @returns {Promise} A backup promise to hold onto
   4513   */
   4514  createBackupOnIdleDispatch({ deletePreviousBackup = true, reason }) {
   4515    if (!this.shouldAttemptBackup()) {
   4516      return Promise.resolve();
   4517    }
   4518 
   4519    // Determine path to old backup file
   4520    const oldBackupFile = this.#_state.lastBackupFileName;
   4521    const isScheduledBackupsEnabled = lazy.scheduledBackupsPref;
   4522 
   4523    let { backupPromise, resolve } = Promise.withResolvers();
   4524    ChromeUtils.idleDispatch(async () => {
   4525      lazy.logConsole.debug(
   4526        "idleDispatch fired. Attempting to create a backup."
   4527      );
   4528      let oldBackupFilePath;
   4529      if (await this.#infalliblePathExists(lazy.backupDirPref)) {
   4530        oldBackupFilePath = PathUtils.join(lazy.backupDirPref, oldBackupFile);
   4531      }
   4532 
   4533      try {
   4534        if (isScheduledBackupsEnabled) {
   4535          await this.createBackup({ reason });
   4536        }
   4537      } catch (e) {
   4538        lazy.logConsole.debug(
   4539          `There was an error creating backup on idle dispatch: ${e}`
   4540        );
   4541 
   4542        BackupService.#errorRetries += 1;
   4543        if (BackupService.#errorRetries > lazy.backupRetryLimit) {
   4544          Services.prefs.setBoolPref(DISABLED_ON_IDLE_RETRY_PREF_NAME, true);
   4545          // Next retry will be 24 hours later (backoffSeconds = 2^(11) * 60s),
   4546          // let's just restart our backoff heuristic
   4547          BackupService.#errorRetries = 0;
   4548          Glean.browserBackup.backupThrottled.record();
   4549        }
   4550      } finally {
   4551        // Now delete the old backup file, if it exists
   4552        if (deletePreviousBackup && oldBackupFilePath) {
   4553          lazy.logConsole.log(
   4554            "Attempting to delete last backup file at ",
   4555            oldBackupFilePath
   4556          );
   4557          await IOUtils.remove(oldBackupFilePath, {
   4558            ignoreAbsent: true,
   4559            retryReadonly: true,
   4560          });
   4561          resolve();
   4562        }
   4563      }
   4564    });
   4565    return backupPromise;
   4566  }
   4567 
   4568  /**
   4569   * Handler for events coming in through our PlacesObserver.
   4570   *
   4571   * @param {PlacesEvent[]} placesEvents
   4572   *   One or more batched events that are of a type that we subscribed to.
   4573   */
   4574  onPlacesEvents(placesEvents) {
   4575    // Note that if any of the events that we iterate result in a regeneration
   4576    // being queued, we simply return without the processing the rest, as there
   4577    // is not really a point.
   4578    for (let event of placesEvents) {
   4579      switch (event.type) {
   4580        case "page-removed": {
   4581          // We will get a page-removed event if a page has been deleted both
   4582          // manually by a user, but also automatically if the page has "aged
   4583          // out" of the Places database. We only want to regenerate backups
   4584          // in the manual case (REASON_DELETED).
   4585          if (event.reason == PlacesVisitRemoved.REASON_DELETED) {
   4586            this.#debounceRegeneration();
   4587            return;
   4588          }
   4589          break;
   4590        }
   4591        case "bookmark-removed":
   4592        // Intentional fall-through
   4593        case "history-cleared": {
   4594          this.#debounceRegeneration();
   4595          return;
   4596        }
   4597      }
   4598    }
   4599  }
   4600 
   4601  /**
   4602   * This method is the only method of the AddonListener interface that
   4603   * BackupService implements and is called by AddonManager when an addon
   4604   * is uninstalled.
   4605   *
   4606   * @param {AddonInternal} _addon
   4607   *   The addon being uninstalled.
   4608   */
   4609  onUninstalled(_addon) {
   4610    this.#debounceRegeneration();
   4611  }
   4612 
   4613  /**
   4614   * Gets a sample from a given backup file and sets a subset of that as
   4615   * the backupFileInfo in the backup service state.
   4616   *
   4617   * Called when getting a info for an archive to potentially restore.
   4618   *
   4619   * @param {string} backupFilePath path to the backup file to sample.
   4620   */
   4621  async getBackupFileInfo(backupFilePath) {
   4622    lazy.logConsole.debug(`Getting info from backup file at ${backupFilePath}`);
   4623 
   4624    this.#_state.restoreID = Services.uuid.generateUUID().toString();
   4625    this.#_state.backupFileInfo = null;
   4626    this.#_state.backupFileToRestore = backupFilePath;
   4627    this.#_state.backupFileCoarseLocation =
   4628      this.classifyLocationForTelemetry(backupFilePath);
   4629 
   4630    try {
   4631      let { archiveJSON, isEncrypted } =
   4632        await this.sampleArchive(backupFilePath);
   4633      this.#_state.backupFileInfo = {
   4634        isEncrypted,
   4635        date: archiveJSON?.meta?.date,
   4636        deviceName: archiveJSON?.meta?.deviceName,
   4637        appName: archiveJSON?.meta?.appName,
   4638        appVersion: archiveJSON?.meta?.appVersion,
   4639        buildID: archiveJSON?.meta?.buildID,
   4640        osName: archiveJSON?.meta?.osName,
   4641        osVersion: archiveJSON?.meta?.osVersion,
   4642        healthTelemetryEnabled: archiveJSON?.meta?.healthTelemetryEnabled,
   4643        legacyClientID: archiveJSON?.meta?.legacyClientID,
   4644      };
   4645 
   4646      // Clear any existing recovery error from state since we've successfully
   4647      // got our file info. Make sure to do this last, since it will cause
   4648      // state change observers to fire.
   4649      this.setRecoveryError(ERRORS.NONE);
   4650    } catch (error) {
   4651      // Nullify the file info when we catch errors that indicate the file is invalid
   4652      this.#_state.backupFileInfo = null;
   4653      this.#_state.backupFileToRestore = null;
   4654 
   4655      // Notify observers of the error last, after we have set the state.
   4656      this.setRecoveryError(error.cause);
   4657    }
   4658  }
   4659 
   4660  /**
   4661   * TEST ONLY: reset's lastBackup state's for testing purposes
   4662   */
   4663  resetLastBackupInternalState() {
   4664    this.#_state.backupFileToRestore = null;
   4665    this.#_state.lastBackupFileName = "";
   4666    this.#_state.lastBackupDate = null;
   4667    this.stateUpdate();
   4668  }
   4669 
   4670  /**
   4671   * TEST ONLY: reset's the defaultParent state for testing purposes
   4672   */
   4673  resetDefaultParentInternalState() {
   4674    this.#_state.defaultParent = {};
   4675    this.stateUpdate();
   4676  }
   4677 
   4678  /*
   4679   * Attempts to open a native file explorer window at the last backup file's
   4680   * location on the filesystem.
   4681   */
   4682  async showBackupLocation() {
   4683    let backupFilePath = PathUtils.join(
   4684      lazy.backupDirPref,
   4685      lazy.lastBackupFileName
   4686    );
   4687    if (await IOUtils.exists(backupFilePath)) {
   4688      new lazy.nsLocalFile(backupFilePath).reveal();
   4689    } else {
   4690      let archiveDestFolderPath = await this.resolveArchiveDestFolderPath(
   4691        lazy.backupDirPref
   4692      );
   4693      new lazy.nsLocalFile(archiveDestFolderPath).reveal();
   4694    }
   4695  }
   4696 
   4697  /**
   4698   * Searches for a valid backup file in the default backup folder.
   4699   *
   4700   * This function checks the possible backup directory's for `.html` backup files.
   4701   * If multiple backups are present and `multipleFiles` is false, it will not select one.
   4702   * Optionally validates each candidate file before selecting it.
   4703   *
   4704   * @param {object} [options={}] - Configuration options.
   4705   * @param {boolean} [options.validateFile=true] - Whether to validate each backup file before selecting it.
   4706   * @param {boolean} [options.multipleFiles=false] - Whether to allow selecting when multiple backup files are found.
   4707   * @param {boolean} [options.speedUpHeuristic=false] - Whether we want to avoid performance bottlenecks in exchange for
   4708   *                              possibly missing valid files.
   4709   *
   4710   * @returns {Promise<object>} A result object with the following properties:
   4711   * - {boolean} multipleBackupsFound — True if more than one backup candidate was found and `multipleFiles` is false.
   4712   */
   4713  async findIfABackupFileExists({
   4714    validateFile = true,
   4715    multipleFiles = false,
   4716    speedUpHeuristic = false,
   4717  } = {}) {
   4718    // Do we already have a backup for this browser? if so, we don't need to do any searching!
   4719    if (lazy.lastBackupFileName) {
   4720      return {
   4721        found: true,
   4722        multipleBackupsFound: false,
   4723      };
   4724    }
   4725 
   4726    try {
   4727      // During the first startup, the browser's backup location is often left
   4728      // unconfigured; therefore, it defaults to predefined locations to look
   4729      // for existing backup files.
   4730      let defaultPath = PathUtils.join(
   4731        BackupService.DEFAULT_PARENT_DIR_PATH,
   4732        BackupService.BACKUP_DIR_NAME
   4733      );
   4734      let files = await IOUtils.getChildren(
   4735        this.#_state.backupDirPath ? this.#_state.backupDirPath : defaultPath,
   4736        {
   4737          ignoreAbsent: true,
   4738        }
   4739      );
   4740      // filtering is an O(N) operation, we can return early if there's too many files
   4741      // in this folder to filter to avoid a performance bottleneck
   4742      if (speedUpHeuristic && files && files.length > 1000) {
   4743        return {
   4744          multipleBackupsFound: false,
   4745        };
   4746      }
   4747 
   4748      // The backup is always a html file and starts with "FirefoxBackup_"
   4749      // disregard any other files in the folder
   4750      let maybeBackupFiles = files.filter(f => {
   4751        let name = PathUtils.filename(f);
   4752 
   4753        // Note: The Firefox backup filename is localized (see BackupService.BACKUP_FILE_NAME).
   4754        // For now, we use a hardcoded regex string directly for performance reasons.
   4755        return /^FirefoxBackup_.*\.html$/.test(name);
   4756      });
   4757 
   4758      // if we aren't validating files, and there's more than 1 html file, we decide
   4759      // that there's no valid backup file found
   4760      if (!multipleFiles && maybeBackupFiles.length > 1 && !validateFile) {
   4761        return { multipleBackupsFound: true };
   4762      }
   4763 
   4764      // Sort the files by the timestamp at the end of the filename,
   4765      // so the newest valid file is selected as the file to restore
   4766      if (multipleFiles && maybeBackupFiles.length > 1 && validateFile) {
   4767        maybeBackupFiles.sort((a, b) => {
   4768          let nameA = PathUtils.filename(a);
   4769          let nameB = PathUtils.filename(b);
   4770          const match = /_(\d{8}-\d{4})\.html$/;
   4771          let timestampA = nameA.match(match)?.[1];
   4772          let timestampB = nameB.match(match)?.[1];
   4773 
   4774          // If either file doesn't match the expected pattern, maintain the original order
   4775          if (!timestampA || !timestampB) {
   4776            return 0;
   4777          }
   4778 
   4779          return timestampB.localeCompare(timestampA);
   4780        });
   4781      }
   4782 
   4783      for (const file of maybeBackupFiles) {
   4784        if (validateFile) {
   4785          try {
   4786            await this.getBackupFileInfo(file);
   4787          } catch (e) {
   4788            lazy.logConsole.log(
   4789              "Not a valid backup file in the default folder",
   4790              file,
   4791              e
   4792            );
   4793 
   4794            // If this was previously selected but is no longer valid, unbind it
   4795            if (this.#_state.backupFileToRestore === file) {
   4796              this.#_state.backupFileToRestore = null;
   4797              this.#_state.backupFileInfo = null;
   4798              this.stateUpdate();
   4799            }
   4800 
   4801            // let's move on to finding another file
   4802            continue;
   4803          }
   4804        }
   4805 
   4806        this.#_state.backupFileToRestore = file;
   4807        this.stateUpdate();
   4808 
   4809        // In the case that multiple files were found,
   4810        // but we also validated files to set the newest backup file as the file to restore,
   4811        // we still want to return that multiple backups were found.
   4812        if (multipleFiles && maybeBackupFiles.length > 1 && validateFile) {
   4813          return { multipleBackupsFound: true };
   4814        }
   4815 
   4816        // TODO: support multiple valid backups for different profiles.
   4817        // Currently, we break out of the loop and select the first profile that works.
   4818        // We want to eventually support showing multiple valid profiles to the user.
   4819        return { multipleBackupsFound: false };
   4820      }
   4821    } catch (e) {
   4822      lazy.logConsole.error(
   4823        "There was an error while looking for backups: ",
   4824        e
   4825      );
   4826    }
   4827 
   4828    return { multipleBackupsFound: false };
   4829  }
   4830 
   4831  /**
   4832   * Searches for backup files in predefined "well-known" locations.
   4833   *
   4834   * This function wraps findIfABackupFileExists to present the result
   4835   * in an object for processing in the frontend.
   4836   *
   4837   * Assumptions:
   4838   * - Intended to be called before `about:welcome` opens.
   4839   * - Clears any existing `lastBackupFileName` and `backupFileToRestore`
   4840   *   in the internal state prior to searching.
   4841   *
   4842   * @param {object} [options] - Configuration options.
   4843   * @param {boolean} [options.validateFile=false] - Whether to validate each backup file
   4844   *   before selecting it.
   4845   * @param {boolean} [options.multipleFiles=false] - Whether to allow selecting a file
   4846   *   when multiple files are found
   4847   *
   4848   * @returns {Promise<object>} A result object with the following properties:
   4849   * - {boolean} found — Whether a backup file was found.
   4850   * - {string|null} backupFileToRestore — Path or identifier of the backup file (if found).
   4851   * - {boolean} multipleBackupsFound — Currently always `false`, reserved for future use.
   4852   */
   4853  async findBackupsInWellKnownLocations({
   4854    validateFile = false,
   4855    multipleFiles = false,
   4856  } = {}) {
   4857    this.#_state.backupFileToRestore = null;
   4858 
   4859    let { multipleBackupsFound } = await this.findIfABackupFileExists({
   4860      validateFile,
   4861      multipleFiles,
   4862    });
   4863 
   4864    // if a valid backup file was found, backupFileToRestore should be set
   4865    if (this.#_state.backupFileToRestore) {
   4866      return {
   4867        found: true,
   4868        backupFileToRestore: this.#_state.backupFileToRestore,
   4869        multipleBackupsFound,
   4870      };
   4871    }
   4872    return { found: false, backupFileToRestore: null, multipleBackupsFound };
   4873  }
   4874 
   4875  /**
   4876   * Shows a native folder picker to set the location to write the single-file
   4877   * archive files.
   4878   *
   4879   * @param {ChromeWindow} window
   4880   *   The top-level browsing window to associate the file picker with.
   4881   * @returns {Promise<undefined>}
   4882   */
   4883  async editBackupLocation(window) {
   4884    let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
   4885    let mode = Ci.nsIFilePicker.modeGetFolder;
   4886    fp.init(window.browsingContext, "", mode);
   4887 
   4888    let currentBackupDirPathParent = PathUtils.parent(
   4889      this.#_state.backupDirPath
   4890    );
   4891    if (await IOUtils.exists(currentBackupDirPathParent)) {
   4892      fp.displayDirectory = await IOUtils.getDirectory(
   4893        currentBackupDirPathParent
   4894      );
   4895    }
   4896 
   4897    let result = await new Promise(resolve => fp.open(resolve));
   4898 
   4899    if (result === Ci.nsIFilePicker.returnCancel) {
   4900      return;
   4901    }
   4902 
   4903    let path = fp.file.path;
   4904 
   4905    // If the same parent directory was chosen, this is a no-op.
   4906    if (
   4907      PathUtils.join(path, BackupService.BACKUP_DIR_NAME) == lazy.backupDirPref
   4908    ) {
   4909      return;
   4910    }
   4911 
   4912    // If the location changed, delete the last backup there if one exists.
   4913    try {
   4914      await this.deleteLastBackup();
   4915    } catch {
   4916      lazy.logConsole.error(
   4917        "Error deleting last backup while editing the backup location."
   4918      );
   4919      // Fall through so the new backup directory is set.
   4920    }
   4921    this.setParentDirPath(path);
   4922  }
   4923 
   4924  /**
   4925   * Will attempt to delete the last created single-file archive if it exists.
   4926   * Once done, this method will also check the parent folder to see if it's
   4927   * empty. If so, then the folder is removed.
   4928   *
   4929   * @returns {Promise<undefined>}
   4930   */
   4931  async deleteLastBackup() {
   4932    if (!lazy.scheduledBackupsPref) {
   4933      lazy.logConsole.debug(
   4934        "Not deleting last backup, as scheduled backups are disabled."
   4935      );
   4936      return undefined;
   4937    }
   4938 
   4939    return locks.request(
   4940      BackupService.WRITE_BACKUP_LOCK_NAME,
   4941      { signal: this.#backupWriteAbortController.signal },
   4942      async () => {
   4943        if (lazy.lastBackupFileName) {
   4944          if (await this.#infalliblePathExists(lazy.backupDirPref)) {
   4945            let backupFilePath = PathUtils.join(
   4946              lazy.backupDirPref,
   4947              lazy.lastBackupFileName
   4948            );
   4949 
   4950            lazy.logConsole.log(
   4951              "Attempting to delete last backup file at ",
   4952              backupFilePath
   4953            );
   4954            await IOUtils.remove(backupFilePath, {
   4955              ignoreAbsent: true,
   4956              retryReadonly: true,
   4957            });
   4958          }
   4959 
   4960          Services.prefs.clearUserPref(LAST_BACKUP_FILE_NAME_PREF_NAME);
   4961        } else {
   4962          lazy.logConsole.log(
   4963            "Not deleting last backup file, since none is known about."
   4964          );
   4965        }
   4966 
   4967        if (await this.#infalliblePathExists(lazy.backupDirPref)) {
   4968          // See if there are any other files lingering around in the destination
   4969          // folder. If not, delete that folder too.
   4970          let children = await IOUtils.getChildren(lazy.backupDirPref);
   4971          if (!children.length) {
   4972            await IOUtils.remove(lazy.backupDirPref, { retryReadony: true });
   4973          }
   4974        }
   4975      }
   4976    );
   4977  }
   4978 
   4979  /**
   4980   * Wraps an IOUtils.exists in a try/catch and returns true iff the passed
   4981   * path actually exists on the file system. Returns false if the path doesn't
   4982   * exist or is an invalid path.
   4983   *
   4984   * @param {string} path
   4985   *   The path to check for existence.
   4986   * @returns {Promise<boolean>}
   4987   */
   4988  async #infalliblePathExists(path) {
   4989    let exists = false;
   4990    try {
   4991      exists = await IOUtils.exists(path);
   4992    } catch (e) {
   4993      lazy.logConsole.warn("Path failed existence check :", path);
   4994      return false;
   4995    }
   4996    return exists;
   4997  }
   4998 }