Quellcodebibliothek Statistik Leitseite products/sources/formale Sprachen/C/Firefox/browser/components/backup/   (Browser von der Mozilla Stiftung Version 136.0.1©)  Datei vom 10.2.2025 mit Größe 118 kB image not shown  

Quellcode-Bibliothek BackupService.sys.mjs   Sprache: unbekannt

 
Columbo aufrufen.mjs Download desUnknown {[0] [0] [0]}Datei anzeigen

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import * as DefaultBackupResources from "resource:///modules/backup/BackupResources.sys.mjs";
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs";
import {
  MeasurementUtils,
  BYTES_IN_KILOBYTE,
  BYTES_IN_MEGABYTE,
  BYTES_IN_MEBIBYTE,
} from "resource:///modules/backup/MeasurementUtils.sys.mjs";

import {
  ERRORS,
  STEPS,
} from "chrome://browser/content/backup/backup-constants.mjs";
import { BackupError } from "resource:///modules/backup/BackupError.mjs";

const BACKUP_DIR_PREF_NAME = "browser.backup.location";
const SCHEDULED_BACKUPS_ENABLED_PREF_NAME = "browser.backup.scheduled.enabled";
const IDLE_THRESHOLD_SECONDS_PREF_NAME =
  "browser.backup.scheduled.idle-threshold-seconds";
const MINIMUM_TIME_BETWEEN_BACKUPS_SECONDS_PREF_NAME =
  "browser.backup.scheduled.minimum-time-between-backups-seconds";
const LAST_BACKUP_TIMESTAMP_PREF_NAME =
  "browser.backup.scheduled.last-backup-timestamp";
const LAST_BACKUP_FILE_NAME_PREF_NAME =
  "browser.backup.scheduled.last-backup-file";

const SCHEMAS = Object.freeze({
  BACKUP_MANIFEST: 1,
  ARCHIVE_JSON_BLOCK: 2,
});

const lazy = {};

ChromeUtils.defineLazyGetter(lazy, "logConsole", function () {
  return console.createInstance({
    prefix: "BackupService",
    maxLogLevel: Services.prefs.getBoolPref("browser.backup.log", false)
      ? "Debug"
      : "Warn",
  });
});

ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => {
  return ChromeUtils.importESModule(
    "resource://gre/modules/FxAccounts.sys.mjs"
  ).getFxAccountsSingleton();
});

ChromeUtils.defineESModuleGetters(lazy, {
  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
  ArchiveDecryptor: "resource:///modules/backup/ArchiveEncryption.sys.mjs",
  ArchiveEncryptionState:
    "resource:///modules/backup/ArchiveEncryptionState.sys.mjs",
  ArchiveUtils: "resource:///modules/backup/ArchiveUtils.sys.mjs",
  BasePromiseWorker: "resource://gre/modules/PromiseWorker.sys.mjs",
  ClientID: "resource://gre/modules/ClientID.sys.mjs",
  DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
  DownloadPaths: "resource://gre/modules/DownloadPaths.sys.mjs",
  FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
  JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
  NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
  UIState: "resource://services-sync/UIState.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "ZipWriter", () =>
  Components.Constructor("@mozilla.org/zipwriter;1", "nsIZipWriter", "open")
);
ChromeUtils.defineLazyGetter(lazy, "ZipReader", () =>
  Components.Constructor(
    "@mozilla.org/libjar/zip-reader;1",
    "nsIZipReader",
    "open"
  )
);
ChromeUtils.defineLazyGetter(lazy, "nsLocalFile", () =>
  Components.Constructor("@mozilla.org/file/local;1", "nsIFile", "initWithPath")
);

ChromeUtils.defineLazyGetter(lazy, "BinaryInputStream", () =>
  Components.Constructor(
    "@mozilla.org/binaryinputstream;1",
    "nsIBinaryInputStream",
    "setInputStream"
  )
);

ChromeUtils.defineLazyGetter(lazy, "gFluentStrings", function () {
  return new Localization(
    ["branding/brand.ftl", "preview/backupSettings.ftl"],
    true
  );
});

ChromeUtils.defineLazyGetter(lazy, "gDOMLocalization", function () {
  return new DOMLocalization([
    "branding/brand.ftl",
    "preview/backupSettings.ftl",
  ]);
});

ChromeUtils.defineLazyGetter(lazy, "defaultParentDirPath", function () {
  return Services.dirsvc.get("Docs", Ci.nsIFile).path;
});

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "scheduledBackupsPref",
  SCHEDULED_BACKUPS_ENABLED_PREF_NAME,
  false,
  function onUpdateScheduledBackups(_pref, _prevVal, newVal) {
    let bs = BackupService.get();
    if (bs) {
      bs.onUpdateScheduledBackups(newVal);
    }
  }
);

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "backupDirPref",
  BACKUP_DIR_PREF_NAME,
  /**
   * To avoid disk access upon startup, do not set DEFAULT_PARENT_DIR_PATH
   * as a fallback value here. Let registered widgets prompt BackupService
   * to update the parentDirPath.
   *
   * @see BackupService.state
   * @see DEFAULT_PARENT_DIR_PATH
   * @see setParentDirPath
   */
  null,
  async function onUpdateLocationDirPath(_pref, _prevVal, newVal) {
    let bs;
    try {
      bs = BackupService.get();
    } catch (e) {
      // This can throw if the BackupService hasn't initialized yet, which
      // is a case we're okay to ignore.
    }
    if (bs) {
      await bs.onUpdateLocationDirPath(newVal);
    }
  }
);

XPCOMUtils.defineLazyPreferenceGetter(
  lazy,
  "minimumTimeBetweenBackupsSeconds",
  MINIMUM_TIME_BETWEEN_BACKUPS_SECONDS_PREF_NAME,
  3600 /* 1 hour */
);

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "idleService",
  "@mozilla.org/widget/useridleservice;1",
  "nsIUserIdleService"
);

XPCOMUtils.defineLazyServiceGetter(
  lazy,
  "nativeOSKeyStore",
  "@mozilla.org/security/oskeystore;1",
  Ci.nsIOSKeyStore
);

/**
 * A class that wraps a multipart/mixed stream converter instance, and streams
 * in the binary part of a single-file archive (which should be at the second
 * index of the attachments) as a ReadableStream.
 *
 * The bytes that are read in are text decoded, but are not guaranteed to
 * represent a "full chunk" of base64 data. Consumers should ensure to buffer
 * the strings emitted by this stream, and to search for `\n` characters, which
 * indicate the end of a (potentially encrypted and) base64 encoded block.
 */
class BinaryReadableStream {
  #channel = null;

  /**
   * Constructs a BinaryReadableStream.
   *
   * @param {nsIChannel} channel
   *   The channel through which to begin the flow of bytes from the
   *   inputStream
   */
  constructor(channel) {
    this.#channel = channel;
  }

