Anforderungen  |   Konzepte  |   Entwurf  |   Entwicklung  |   Qualitätssicherung  |   Lebenszyklus  |   Steuerung
 
 
 
 


Quelle  BackupService.sys.mjs   Sprache: unbekannt

 
/* 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

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

[ Verzeichnis aufwärts0.25unsichere Verbindung  Übersetzung europäischer Sprachen durch Browser  ]

                                                                                                                                                                                                                                                                                                                                                                                                     


Neuigkeiten

     Aktuelles
     Motto des Tages

Software

     Produkte
     Quellcodebibliothek

Aktivitäten

     Artikel über Sicherheit
     Anleitung zur Aktivierung von SSL

Muße

     Gedichte
     Musik
     Bilder

Jenseits des Üblichen ....

Besucherstatistik

Besucherstatistik

Monitoring

Montastic status badge