Commit add5221d authored by Mike Conley's avatar Mike Conley
Browse files

Bug 1892249 - Use nsIZipWriter to compress the staging folder. r=backup-reviewers,fchasen

This is an intermediary stage before the compressed archive gets (optionally)
encrypted and written into the container file. This is why there's not a whole
lot of testing for the compressed file - those tests will get added once it
completes its journey into the container file so that we can test both
extraction and decompression at the same time.

Differential Revision: https://phabricator.services.mozilla.com/D210311
parent f5e5b985
Loading
Loading
Loading
Loading
+104 −1
Original line number Diff line number Diff line
@@ -29,6 +29,10 @@ ChromeUtils.defineESModuleGetters(lazy, {
  UIState: "resource://services-sync/UIState.sys.mjs",
});

ChromeUtils.defineLazyGetter(lazy, "ZipWriter", () =>
  Components.Constructor("@mozilla.org/zipwriter;1", "nsIZipWriter", "open")
);

/**
 * The BackupService class orchestrates the scheduling and creation of profile
 * backups. It also does most of the heavy lifting for the restoration of a
@@ -184,6 +188,15 @@ export class BackupService extends EventTarget {
    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 a reference to a BackupService singleton. If this is the first time
   * that this getter is accessed, this causes the BackupService singleton to be
@@ -382,7 +395,13 @@ export class BackupService extends EventTarget {
        "Wrote backup to staging directory at ",
        renamedStagingPath
      );
      return { stagingPath: renamedStagingPath };

      let compressedStagingPath = await this.#compressStagingFolder(
        renamedStagingPath,
        backupDirPath
      );

      return { stagingPath: renamedStagingPath, compressedStagingPath };
    } finally {
      this.#backupInProgress = false;
    }
@@ -411,6 +430,90 @@ export class BackupService extends EventTarget {
    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
        );
      }
    }
  }

  /**
   * Renames the staging folder to an ISO 8601 date string with dashes replacing colons and fractional seconds stripped off.
   * The ISO date string should be formatted from YYYY-MM-DDTHH:mm:ss.sssZ to YYYY-MM-DDTHH-mm-ssZ
+22 −6
Original line number Diff line number Diff line
@@ -114,21 +114,37 @@ async function testCreateBackupHelper(sandbox, taskFn) {
  let backupsFolderPath = PathUtils.join(fakeProfilePath, "backups");
  let stagingPath = PathUtils.join(backupsFolderPath, "staging");

  // For now, we expect a single backup only to be saved.
  let backups = await IOUtils.getChildren(backupsFolderPath);
  // For now, we expect a single backup only to be saved. There should also be
  // a single compressed file for the staging folder.
  let backupsChildren = await IOUtils.getChildren(backupsFolderPath);
  Assert.equal(
    backups.length,
    1,
    "There should only be 1 backup in the backups folder"
    backupsChildren.length,
    2,
    "There should only be 2 items in the backups folder"
  );

  let renamedFilename = await PathUtils.filename(backups[0]);
  // The folder and the compressed file should have the same filename, but
  // the compressed file should have a `.zip` file extension. We sort the
  // list of directory children to make sure that the folder is first in
  // the array.
  backupsChildren.sort();

  let renamedFilename = await PathUtils.filename(backupsChildren[0]);
  let expectedFormatRegex = /^\d{4}(-\d{2}){2}T(\d{2}-){2}\d{2}Z$/;
  Assert.ok(
    renamedFilename.match(expectedFormatRegex),
    "Renamed staging folder should have format YYYY-MM-DDTHH-mm-ssZ"
  );

  // We also expect a zipped version of that same folder to exist in the
  // directory, with the same name along with a .zip extension.
  let archiveFilename = await PathUtils.filename(backupsChildren[1]);
  Assert.equal(
    archiveFilename,
    `${renamedFilename}.zip`,
    "Compressed staging folder exists."
  );

  let stagingPathRenamed = PathUtils.join(backupsFolderPath, renamedFilename);

  for (let backupResourceClass of [