  /**
   * Implements `start` from the `underlyingSource` of a ReadableStream
   *
   * @param {ReadableStreamDefaultController} controller
   *   The controller for the ReadableStream to feed strings into.
   */
  start(controller) {
    let streamConv = Cc["@mozilla.org/streamConverters;1"].getService(
      Ci.nsIStreamConverterService
    );

    let textDecoder = new TextDecoder();

    // The attachment index that should contain the binary data.
    const EXPECTED_CONTENT_TYPE = "application/octet-stream";

    // This is fairly clumsy, but by using an object nsIStreamListener like
    // this, I can keep from stashing the `controller` somewhere, as it's
    // available in the closure.
    let multipartListenerForBinary = {
      /**
       * True once we've found an attachment matching our EXPECTED_CONTENT_TYPE.
       * Once this is true, bytes flowing into onDataAvailable will be
       * enqueued through the controller.
       *
       * @type {boolean}
       */
      _enabled: false,

      /**
       * True once onStopRequest has been called once the listener is enabled.
       * After this, the listener will not attempt to read any data passed
       * to it through onDataAvailable.
       *
       * @type {boolean}
       */
      _done: false,

      QueryInterface: ChromeUtils.generateQI([
        "nsIStreamListener",
        "nsIRequestObserver",
        "nsIMultiPartChannelListener",
      ]),

      /**
       * Called when we begin to load an attachment from the MIME message.
       *
       * @param {nsIRequest} request
       *   The request corresponding to the source of the data.
       */
      onStartRequest(request) {
        if (!(request instanceof Ci.nsIChannel)) {
          throw Components.Exception(
            "onStartRequest expected an nsIChannel request",
            Cr.NS_ERROR_UNEXPECTED
          );
        }
        this._enabled = request.contentType == EXPECTED_CONTENT_TYPE;
      },

      /**
       * Called when data is flowing in for an attachment.
       *
       * @param {nsIRequest} request
       *   The request corresponding to the source of the data.
       * @param {nsIInputStream} stream
       *   The input stream containing the data chunk.
       * @param {number} offset
       *   The number of bytes that were sent in previous onDataAvailable calls
       *   for this request. In other words, the sum of all previous count
       *   parameters.
       * @param {number} count
       *   The number of bytes available in the stream
       */
      onDataAvailable(request, stream, offset, count) {
        if (!this._enabled) {
          // We don't care about this data, just move on.
          return;
        }

        let binStream = new lazy.BinaryInputStream(stream);
        let bytes = new Uint8Array(count);
        binStream.readArrayBuffer(count, bytes.buffer);
        let string = textDecoder.decode(bytes);
        controller.enqueue(string);
      },

      /**
       * Called when the load of an attachment finishes.
       */
      onStopRequest() {
        if (this._enabled && !this._done) {
          this._enabled = false;
          this._done = true;

          controller.close();

          // No need to load anything else - abort reading in more
          // attachments.
          throw Components.Exception(
            "Got binary block - cancelling loading the multipart stream.",
            Cr.NS_BINDING_ABORTED
          );
        }
      },

      onAfterLastPart() {
        if (!this._done) {
          // We finished reading the parts before we found the binary block,
          // so the binary block is missing.
          controller.error(
            new BackupError(
              "Could not find binary block.",
              ERRORS.CORRUPTED_ARCHIVE
            )
          );
        }
      },
    };

    let conv = streamConv.asyncConvertData(
      "multipart/mixed",
      "*/*",
      multipartListenerForBinary,
      null
    );

    this.#channel.asyncOpen(conv);
  }
}

/**
 * A TransformStream class that takes in chunks of base64 encoded data,
 * decodes (and eventually, decrypts) them before passing the resulting
 * bytes along to the next step in the pipe.
 *
 * The BinaryReadableStream feeds strings into this TransformStream, but the
 * buffering of these streams means that we cannot be certain that the string
 * that was passed is the entirety of a base64 encoded block. ArchiveWorker
 * puts every block on its own line, meaning that we must simply look for
 * newlines to indicate when a break between full blocks is, and buffer chunks
 * until we see those breaks - only decoding once we have a full block.
 */
export class DecoderDecryptorTransformer {
  #buffer = "";
  #decryptor = null;

  /**
   * Constructs the DecoderDecryptorTransformer.
   *
   * @param {ArchiveDecryptor|null} decryptor
   *   An initialized ArchiveDecryptor, if this stream of bytes is presumed to
   *   be encrypted.
   */
  constructor(decryptor) {
    this.#decryptor = decryptor;
  }

  /**
   * Consumes a single chunk of a base64 encoded string sent by
   * BinaryReadableStream.
   *
   * @param {string} chunkPart
   *   A part of a chunk of a base64 encoded string sent by
   *   BinaryReadableStream.
   * @param {TransformStreamDefaultController} controller
   *   The controller to send decoded bytes to.
   * @returns {Promise<undefined>}
   */
  async transform(chunkPart, controller) {
    // A small optimization, but considering the size of these strings, it's
    // likely worth it.
    if (this.#buffer) {
      this.#buffer += chunkPart;
    } else {
      this.#buffer = chunkPart;
    }

    // If the compressed archive was large enough, then it got split up over
    // several chunks. In that case, each chunk is separated by a newline. We
    // also filter out any extraneous newlines that might have been included
    // at the end.
    let chunks = this.#buffer.split("\n").filter(chunk => chunk != "");

    this.#buffer = chunks.pop();
    // If there were any remaining parts that we split out from the buffer,
    // they must constitute full blocks that we can decode.
    for (let chunk of chunks) {
      await this.#processChunk(controller, chunk);
    }
  }

  /**
   * Called once BinaryReadableStream signals that it has sent all of its
   * strings, in which case we know that whatever is in the buffer should be
   * a valid block.
   *
   * @param {TransformStreamDefaultController} controller
   *   The controller to send decoded bytes to.
   * @returns {Promise<undefined>}
   */
  async flush(controller) {
    await this.#processChunk(controller, this.#buffer, true);
    this.#buffer = "";
  }

  /**
   * Decodes (and potentially decrypts) a valid base64 encoded chunk into a
   * Uint8Array and sends it to the next step in the pipe.
   *
   * @param {TransformStreamDefaultController} controller
   *   The controller to send decoded bytes to.
   * @param {string} chunk
   *   The base64 encoded string to decode and potentially decrypt.
   * @param {boolean} [isLastChunk=false]
   *   True if this is the last chunk to be processed.
   * @returns {Promise<undefined>}
   */
  async #processChunk(controller, chunk, isLastChunk = false) {
    try {
      let bytes = lazy.ArchiveUtils.stringToArray(chunk);

      if (this.#decryptor) {
        let plaintextBytes = await this.#decryptor.decrypt(bytes, isLastChunk);
        controller.enqueue(plaintextBytes);
      } else {
        controller.enqueue(bytes);
      }
    } catch (e) {
      // Something went wrong base64 decoding or decrypting. Tell the controller
      // that we're done, so that it can destroy anything that was decoded /
      // decrypted already.
      controller.error("Corrupted archive.");
    }
  }
}

/**
 * A class that lets us construct a WritableStream that writes bytes to a file
 * on disk somewhere.
 */
export class FileWriterStream {
  /**
   * @type {string}
   */
  #destPath = null;

  /**
   * @type {nsIOutputStream}
   */
  #outStream = null;

  /**
   * @type {nsIBinaryOutputStream}
   */
  #binStream = null;

  /**
   * @type {ArchiveDecryptor}
   */
  #decryptor = null;

  /**
   * Constructor for FileWriterStream.
   *
   * @param {string} destPath
   *   The path to write the incoming bytes to.
   * @param {ArchiveDecryptor|null} decryptor
   *   An initialized ArchiveDecryptor, if this stream of bytes is presumed to
   *   be encrypted.
   */
  constructor(destPath, decryptor) {
    this.#destPath = destPath;
    this.#decryptor = decryptor;
  }

  /**
   * Called once the first set of bytes comes in from the
   * DecoderDecryptorTransformer. This creates the file, and sets up the
   * underlying nsIOutputStream mechanisms to let us write bytes to the file.
   */
  async start() {
    let extractionDestFile = await IOUtils.getFile(this.#destPath);
    this.#outStream =
      lazy.FileUtils.openSafeFileOutputStream(extractionDestFile);
    this.#binStream = Cc["@mozilla.org/binaryoutputstream;1"].createInstance(
      Ci.nsIBinaryOutputStream
    );
    this.#binStream.setOutputStream(this.#outStream);
  }

  /**
   * Writes bytes to the destination on the file system.
   *
   * @param {Uint8Array} chunk
   *   The bytes to stream to the destination file.
   */
  write(chunk) {
    this.#binStream.writeByteArray(chunk);
  }

  /**
   * Called once the stream of bytes finishes flowing in and closes the stream.
   *
   * @param {WritableStreamDefaultController} controller
   *   The controller for the WritableStream.
   */
  close(controller) {
    lazy.FileUtils.closeSafeFileOutputStream(this.#outStream);
    if (this.#decryptor && !this.#decryptor.isDone()) {
      lazy.logConsole.error(
        "Decryptor was not done when the stream was closed."
      );
      controller.error("Corrupted archive.");
    }
  }

  /**
   * Called if something went wrong while decoding / decrypting the stream of
   * bytes. This destroys any bytes that may have been decoded / decrypted
   * prior to the error.
   *
   * @param {string} reason
   *   The reported reason for aborting the decoding / decrpytion.
   */
  async abort(reason) {
    lazy.logConsole.error(`Writing to ${this.#destPath} failed: `, reason);
    lazy.FileUtils.closeSafeFileOutputStream(this.#outStream);
    await IOUtils.remove(this.#destPath, {
      ignoreAbsent: true,
      retryReadonly: true,
    });
  }
}

/**
 * The BackupService class orchestrates the scheduling and creation of profile
 * backups. It also does most of the heavy lifting for the restoration of a
 * profile backup.
 */
export class BackupService extends EventTarget {
  /**
   * The BackupService singleton instance.
   *
   * @static
   * @type {BackupService|null}
   */
  static #instance = null;

  /**
   * Map of instantiated BackupResource classes.
   *
   * @type {Map<string, BackupResource>}
   */
  #resources = new Map();

  /**
   * The name of the backup folder. Should be localized.
   *
   * @see BACKUP_DIR_NAME
   */
  static #backupFolderName = null;

  /**
   * The name of the backup archive file. Should be localized.
   *
   * @see BACKUP_FILE_NAME
   */
  static #backupFileName = null;

  /**
   * Set to true if a backup is currently in progress. Causes stateUpdate()
   * to be called.
   *
   * @see BackupService.stateUpdate()
   * @param {boolean} val
   *   True if a backup is in progress.
   */
  set #backupInProgress(val) {
    if (this.#_state.backupInProgress != val) {
      this.#_state.backupInProgress = val;
      this.stateUpdate();
    }
  }

  /**
   * True if a backup is currently in progress.
   *
   * @type {boolean}
   */
  get #backupInProgress() {
    return this.#_state.backupInProgress;
  }

  /**
   * Dispatches an event to let listeners know that the BackupService state
   * object has been updated.
   */
  stateUpdate() {
    this.dispatchEvent(new CustomEvent("BackupService:StateUpdate"));
  }

  /**
   * True if a recovery is currently in progress.
   *
   * @type {boolean}
   */
  #recoveryInProgress = false;

  /**
   * An object holding the current state of the BackupService instance, for
   * the purposes of representing it in the user interface. Ideally, this would
   * be named #state instead of #_state, but sphinx-js seems to be fairly
   * unhappy with that coupled with the ``state`` getter.
   *
   * @type {object}
   */
  #_state = {
    backupDirPath: lazy.backupDirPref,
    defaultParent: {},
    backupFileToRestore: null,
    backupFileInfo: null,
    backupInProgress: false,
    scheduledBackupsEnabled: lazy.scheduledBackupsPref,
    encryptionEnabled: false,
    /** @type {number?} Number of seconds since UNIX epoch */
    lastBackupDate: null,
    lastBackupFileName: "",
    supportBaseLink: Services.urlFormatter.formatURLPref("app.support.baseURL"),
  };

  /**
   * A Promise that will resolve once the postRecovery steps are done. It will
   * also resolve if postRecovery steps didn't need to run.
   *
   * @see BackupService.checkForPostRecovery()
   * @type {Promise<undefined>}
   */
  #postRecoveryPromise;

  /**
   * The resolving function for #postRecoveryPromise, which should be called
   * by checkForPostRecovery() before exiting.
   *
   * @type {Function}
   */
  #postRecoveryResolver;

  /**
   * The currently used ArchiveEncryptionState. Callers should use
   * loadEncryptionState() instead, to ensure that any pre-serialized
   * encryption state has been read in and deserialized.
   *
   * This member can be in 3 states:
   *
   * 1. undefined - no attempt has been made to load encryption state from
   *    disk yet.
   * 2. null - encryption is not enabled.
   * 3. ArchiveEncryptionState - encryption is enabled.
   *
   * @see BackupService.loadEncryptionState()
   * @type {ArchiveEncryptionState|null|undefined}
   */
  #encState = undefined;

  /**
   * The PlacesObserver instance used to monitor the Places database for
   * history and bookmark removals to determine if backups should be
   * regenerated.
   *
   * @type {PlacesObserver|null}
   */
  #placesObserver = null;

  /**
   * The AbortController used to abort any queued requests to create or delete
   * backups that might be waiting on the WRITE_BACKUP_LOCK_NAME lock.
   *
   * @type {AbortController}
   */
  #backupWriteAbortController = null;

  /**
   * A DeferredTask that will cause the last known backup to be deleted, and
   * a new backup to be created.
   *
   * See BackupService.#debounceRegeneration()
   *
   * @type {DeferredTask}
   */
  #regenerationDebouncer = null;

  /**
   * True if takeMeasurements has been called and various measurements related
   * to the BackupService have been taken.
   *
   * @type {boolean}
   */
  #takenMeasurements = false;

  /**
   * The path of the default parent directory for saving backups.
   * The current default is the Documents directory.
   *
   * @returns {string} The path of the default parent directory
   */
  static get DEFAULT_PARENT_DIR_PATH() {
    return lazy.defaultParentDirPath;
  }

  /**
   * The localized name for the user's backup folder.
   *
   * @returns {string} The localized backup folder name
   */
  static get BACKUP_DIR_NAME() {
    if (!BackupService.#backupFolderName) {
      BackupService.#backupFolderName = lazy.DownloadPaths.sanitize(
        lazy.gFluentStrings.formatValueSync("backup-folder-name")
      );
    }
    return BackupService.#backupFolderName;
  }

  /**
   * The localized name for the user's backup archive file. This will have
   * `.html` appended to it before writing the archive file.
   *
   * @returns {string} The localized backup file name
   */
  static get BACKUP_FILE_NAME() {
    if (!BackupService.#backupFileName) {
      BackupService.#backupFileName = lazy.DownloadPaths.sanitize(
        lazy.gFluentStrings.formatValueSync("backup-file-name")
      );
    }
    return BackupService.#backupFileName;
  }

  /**
   * The name of the folder within the profile folder where this service reads
   * and writes state to.
   *
   * @type {string}
   */
  static get PROFILE_FOLDER_NAME() {
    return "backups";
  }

  /**
   * The name of the folder within the PROFILE_FOLDER_NAME where the staging
   * folder / prior backups will be stored.
   *
   * @type {string}
   */
  static get SNAPSHOTS_FOLDER_NAME() {
    return "snapshots";
  }

  /**
   * The name of the backup manifest file.
   *
   * @type {string}
   */
  static get MANIFEST_FILE_NAME() {
    return "backup-manifest.json";
  }

  /**
   * A promise that resolves to the schema for the backup manifest that this
   * BackupService uses when creating a backup. This should be accessed via
   * the `MANIFEST_SCHEMA` static getter.
   *
   * @type {Promise<object>}
   */
  static #manifestSchemaPromise = null;

  /**
   * The current schema version of the backup manifest that this BackupService
   * uses when creating a backup.
   *
   * @type {Promise<object>}
   */
  static get MANIFEST_SCHEMA() {
    if (!BackupService.#manifestSchemaPromise) {
      BackupService.#manifestSchemaPromise = BackupService.getSchemaForVersion(
        SCHEMAS.BACKUP_MANIFEST,
        lazy.ArchiveUtils.SCHEMA_VERSION
      );
    }

    return BackupService.#manifestSchemaPromise;
  }

  /**
   * The name of the post recovery file written into the newly created profile
   * directory just after a profile is recovered from a backup.
   *
   * @type {string}
   */
  static get POST_RECOVERY_FILE_NAME() {
    return "post-recovery.json";
  }

  /**
   * The name of the serialized ArchiveEncryptionState that is written to disk
   * if encryption is enabled.
   *
   * @type {string}
   */
  static get ARCHIVE_ENCRYPTION_STATE_FILE() {
    return "enc-state.json";
  }

  /**
   * Returns the SCHEMAS constants, which is a key/value store of constants.
   *
   * @type {object}
   */
  static get SCHEMAS() {
    return SCHEMAS;
  }

  /**
   * Returns the filename used for the intermediary compressed ZIP file that
   * is extracted from archives during recovery.
   *
   * @type {string}
   */
  static get RECOVERY_ZIP_FILE_NAME() {
    return "recovery.zip";
  }

  /**
   * Returns the schema for the schemaType for a given version.
   *
   * @param {number} schemaType
   *   One of the constants from SCHEMAS.
   * @param {number} version
   *   The version of the schema to return.
   * @returns {Promise<object>}
   */
  static async getSchemaForVersion(schemaType, version) {
    let schemaURL;

    if (schemaType == SCHEMAS.BACKUP_MANIFEST) {
      schemaURL = `chrome://browser/content/backup/BackupManifest.${version}.schema.json`;
    } else if (schemaType == SCHEMAS.ARCHIVE_JSON_BLOCK) {
      schemaURL = `chrome://browser/content/backup/ArchiveJSONBlock.${version}.schema.json`;
    } else {
      throw new BackupError(
        `Did not recognize SCHEMAS constant: ${schemaType}`,
        ERRORS.UNKNOWN
      );
    }

    let response = await fetch(schemaURL);
    return response.json();
  }

  /**
   * The level of Zip compression to use on the zipped staging folder.
   *
   * @type {number}
   */
  static get COMPRESSION_LEVEL() {
    return Ci.nsIZipWriter.COMPRESSION_BEST;
  }

  /**
   * Returns the chrome:// URI string for the template that should be used to
   * construct the single-file archive.
   *
   * @type {string}
   */
  static get ARCHIVE_TEMPLATE() {
    return "chrome://browser/content/backup/archive.template.html";
  }

  /**
   * The native OSKeyStore label used for the temporary recovery store. The
   * temporary recovery store is initialized with the original OSKeyStore
   * secret that was included in an encrypted backup, and then used by any
   * BackupResource's that need to decrypt / re-encrypt OSKeyStore secrets for
   * the current device.
   *
   * @type {string}
   */
  static get RECOVERY_OSKEYSTORE_LABEL() {
    return AppConstants.MOZ_APP_BASENAME + " Backup Recovery Storage";
  }

  /**
   * The name of the exclusive Web Lock that will be requested and held when
   * creating or deleting a backup.
   *
   * @type {string}
   */
  static get WRITE_BACKUP_LOCK_NAME() {
    return "write-backup";
  }

  /**
   * The amount of time (in milliseconds) to wait for our backup regeneration
   * debouncer to kick off a regeneration.
   *
   * @type {number}
   */
  static get REGENERATION_DEBOUNCE_RATE_MS() {
    return 10000;
  }

  /**
   * Returns a reference to a BackupService singleton. If this is the first time
   * that this getter is accessed, this causes the BackupService singleton to be
   * be instantiated.
   *
   * @static
   * @type {BackupService}
   */
  static init() {
    if (this.#instance) {
      return this.#instance;
    }
    this.#instance = new BackupService(DefaultBackupResources);

    this.#instance.checkForPostRecovery();
    this.#instance.initBackupScheduler();
    return this.#instance;
  }

  /**
   * Returns a reference to the BackupService singleton. If the singleton has
   * not been initialized, an error is thrown.
   *
   * @static
   * @returns {BackupService}
   */
  static get() {
    if (!this.#instance) {
      throw new BackupError(
        "BackupService not initialized",
        ERRORS.UNINITIALIZED
      );
    }
    return this.#instance;
  }

  /**
   * Create a BackupService instance.
   *
   * @param {object} [backupResources=DefaultBackupResources]
   *   Object containing BackupResource classes to associate with this service.
   */
  constructor(backupResources = DefaultBackupResources) {
    super();
    lazy.logConsole.debug("Instantiated");

    for (const resourceName in backupResources) {
      let resource = backupResources[resourceName];
      this.#resources.set(resource.key, resource);
    }

    let { promise, resolve } = Promise.withResolvers();
    this.#postRecoveryPromise = promise;
    this.#postRecoveryResolver = resolve;
    this.#backupWriteAbortController = new AbortController();
    this.#regenerationDebouncer = new lazy.DeferredTask(async () => {
      if (!this.#backupWriteAbortController.signal.aborted) {
        await this.deleteLastBackup();
        if (lazy.scheduledBackupsPref) {
          await this.createBackupOnIdleDispatch();
        }
      }
    }, BackupService.REGENERATION_DEBOUNCE_RATE_MS);
  }

  /**
   * Returns a reference to a Promise that will resolve with undefined once
   * postRecovery steps have had a chance to run. This will also be resolved
   * with undefined if no postRecovery steps needed to be run.
   *
   * @see BackupService.checkForPostRecovery()
   * @returns {Promise<undefined>}
   */
  get postRecoveryComplete() {
    return this.#postRecoveryPromise;
  }

  /**
   * Returns a state object describing the state of the BackupService for the
   * purposes of representing it in the user interface. The returned state
   * object is immutable.
   *
   * @type {object}
   */
  get state() {
    if (!Object.keys(this.#_state.defaultParent).length) {
      let defaultPath = BackupService.DEFAULT_PARENT_DIR_PATH;
      this.#_state.defaultParent = {
        path: defaultPath,
        fileName: PathUtils.filename(defaultPath),
        iconURL: this.getIconFromFilePath(defaultPath),
      };
    }

    return Object.freeze(structuredClone(this.#_state));
  }

  /**
   * Attempts to find the right folder to write the single-file archive to, and
   * if it does not exist, to create it.
   *
   * If the configured destination's parent folder does not exist and cannot
   * be recreated, we will fall back to the `defaultParentDirPath`. If
   * `defaultParentDirPath` happens to not exist or cannot be created, we will
   * fall back to the home directory. If _that_ folder does not exist and cannot
   * be recreated, this method will reject.
   *
   * @param {string} configuredDestFolderPath
   *   The currently configured destination folder for the archive.
   * @returns {Promise<string, Error>}
   */
  async resolveArchiveDestFolderPath(configuredDestFolderPath) {
    lazy.logConsole.log(
      "Resolving configured archive destination folder: ",
      configuredDestFolderPath
    );

    // Try to create the configured folder ancestry. If that fails, we clear
    // configuredDestFolderPath so that we can try the fallback paths, as
    // if the folder was never set.
    try {
      await IOUtils.makeDirectory(configuredDestFolderPath, {
        createAncestors: true,
        ignoreExisting: true,
      });
      return configuredDestFolderPath;
    } catch (e) {
      lazy.logConsole.warn("Could not create configured destination path: ", e);
    }

    lazy.logConsole.warn(
      "The destination directory was invalid. Attempting to fall back to " +
        "default parent folder: ",
      BackupService.DEFAULT_PARENT_DIR_PATH
    );
    let fallbackFolderPath = PathUtils.join(
      BackupService.DEFAULT_PARENT_DIR_PATH,
      BackupService.BACKUP_DIR_NAME
    );
    try {
      await IOUtils.makeDirectory(fallbackFolderPath, {
        createAncestors: true,
        ignoreExisting: true,
      });
      return fallbackFolderPath;
    } catch (e) {
      lazy.logConsole.warn("Could not create fallback destination path: ", e);
    }

    let homeDirPath = PathUtils.join(
      Services.dirsvc.get("Home", Ci.nsIFile).path,
      BackupService.BACKUP_DIR_NAME
    );
    lazy.logConsole.warn(
      "The destination directory was invalid. Attempting to fall back to " +
        "Home folder: ",
      homeDirPath
    );
    try {
      await IOUtils.makeDirectory(homeDirPath, {
        createAncestors: true,
        ignoreExisting: true,
      });
      return homeDirPath;
    } catch (e) {
      lazy.logConsole.warn("Could not create Home destination path: ", e);
      throw new Error(
        "Could not resolve to a writable destination folder path.",
        { cause: ERRORS.FILE_SYSTEM_ERROR }
      );
    }
  }

  /**
   * Computes the appropriate link to place in the single-file archive for
   * downloading a version of this application for the same update channel.
   *
   * When bug 1905909 lands, we'll first check to see if there are download
   * links available in Remote Settings.
   *
   * If there aren't any, we will fallback by looking for preference values at
   * browser.backup.template.fallback-download.${updateChannel}.
   *
   * If no such preference exists, a final "ultimate" fallback download link is
   * chosen for the release channel.
   *
   * @param {string} updateChannel
   *  The current update channel for the application, as provided by
   *  AppConstants.MOZ_UPDATE_CHANNEL.
   * @returns {Promise<string>}
   */
  async resolveDownloadLink(updateChannel) {
    // If all else fails, this is the download link we'll put into the rendered
    // template.
    const ULTIMATE_FALLBACK_DOWNLOAD_URL =
      "https://www.mozilla.org/firefox/download/thanks/?s=direct&utm_medium=firefox-desktop&utm_source=backup&utm_campaign=firefox-backup-2024&utm_content=control";
    const FALLBACK_DOWNLOAD_URL = Services.prefs.getStringPref(
      `browser.backup.template.fallback-download.${updateChannel}`,
      ULTIMATE_FALLBACK_DOWNLOAD_URL
    );

    // Bug 1905909: Once we set up the download links in RemoteSettings, we can
    // query for them here.

    return FALLBACK_DOWNLOAD_URL;
  }

  /**
   * @typedef {object} CreateBackupResult
   * @property {object} manifest
   *   The backup manifest data of the created backup. See BackupManifest
   *   schema for specific details.
   * @property {string} archivePath
   *   The path to the single file archive that was created.
   */

  /**
   * Create a backup of the user's profile.
   *
   * @param {object} [options]
   *   Options for the backup.
   * @param {string} [options.profilePath=PathUtils.profileDir]
   *   The path to the profile to backup. By default, this is the current
   *   profile.
   * @returns {Promise<CreateBackupResult|null>}
   *   A promise that resolves to information about the backup that was
   *   created, or null if the backup failed.
   */
  async createBackup({ profilePath = PathUtils.profileDir } = {}) {
    // createBackup does not allow re-entry or concurrent backups.
    if (this.#backupInProgress) {
      lazy.logConsole.warn("Backup attempt already in progress");
      return null;
    }

    return locks.request(
      BackupService.WRITE_BACKUP_LOCK_NAME,
      { signal: this.#backupWriteAbortController.signal },
      async () => {
        let currentStep = STEPS.CREATE_BACKUP_ENTRYPOINT;
        this.#backupInProgress = true;
        const backupTimer = Glean.browserBackup.totalBackupTime.start();

        try {
          lazy.logConsole.debug(
            `Creating backup for profile at ${profilePath}`
          );

          currentStep = STEPS.CREATE_BACKUP_RESOLVE_DESTINATION;
          let archiveDestFolderPath = await this.resolveArchiveDestFolderPath(
            lazy.backupDirPref
          );
          lazy.logConsole.debug(
            `Destination for archive: ${archiveDestFolderPath}`
          );

          currentStep = STEPS.CREATE_BACKUP_CREATE_MANIFEST;
          let manifest = await this.#createBackupManifest();

          currentStep = STEPS.CREATE_BACKUP_CREATE_BACKUPS_FOLDER;
          // First, check to see if a `backups` directory already exists in the
          // profile.
          let backupDirPath = PathUtils.join(
            profilePath,
            BackupService.PROFILE_FOLDER_NAME,
            BackupService.SNAPSHOTS_FOLDER_NAME
          );
          lazy.logConsole.debug("Creating backups folder");

          // ignoreExisting: true is the default, but we're being explicit that it's
          // okay if this folder already exists.
          await IOUtils.makeDirectory(backupDirPath, {
            ignoreExisting: true,
            createAncestors: true,
          });

          currentStep = STEPS.CREATE_BACKUP_CREATE_STAGING_FOLDER;
          let stagingPath = await this.#prepareStagingFolder(backupDirPath);

          // Sort resources be priority.
          let sortedResources = Array.from(this.#resources.values()).sort(
            (a, b) => {
              return b.priority - a.priority;
            }
          );

          currentStep = STEPS.CREATE_BACKUP_LOAD_ENCSTATE;
          let encState = await this.loadEncryptionState(profilePath);
          let encryptionEnabled = !!encState;
          lazy.logConsole.debug("Encryption enabled: ", encryptionEnabled);

          currentStep = STEPS.CREATE_BACKUP_RUN_BACKUP;
          // Perform the backup for each resource.
          for (let resourceClass of sortedResources) {
            try {
              lazy.logConsole.debug(
                `Backing up resource with key ${resourceClass.key}. ` +
                  `Requires encryption: ${resourceClass.requiresEncryption}`
              );

              if (resourceClass.requiresEncryption && !encryptionEnabled) {
                lazy.logConsole.debug(
                  "Encryption is not currently enabled. Skipping."
                );
                continue;
              }

              let resourcePath = PathUtils.join(stagingPath, resourceClass.key);
              await IOUtils.makeDirectory(resourcePath);

              // `backup` on each BackupResource should return us a ManifestEntry
              // that we eventually write to a JSON manifest file, but for now,
              // we're just going to log it.
              let manifestEntry = await new resourceClass().backup(
                resourcePath,
                profilePath,
                encryptionEnabled
              );

              if (manifestEntry === undefined) {
                lazy.logConsole.error(
                  `Backup of resource with key ${resourceClass.key} returned undefined
                as its ManifestEntry instead of null or an object`
                );
              } else {
                lazy.logConsole.debug(
                  `Backup of resource with key ${resourceClass.key} completed`,
                  manifestEntry
                );
                manifest.resources[resourceClass.key] = manifestEntry;
              }
            } catch (e) {
              lazy.logConsole.error(
                `Failed to backup resource: ${resourceClass.key}`,
                e
              );
            }
          }

          currentStep = STEPS.CREATE_BACKUP_VERIFY_MANIFEST;
          // Ensure that the manifest abides by the current schema, and log
          // an error if somehow it doesn't. We'll want to collect telemetry for
          // this case to make sure it's not happening in the wild. We debated
          // throwing an exception here too, but that's not meaningfully better
          // than creating a backup that's not schema-compliant. At least in this
          // case, a user so-inclined could theoretically repair the manifest
          // to make it valid.
          let manifestSchema = await BackupService.MANIFEST_SCHEMA;
          let schemaValidationResult = lazy.JsonSchema.validate(
            manifest,
            manifestSchema
          );
          if (!schemaValidationResult.valid) {
            lazy.logConsole.error(
              "Backup manifest does not conform to schema:",
              manifest,
              manifestSchema,
              schemaValidationResult
            );
            // TODO: Collect telemetry for this case. (bug 1891817)
          }

          currentStep = STEPS.CREATE_BACKUP_WRITE_MANIFEST;
          // Write the manifest to the staging folder.
          let manifestPath = PathUtils.join(
            stagingPath,
            BackupService.MANIFEST_FILE_NAME
          );
          await IOUtils.writeJSON(manifestPath, manifest);

          currentStep = STEPS.CREATE_BACKUP_FINALIZE_STAGING;
          let renamedStagingPath =
            await this.#finalizeStagingFolder(stagingPath);
          lazy.logConsole.log(
            "Wrote backup to staging directory at ",
            renamedStagingPath
          );

          // Record the total size of the backup staging directory
          let totalSizeKilobytes =
            await BackupResource.getDirectorySize(renamedStagingPath);
          let totalSizeBytesNearestMebibyte = MeasurementUtils.fuzzByteSize(
            totalSizeKilobytes * BYTES_IN_KILOBYTE,
            1 * BYTES_IN_MEBIBYTE
          );
          lazy.logConsole.debug(
            "total staging directory size in bytes: " +
              totalSizeBytesNearestMebibyte
          );

          Glean.browserBackup.totalBackupSize.accumulate(
            totalSizeBytesNearestMebibyte / BYTES_IN_MEBIBYTE
          );

          currentStep = STEPS.CREATE_BACKUP_COMPRESS_STAGING;
          let compressedStagingPath = await this.#compressStagingFolder(
            renamedStagingPath,
            backupDirPath
          ).finally(async () => {
            await IOUtils.remove(renamedStagingPath, { recursive: true });
          });

          currentStep = STEPS.CREATE_BACKUP_CREATE_ARCHIVE;
          // Now create the single-file archive. For now, we'll stash this in the
          // backups folder while it gets written. Once that's done, we'll attempt
          // to move it to the user's configured backup path.
          let archiveTmpPath = PathUtils.join(backupDirPath, "archive.html");
          lazy.logConsole.log(
            "Exporting single-file archive to ",
            archiveTmpPath
          );
          await this.createArchive(
            archiveTmpPath,
            BackupService.ARCHIVE_TEMPLATE,
            compressedStagingPath,
            this.#encState,
            manifest.meta
          ).finally(async () => {
            await IOUtils.remove(compressedStagingPath);
          });

          // Record the size of the complete single-file archive
          let archiveSizeKilobytes =
            await BackupResource.getFileSize(archiveTmpPath);
          let archiveSizeBytesNearestMebibyte = MeasurementUtils.fuzzByteSize(
            archiveSizeKilobytes * BYTES_IN_KILOBYTE,
            1 * BYTES_IN_MEBIBYTE
          );
          lazy.logConsole.debug(
            "backup archive size in bytes: " + archiveSizeBytesNearestMebibyte
          );

          Glean.browserBackup.compressedArchiveSize.accumulate(
            archiveSizeBytesNearestMebibyte / BYTES_IN_MEBIBYTE
          );

          currentStep = STEPS.CREATE_BACKUP_FINALIZE_ARCHIVE;
          let archivePath = await this.finalizeSingleFileArchive(
            archiveTmpPath,
            archiveDestFolderPath,
            manifest.meta
          );

          let nowSeconds = Math.floor(Date.now() / 1000);
          Services.prefs.setIntPref(
            LAST_BACKUP_TIMESTAMP_PREF_NAME,
            nowSeconds
          );
          this.#_state.lastBackupDate = nowSeconds;
          Glean.browserBackup.totalBackupTime.stopAndAccumulate(backupTimer);

          Glean.browserBackup.created.record();

          return { manifest, archivePath };
        } catch (e) {
          Glean.browserBackup.totalBackupTime.cancel(backupTimer);
          Glean.browserBackup.error.record({
            error_code: String(e.cause || ERRORS.UNKNOWN),
            backup_step: String(currentStep),
          });
          return null;
        } finally {
          this.#backupInProgress = false;
        }
      }
    );
  }

  /**
   * Generates a string from a Date in the form of:
   *
   * YYYYMMDD-HHMM
   *
   * @param {Date} date
   *   The date to convert into the archive date suffix.
   * @returns {string}
   */
  generateArchiveDateSuffix(date) {
    let year = date.getFullYear().toString();

    // In all cases, months or days with single digits are expected to start
    // with a 0.

    // Note that getMonth() is 0-indexed for some reason, so we increment by 1.
    let month = `${date.getMonth() + 1}`.padStart(2, "0");

    let day = `${date.getDate()}`.padStart(2, "0");
    let hours = `${date.getHours()}`.padStart(2, "0");
    let minutes = `${date.getMinutes()}`.padStart(2, "0");

    return `${year}${month}${day}-${hours}${minutes}`;
  }

  /**
   * Moves the single-file archive into its configured location with a filename
   * that is sanitized and contains a timecode. This also removes any existing
   * single-file archives in that same folder after the move completes.
   *
   * @param {string} sourcePath
   *   The file system location of the single-file archive prior to the move.
   * @param {string} destFolder
   *   The folder that the single-file archive is configured to be eventually
   *   written to.
   * @param {object} metadata
   *   The metadata for the backup. See the BackupManifest schema for details.
   * @returns {Promise<string>}
   *   Resolves with the path that the single-file archive was moved to.
   */
  async finalizeSingleFileArchive(sourcePath, destFolder, metadata) {
    let archiveDateSuffix = this.generateArchiveDateSuffix(
      new Date(metadata.date)
    );

    let existingChildren = await IOUtils.getChildren(destFolder);

    const FILENAME_PREFIX = `${BackupService.BACKUP_FILE_NAME}_${metadata.profileName}`;
    const FILENAME = `${FILENAME_PREFIX}_${archiveDateSuffix}.html`;
    let destPath = PathUtils.join(destFolder, FILENAME);
    lazy.logConsole.log("Moving single-file archive to ", destPath);
    await IOUtils.move(sourcePath, destPath);

    Services.prefs.setStringPref(LAST_BACKUP_FILE_NAME_PREF_NAME, FILENAME);
    // It is expected that our caller will call stateUpdate(), so we skip doing
    // that here. This is done via the backupInProgress setter in createBackup.
    this.#_state.lastBackupFileName = FILENAME;

    for (let childFilePath of existingChildren) {
      let childFileName = PathUtils.filename(childFilePath);
      // We check both the prefix and the suffix, because the prefix encodes
      // the profile name in it. If there are other profiles from the same
      // application performing backup, we don't want to accidentally remove
      // those.
      if (
        childFileName.startsWith(FILENAME_PREFIX) &&
        childFileName.endsWith(".html")
      ) {
        if (childFileName == FILENAME) {
          // Since filenames don't include seconds, this might occur if a
          // backup was created seconds after the last one during the same
          // minute. That tends not to happen in practice, but might occur
          // during testing, in which case, we'll skip clearing this file.
          lazy.logConsole.warn(
            "Collided with a pre-existing archive name, so not clearing: ",
            FILENAME
          );
          continue;
        }
        lazy.logConsole.debug("Getting rid of ", childFilePath);
        await IOUtils.remove(childFilePath);
      }
    }

    return destPath;
  }

  /**
   * Constructs the staging folder for the backup in the passed in backup
   * folder. If a pre-existing staging folder exists, it will be cleared out.
   *
   * @param {string} backupDirPath
   *   The path to the backup folder.
   * @returns {Promise<string>}
   *   The path to the empty staging folder.
   */
  async #prepareStagingFolder(backupDirPath) {
    let stagingPath = PathUtils.join(backupDirPath, "staging");
    lazy.logConsole.debug("Checking for pre-existing staging folder");
    if (await IOUtils.exists(stagingPath)) {
      // A pre-existing staging folder exists. A previous backup attempt must
      // have failed or been interrupted. We'll clear it out.
      lazy.logConsole.warn("A pre-existing staging folder exists. Clearing.");
      await IOUtils.remove(stagingPath, { recursive: true });
    }
    await IOUtils.makeDirectory(stagingPath);

    return stagingPath;
  }

  /**
   * Compresses a staging folder into a Zip file. If a pre-existing Zip file
   * for a staging folder resides in destFolderPath, it is overwritten. The
   * Zip file will have the same name as the stagingPath folder, with `.zip`
   * as the extension.
   *
   * @param {string} stagingPath
   *   The path to the staging folder to be compressed.
   * @param {string} destFolderPath
   *   The parent folder to write the Zip file to.
   * @returns {Promise<string>}
   *   Resolves with the path to the created Zip file.
   */
  async #compressStagingFolder(stagingPath, destFolderPath) {
    const PR_RDWR = 0x04;
    const PR_CREATE_FILE = 0x08;
    const PR_TRUNCATE = 0x20;

    let archivePath = PathUtils.join(
      destFolderPath,
      `${PathUtils.filename(stagingPath)}.zip`
    );
    let archiveFile = await IOUtils.getFile(archivePath);

    let writer = new lazy.ZipWriter(
      archiveFile,
      PR_RDWR | PR_CREATE_FILE | PR_TRUNCATE
    );

    lazy.logConsole.log("Compressing staging folder to ", archivePath);
    let rootPathNSIFile = await IOUtils.getDirectory(stagingPath);
    await this.#compressChildren(rootPathNSIFile, stagingPath, writer);
    await new Promise(resolve => {
      let observer = {
        onStartRequest(_request) {
          lazy.logConsole.debug("Starting to write out archive file");
        },
        onStopRequest(_request, status) {
          lazy.logConsole.log("Done writing archive file");
          resolve(status);
        },
      };
      writer.processQueue(observer, null);
    });
    writer.close();

    return archivePath;
  }

  /**
   * A helper function for #compressStagingFolder that iterates through a
   * directory, and adds each file to a nsIZipWriter. For each directory it
   * finds, it recurses.
   *
   * @param {nsIFile} rootPathNSIFile
   *   An nsIFile pointing at the root of the folder being compressed.
   * @param {string} parentPath
   *   The path to the folder whose children should be iterated.
   * @param {nsIZipWriter} writer
   *   The writer to add all of the children to.
   * @returns {Promise<undefined>}
   */
  async #compressChildren(rootPathNSIFile, parentPath, writer) {
    let children = await IOUtils.getChildren(parentPath);
    for (let childPath of children) {
      let childState = await IOUtils.stat(childPath);
      if (childState.type == "directory") {
        await this.#compressChildren(rootPathNSIFile, childPath, writer);
      } else {
        let childFile = await IOUtils.getFile(childPath);
        // nsIFile.getRelativePath returns paths using the "/" separator,
        // regardless of which platform we're on. That's handy, because this
        // is the same separator that nsIZipWriter expects for entries.
        let pathRelativeToRoot = childFile.getRelativePath(rootPathNSIFile);
        writer.addEntryFile(
          pathRelativeToRoot,
          BackupService.COMPRESSION_LEVEL,
          childFile,
          true
        );
      }
    }
  }

  /**
   * Decompressed a compressed recovery file into recoveryFolderDestPath.
   *
   * @param {string} recoveryFilePath
   *   The path to the compressed recovery file to decompress.
   * @param {string} recoveryFolderDestPath
   *   The path to the folder that the compressed recovery file should be
   *   decompressed within.
   * @returns {Promise<undefined>}
   */
  async decompressRecoveryFile(recoveryFilePath, recoveryFolderDestPath) {
    let recoveryFile = await IOUtils.getFile(recoveryFilePath);
    let recoveryArchive = new lazy.ZipReader(recoveryFile);
    lazy.logConsole.log(
      "Decompressing recovery folder to ",
      recoveryFolderDestPath
    );
    try {
      // null is passed to test if we're meant to CRC test the entire
      // ZIP file. If an exception is thrown, this means we failed the CRC
      // check. See the nsIZipReader.idl documentation for details.
      recoveryArchive.test(null);
    } catch (e) {
      recoveryArchive.close();
      lazy.logConsole.error("Compressed recovery file was corrupt.");
      await IOUtils.remove(recoveryFilePath, {
        retryReadonly: true,
      });
      throw new BackupError("Corrupt archive.", ERRORS.CORRUPTED_ARCHIVE);
    }

    await this.#decompressChildren(recoveryFolderDestPath, "", recoveryArchive);
    recoveryArchive.close();
  }

  /**
   * A helper method that recursively decompresses any children within a folder
   * within a compressed archive.
   *
   * @param {string} rootPath
   *   The path to the root folder that is being decompressed into.
   * @param {string} parentEntryName
   *   The name of the parent folder within the compressed archive that is
   *   having its children decompressed.
   * @param {nsIZipReader} reader
   *   The nsIZipReader for the compressed archive.
   * @returns {Promise<undefined>}
   */
  async #decompressChildren(rootPath, parentEntryName, reader) {
    // nsIZipReader.findEntries has an interesting querying language that is
    // documented in the nsIZipReader IDL file, in case you're curious about
    // what these symbols mean.
    let childEntryNames = reader.findEntries(
      parentEntryName + "?*~" + parentEntryName + "?*/?*"
    );

    for (let childEntryName of childEntryNames) {
      let childEntry = reader.getEntry(childEntryName);
      if (childEntry.isDirectory) {
        await this.#decompressChildren(rootPath, childEntryName, reader);
      } else {
        let inputStream = reader.getInputStream(childEntryName);
        // ZIP files all use `/` as their path separators, regardless of
        // platform.
        let fileNameParts = childEntryName.split("/");
        let outputFilePath = PathUtils.join(rootPath, ...fileNameParts);
        let outputFile = await IOUtils.getFile(outputFilePath);
        let outputStream = Cc[
          "@mozilla.org/network/file-output-stream;1"
        ].createInstance(Ci.nsIFileOutputStream);

        outputStream.init(
          outputFile,
          -1,
          -1,
          Ci.nsIFileOutputStream.DEFER_OPEN
        );

        await new Promise(resolve => {
          lazy.logConsole.debug("Writing ", outputFilePath);
          lazy.NetUtil.asyncCopy(inputStream, outputStream, () => {
            lazy.logConsole.debug("Done writing ", outputFilePath);
            outputStream.close();
            resolve();
          });
        });
      }
    }
  }

  /**
   * Given a URI to an HTML template for the single-file backup archive,
   * produces the static markup that will then be used as the beginning of that
   * single-file backup archive.
   *
   * @param {string} templateURI
   *   A URI pointing at a template for the HTML content for the page. This is
   *   what is visible if the file is loaded in a web browser.
   * @param {boolean} isEncrypted
   *   True if the template should indicate that the backup is encrypted.
   * @param {object} backupMetadata
   *   The metadata for the backup, which is also stored in the backup manifest
   *   of the compressed backup snapshot.
   * @returns {Promise<string>}
   */
  async renderTemplate(templateURI, isEncrypted, backupMetadata) {
    const ARCHIVE_STYLES = "chrome://browser/content/backup/archive.css";
    const ARCHIVE_SCRIPT = "chrome://browser/content/backup/archive.js";
    const LOGO = "chrome://branding/content/icon128.png";

    let templateResponse = await fetch(templateURI);
    let templateString = await templateResponse.text();
    let templateDOM = new DOMParser().parseFromString(
      templateString,
      "text/html"
    );

    // Set the lang attribute on the <html> element
    templateDOM.documentElement.setAttribute(
      "lang",
      Services.locale.appLocaleAsBCP47
    );

    let downloadLink = templateDOM.querySelector("#download-moz-browser");
    downloadLink.href = await this.resolveDownloadLink(
      AppConstants.MOZ_UPDATE_CHANNEL
    );

    let supportLinkHref =
      Services.urlFormatter.formatURLPref("app.support.baseURL") +
      "recover-from-backup";
    let supportLink = templateDOM.querySelector("#support-link");
    supportLink.href = supportLinkHref;

    // Now insert the logo as a dataURL, since we want the single-file backup
    // archive to be entirely self-contained.
    let logoResponse = await fetch(LOGO);
    let logoBlob = await logoResponse.blob();
    let logoDataURL = await new Promise((resolve, reject) => {
      let reader = new FileReader();
      reader.addEventListener("load", () => resolve(reader.result));
      reader.addEventListener("error", reject);
      reader.readAsDataURL(logoBlob);
    });

    let logoNode = templateDOM.querySelector("#logo");
    logoNode.src = logoDataURL;

    let encStateNode = templateDOM.querySelector("#encryption-state");
    lazy.gDOMLocalization.setAttributes(
      encStateNode,
      isEncrypted
        ? "backup-file-encryption-state-encrypted"
        : "backup-file-encryption-state-not-encrypted"
    );

    let lastBackedUpNode = templateDOM.querySelector("#last-backed-up");
    lazy.gDOMLocalization.setArgs(lastBackedUpNode, {
      // It's very unlikely that backupMetadata.date isn't a valid Date string,
      // but if it _is_, then Fluent will cause us to crash in debug builds.
      // We fallback to the current date if all else fails.
      date: new Date(backupMetadata.date).getTime() || new Date().getTime(),
    });

    let creationDeviceNode = templateDOM.querySelector("#creation-device");
    lazy.gDOMLocalization.setArgs(creationDeviceNode, {
      machineName: backupMetadata.machineName,
    });

    try {
      await lazy.gDOMLocalization.translateFragment(
        templateDOM.documentElement
      );
    } catch (_) {
      // This shouldn't happen, but we don't want a missing locale string to
      // cause backup creation to fail.
    }

    // We have to insert styles and scripts after we serialize to XML, otherwise
    // the XMLSerializer will escape things like descendent selectors in CSS
    // with >.
    let stylesResponse = await fetch(ARCHIVE_STYLES);
    let scriptResponse = await fetch(ARCHIVE_SCRIPT);

    // These days, we don't really support CSS preprocessor directives, so we
    // can't ifdef out the MPL license header in styles before writing it into
    // the archive file. Instead, we'll ensure that the license header is there,
    // and then manually remove it here at runtime.
    let stylesText = await stylesResponse.text();
    const MPL_LICENSE = `/**
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
 */`;
    if (!stylesText.includes(MPL_LICENSE)) {
      throw new BackupError(
        "Expected the MPL license block within archive.css",
        ERRORS.UNKNOWN
      );
    }

    stylesText = stylesText.replace(MPL_LICENSE, "");

    let serializer = new XMLSerializer();
    return serializer
      .serializeToString(templateDOM)
      .replace("{{styles}}", stylesText)
      .replace("{{script}}", await scriptResponse.text());
  }

  /**
   * Creates a portable, potentially encrypted single-file archive containing
   * a compressed backup snapshot. The single-file archive is a specially
   * crafted HTML file that embeds the compressed backup snapshot and
   * backup metadata.
   *
   * @param {string} archivePath
   *   The path to write the single-file archive to.
   * @param {string} templateURI
   *   A URI pointing at a template for the HTML content for the page. This is
   *   what is visible if the file is loaded in a web browser.
   * @param {string} compressedBackupSnapshotPath
   *   The path on the file system where the compressed backup snapshot exists.
   * @param {ArchiveEncryptionState|null} encState
   *   The ArchiveEncryptionState to encrypt the backup with, if encryption is
   *   enabled. If null is passed, the backup will not be encrypted.
   * @param {object} backupMetadata
   *   The metadata for the backup, which is also stored in the backup manifest
   *   of the compressed backup snapshot.
   * @param {object} options
   *   Options to pass to the worker, mainly for testing.
   * @param {object} [options.chunkSize=ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE]
   *   The chunk size to break the bytes into.
   */
  async createArchive(
    archivePath,
    templateURI,
    compressedBackupSnapshotPath,
    encState,
    backupMetadata,
    options = {}
  ) {
    let markup = await this.renderTemplate(
      templateURI,
      !!encState,
      backupMetadata
    );

    let worker = new lazy.BasePromiseWorker(
      "resource:///modules/backup/Archive.worker.mjs",
      { type: "module" }
    );
    worker.ExceptionHandlers[BackupError.name] = BackupError.fromMsg;

    let chunkSize =
      options.chunkSize || lazy.ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE;

    try {
      let encryptionArgs = encState
        ? {
            publicKey: encState.publicKey,
            salt: encState.salt,
            nonce: encState.nonce,
            backupAuthKey: encState.backupAuthKey,
            wrappedSecrets: encState.wrappedSecrets,
          }
        : null;

      await worker
        .post("constructArchive", [
          {
            archivePath,
            markup,
            backupMetadata,
            compressedBackupSnapshotPath,
            encryptionArgs,
            chunkSize,
          },
        ])
        .catch(e => {
          lazy.logConsole.error(e);
          if (!(e instanceof BackupError)) {
            throw new BackupError("Failed to create archive", ERRORS.UNKNOWN);
          }
          throw e;
        });
    } finally {
      worker.terminate();
    }
  }

  /**
   * Constructs an nsIChannel that serves the bytes from an nsIInputStream -
   * specifically, a nsIInputStream of bytes being streamed from a file.
   *
   * @see BackupService.#extractMetadataFromArchive()
   * @param {nsIInputStream} inputStream
   *   The nsIInputStream to create the nsIChannel for.
   * @param {string} contentType
   *   The content type for the nsIChannel. This is provided by
   *   BackupService.#extractMetadataFromArchive().
   * @returns {nsIChannel}
   */
  #createExtractionChannel(inputStream, contentType) {
    let uri = "http://localhost";
    let httpChan = lazy.NetUtil.newChannel({
      uri,
      loadUsingSystemPrincipal: true,
    });

    let channel = Cc["@mozilla.org/network/input-stream-channel;1"]
      .createInstance(Ci.nsIInputStreamChannel)
      .QueryInterface(Ci.nsIChannel);

    channel.setURI(httpChan.URI);
    channel.loadInfo = httpChan.loadInfo;

    channel.contentStream = inputStream;
    channel.contentType = contentType;
    return channel;
  }

  /**
   * A helper for BackupService.extractCompressedSnapshotFromArchive() that
   * reads in the JSON block from the MIME message embedded within an
   * archiveFile.
   *
   * @see BackupService.extractCompressedSnapshotFromArchive()
   * @param {nsIFile} archiveFile
   *   The file to read the MIME message out from.
   * @param {number} startByteOffset
   *   The start byte offset of the MIME message.
   * @param {string} contentType
   *   The Content-Type of the MIME message.
   * @returns {Promise<object>}
   */
  async #extractJSONFromArchive(archiveFile, startByteOffset, contentType) {
    let fileInputStream = Cc[
      "@mozilla.org/network/file-input-stream;1"
    ].createInstance(Ci.nsIFileInputStream);
    fileInputStream.init(
      archiveFile,
      -1,
      -1,
      Ci.nsIFileInputStream.CLOSE_ON_EOF
    );
    fileInputStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, startByteOffset);

    const EXPECTED_CONTENT_TYPE = "application/json";

    let extractionChannel = this.#createExtractionChannel(
      fileInputStream,
      contentType
    );
    let textDecoder = new TextDecoder();
    return new Promise((resolve, reject) => {
      let streamConv = Cc["@mozilla.org/streamConverters;1"].getService(
        Ci.nsIStreamConverterService
      );
      let multipartListenerForJSON = {
        /**
         * True once we've found an attachment matching our
         * EXPECTED_CONTENT_TYPE. Once this is true, bytes flowing into
         * onDataAvailable will be enqueued through the controller.
         *
         * @type {boolean}
         */
        _enabled: false,

        /**
         * True once onStopRequest has been called once the listener is enabled.
         * After this, the listener will not attempt to read any data passed
         * to it through onDataAvailable.
         *
         * @type {boolean}
         */
        _done: false,

        /**
         * A buffer with which we will cobble together the JSON string that
         * will get parsed once the attachment finishes being read in.
         *
         * @type {string}
         */
        _buffer: "",

        QueryInterface: ChromeUtils.generateQI([
          "nsIStreamListener",
          "nsIRequestObserver",
          "nsIMultiPartChannelListener",
        ]),

        /**
         * Called when we begin to load an attachment from the MIME message.
         *
         * @param {nsIRequest} request
         *   The request corresponding to the source of the data.
         */
        onStartRequest(request) {
          if (!(request instanceof Ci.nsIChannel)) {
            throw Components.Exception(
              "onStartRequest expected an nsIChannel request",
              Cr.NS_ERROR_UNEXPECTED
            );
          }
          this._enabled = request.contentType == EXPECTED_CONTENT_TYPE;
        },

        /**
         * Called when data is flowing in for an attachment.
         *
         * @param {nsIRequest} request
         *   The request corresponding to the source of the data.
--> --------------------

--> maximum size reached

--> --------------------

[ Normaldarstellung0.118Diashow  ]