/* Any copyright is dedicated to the Public Domain.
https://creativecommons.org/publicdomain/zero/1.0/ */

"use strict";

const { ArchiveEncryptionState } = ChromeUtils.importESModule(
  "resource:///modules/backup/ArchiveEncryptionState.sys.mjs"
);

const { ERRORS } = ChromeUtils.importESModule(
  "chrome://browser/content/backup/backup-constants.mjs"
);

const TEST_PASSWORD = "This is some test password.";
const TEST_PASSWORD_ALT = "mozilla.org";

/**
 * Creates a backup with this backup service and checks whether it can be
 * decrypted with the given password. This ensures that encryption is
 * enabled/disabled as needed, and that the backup service will actually use
 * the given password if applicable.
 *
 * @param {BackupService} bs
 *   The BackupService to use.
 * @param {string} profilePath
 *   The path to the current profile.
 * @param {string?} password
 *   The expected password, or null if it should not be encrypted.
 * @returns {boolean}
 *   True if the password matched, false if something went wrong.
 */
async function backupServiceUsesPassword(bs, profilePath, password = null) {
  try {
    const backup = await bs.createBackup({ profilePath });
    let dest = await IOUtils.createUniqueFile(
      PathUtils.parent(backup.archivePath),
      "extracted-" + PathUtils.filename(backup.archivePath)
    );

    let { isEncrypted } = await bs.sampleArchive(backup.archivePath);
    let shouldBeEncrypted = password != null;
    Assert.equal(
      isEncrypted,
      shouldBeEncrypted,
      `Archive is ${shouldBeEncrypted ? "" : "not "}encrypted`
    );

    // This should throw if the password is incorrect.
    await bs.extractCompressedSnapshotFromArchive(
      backup.archivePath,
      dest,
      password
    );

    await IOUtils.remove(backup.archivePath);
    await IOUtils.remove(dest);
    return true;
  } catch (e) {
    console.error(e);
    return false;
  }
}

add_setup(async () => {
  // Much of this setup is copied from toolkit/profile/xpcshell/head.js. It is
  // needed in order to put the xpcshell test environment into the state where
  // it thinks its profile is the one pointed at by
  // nsIToolkitProfileService.currentProfile.
  let gProfD = do_get_profile();
  let gDataHome = gProfD.clone();
  gDataHome.append("data");
  gDataHome.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
  let gDataHomeLocal = gProfD.clone();
  gDataHomeLocal.append("local");
  gDataHomeLocal.createUnique(Ci.nsIFile.DIRECTORY_TYPE, 0o755);

  let xreDirProvider = Cc["@mozilla.org/xre/directory-provider;1"].getService(
    Ci.nsIXREDirProvider
  );
  xreDirProvider.setUserDataDirectory(gDataHome, false);
  xreDirProvider.setUserDataDirectory(gDataHomeLocal, true);

  let profileSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService(
    Ci.nsIToolkitProfileService
  );

  let createdProfile = {};
  let didCreate = profileSvc.selectStartupProfile(
    ["xpcshell"],
    false,
    AppConstants.UPDATE_CHANNEL,
    "",
    {},
    {},
    createdProfile
  );
  Assert.ok(didCreate, "Created a testing profile and set it to current.");
  Assert.equal(
    profileSvc.currentProfile,
    createdProfile.value,
    "Profile set to current"
  );
});

/**
 * Tests that if encryption is disabled that only BackupResource's with
 * requiresEncryption set to `false` will have `backup()` called on them.
 */
add_task(async function test_disabled_encryption() {
  let sandbox = sinon.createSandbox();

  let bs = new BackupService({
    FakeBackupResource1,
    FakeBackupResource2,
    FakeBackupResource3,
  });

  Assert.ok(
    !bs.state.encryptionEnabled,
    "State should indicate that encryption is disabled."
  );

  let testProfilePath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "testDisabledEncryption"
  );
  let encState = await bs.loadEncryptionState(testProfilePath);
  Assert.ok(!encState, "Should not find an ArchiveEncryptionState.");

  // Override FakeBackupResource2 so that it requires encryption.
  sandbox.stub(FakeBackupResource2, "requiresEncryption").get(() => {
    return true;
  });
  Assert.ok(
    FakeBackupResource2.requiresEncryption,
    "FakeBackupResource2 requires encryption."
  );

  // This is how these FakeBackupResource's are defined in head.js
  Assert.ok(
    !FakeBackupResource1.requiresEncryption,
    "FakeBackupResource1 does not require encryption."
  );
  Assert.ok(
    !FakeBackupResource3.requiresEncryption,
    "FakeBackupResource3 does not require encryption."
  );

  let resourceWithoutEncryptionStubs = [
    sandbox.stub(FakeBackupResource1.prototype, "backup").resolves(null),
    sandbox.stub(FakeBackupResource3.prototype, "backup").resolves(null),
  ];

  let resourceWithEncryptionStub = sandbox
    .stub(FakeBackupResource2.prototype, "backup")
    .resolves(null);

  await bs.createBackup({ profilePath: testProfilePath });
  Assert.ok(
    resourceWithEncryptionStub.notCalled,
    "FakeBackupResource2.backup should not have been called"
  );

  for (let resourceWithoutEncryptionStub of resourceWithoutEncryptionStubs) {
    Assert.ok(
      resourceWithoutEncryptionStub.calledOnce,
      "backup called on resource that didn't require encryption"
    );
  }

  await IOUtils.remove(testProfilePath, { recursive: true });
  sandbox.restore();
});

/**
 * Tests that if encryption is enabled from a non-enabled state, that an
 * ArchiveEncryptionState is created, and state is written to the profile
 * directory. Also tests that this allows BackupResource's with
 * requiresEncryption set to `true` to have `backup()` called on them.
 */
add_task(async function test_enable_encryption() {
  let sandbox = sinon.createSandbox();

  let bs = new BackupService({
    FakeBackupResource1,
    FakeBackupResource2,
    FakeBackupResource3,
  });

  Assert.ok(
    !bs.state.encryptionEnabled,
    "State should initially indicate that encryption is disabled."
  );

  let testProfilePath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "testEnableEncryption"
  );
  let encState = await bs.loadEncryptionState(testProfilePath);
  Assert.ok(!encState, "Should not find an ArchiveEncryptionState.");

  // Now enable encryption.
  let stateUpdatePromise = new Promise(resolve => {
    bs.addEventListener("BackupService:StateUpdate", resolve, { once: true });
  });
  await bs.enableEncryption(TEST_PASSWORD, testProfilePath);
  await stateUpdatePromise;
  Assert.ok(
    bs.state.encryptionEnabled,
    "State should indicate that encryption is enabled."
  );

  Assert.ok(
    await IOUtils.exists(
      PathUtils.join(
        testProfilePath,
        BackupService.PROFILE_FOLDER_NAME,
        BackupService.ARCHIVE_ENCRYPTION_STATE_FILE
      )
    ),
    "Encryption state file should exist."
  );

  // Override FakeBackupResource2 so that it requires encryption.
  sandbox.stub(FakeBackupResource2, "requiresEncryption").get(() => {
    return true;
  });
  Assert.ok(
    FakeBackupResource2.requiresEncryption,
    "FakeBackupResource2 requires encryption."
  );

  // This is how these FakeBackupResource's are defined in head.js
  Assert.ok(
    !FakeBackupResource1.requiresEncryption,
    "FakeBackupResource1 does not require encryption."
  );
  Assert.ok(
    !FakeBackupResource3.requiresEncryption,
    "FakeBackupResource3 does not require encryption."
  );

  let allResourceBackupStubs = [
    sandbox.stub(FakeBackupResource1.prototype, "backup").resolves(null),
    sandbox.stub(FakeBackupResource3.prototype, "backup").resolves(null),
    sandbox.stub(FakeBackupResource2.prototype, "backup").resolves(null),
  ];

  await bs.createBackup({
    profilePath: testProfilePath,
  });

  for (let resourceBackupStub of allResourceBackupStubs) {
    Assert.ok(resourceBackupStub.calledOnce, "backup called on resource");
  }

  Assert.ok(
    await IOUtils.exists(
      PathUtils.join(
        testProfilePath,
        BackupService.PROFILE_FOLDER_NAME,
        BackupService.ARCHIVE_ENCRYPTION_STATE_FILE
      )
    ),
    "Encryption state file should still exist."
  );

  await IOUtils.remove(testProfilePath, { recursive: true });
  sandbox.restore();
});

/**
 * Tests that enabling encryption while it is already enabled changes the
 * password.
 */
add_task(async function test_change_encryption_password() {
  let bs = new BackupService();

  let testProfilePath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "testAlreadyEnabledEncryption"
  );

  // Enable encryption.
  await bs.enableEncryption(TEST_PASSWORD, testProfilePath);

  let encState = await bs.loadEncryptionState(testProfilePath);
  Assert.ok(encState, "ArchiveEncryptionState is available.");
  Assert.ok(
    await backupServiceUsesPassword(bs, testProfilePath, TEST_PASSWORD),
    "BackupService is using TEST_PASSWORD"
  );

  // Change the password.
  await bs.enableEncryption(TEST_PASSWORD_ALT, testProfilePath);
  encState = await bs.loadEncryptionState(testProfilePath);
  Assert.ok(encState, "ArchiveEncryptionState is still available.");
  Assert.ok(
    await backupServiceUsesPassword(bs, testProfilePath, TEST_PASSWORD_ALT),
    "BackupService is using TEST_PASSWORD_ALT"
  );

  await IOUtils.remove(testProfilePath, { recursive: true });
});

/**
 * Tests that if encryption is enabled that it can be disabled.
 */
add_task(async function test_disabling_encryption() {
  let bs = new BackupService();

  let testProfilePath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "testDisableEncryption"
  );

  // Enable encryption.
  await bs.enableEncryption(TEST_PASSWORD, testProfilePath);

  let encState = await bs.loadEncryptionState(testProfilePath);
  Assert.ok(encState, "ArchiveEncryptionState is available.");
  Assert.ok(
    bs.state.encryptionEnabled,
    "State should indicate that encryption is enabled."
  );

  Assert.ok(
    await IOUtils.exists(
      PathUtils.join(
        testProfilePath,
        BackupService.PROFILE_FOLDER_NAME,
        BackupService.ARCHIVE_ENCRYPTION_STATE_FILE
      )
    ),
    "Encryption state file should exist."
  );

  // Now disable encryption.
  let stateUpdatePromise = new Promise(resolve => {
    bs.addEventListener("BackupService:StateUpdate", resolve, { once: true });
  });
  await bs.disableEncryption(testProfilePath);
  await stateUpdatePromise;
  Assert.ok(
    !bs.state.encryptionEnabled,
    "State should indicate that encryption is now disabled."
  );

  Assert.ok(
    !(await IOUtils.exists(
      PathUtils.join(
        testProfilePath,
        BackupService.PROFILE_FOLDER_NAME,
        BackupService.ARCHIVE_ENCRYPTION_STATE_FILE
      )
    )),
    "Encryption state file should have been removed."
  );
  Assert.ok(
    await backupServiceUsesPassword(bs, testProfilePath, null),
    "BackupService should not use encryption."
  );

  await IOUtils.remove(testProfilePath, { recursive: true });
});

/**
 * Tests that if encryption is enabled, that we can call
 * BackupService.createBackup using an alternative ArchiveEncryptionState if
 * we'd like.
 */
add_task(async function test_alternative_archive_encryption_state() {
  let sandbox = sinon.createSandbox();

  let bs = new BackupService({
    FakeBackupResource1,
    FakeBackupResource2,
    FakeBackupResource3,
  });

  Assert.ok(
    !bs.state.encryptionEnabled,
    "State should initially indicate that encryption is disabled."
  );

  let testProfilePath = await IOUtils.createUniqueDirectory(
    PathUtils.tempDir,
    "testAlternativeArchiveEncryptionState"
  );
  let encState = await bs.loadEncryptionState(testProfilePath);
  Assert.ok(!encState, "Should not find an ArchiveEncryptionState.");

  // Now enable encryption.
  let stateUpdatePromise = new Promise(resolve => {
    bs.addEventListener("BackupService:StateUpdate", resolve, { once: true });
  });
  await bs.enableEncryption(TEST_PASSWORD, testProfilePath);
  await stateUpdatePromise;
  Assert.ok(
    bs.state.encryptionEnabled,
    "State should indicate that encryption is enabled."
  );

  Assert.ok(
    await IOUtils.exists(
      PathUtils.join(
        testProfilePath,
        BackupService.PROFILE_FOLDER_NAME,
        BackupService.ARCHIVE_ENCRYPTION_STATE_FILE
      )
    ),
    "Encryption state file should exist."
  );

  // Override FakeBackupResource2 so that it requires encryption.
  sandbox.stub(FakeBackupResource2, "requiresEncryption").get(() => {
    return true;
  });
  Assert.ok(
    FakeBackupResource2.requiresEncryption,
    "FakeBackupResource2 requires encryption."
  );

  // This is how these FakeBackupResource's are defined in head.js
  Assert.ok(
    !FakeBackupResource1.requiresEncryption,
    "FakeBackupResource1 does not require encryption."
  );
  Assert.ok(
    !FakeBackupResource3.requiresEncryption,
    "FakeBackupResource3 does not require encryption."
  );

  let allResourceBackupStubs = [
    sandbox.stub(FakeBackupResource1.prototype, "backup").resolves(null),
    sandbox.stub(FakeBackupResource3.prototype, "backup").resolves(null),
    sandbox.stub(FakeBackupResource2.prototype, "backup").resolves(null),
  ];

  let { instance: alternativeEncState } =
    await ArchiveEncryptionState.initialize(TEST_PASSWORD_ALT);

  let { archivePath } = await bs.createBackup({
    profilePath: testProfilePath,
    encState: alternativeEncState,
  });

  for (let resourceBackupStub of allResourceBackupStubs) {
    Assert.ok(resourceBackupStub.calledOnce, "backup called on resource");
  }

  Assert.ok(
    await IOUtils.exists(
      PathUtils.join(
        testProfilePath,
        BackupService.PROFILE_FOLDER_NAME,
        BackupService.ARCHIVE_ENCRYPTION_STATE_FILE
      )
    ),
    "Encryption state file should still exist."
  );

  Assert.ok(
    await IOUtils.exists(archivePath),
    "Encrypted archive was created."
  );

  const EXTRACTION_PATH = PathUtils.join(testProfilePath, "extraction.bin");

  // Trying to decrypt with TEST_PASSWORD, which is the password we set up with
  // the service, should actually fail, because the ArchiveEncryptionState we
  // passed to createBackup should only decrypt with TEST_PASSWORD_ALT.
  try {
    await bs.extractCompressedSnapshotFromArchive(
      archivePath,
      EXTRACTION_PATH,
      TEST_PASSWORD
    );
    Assert.ok(
      false,
      "Should have failed to decrypt backup with TEST_PASSWORD."
    );
  } catch (e) {
    Assert.equal(
      e.cause,
      ERRORS.UNAUTHORIZED,
      "TEST_PASSWORD failed to decrypt the backup."
    );
  }

  // Using TEST_PASSWORD_ALT should work to decrypt the backup.
  await bs.extractCompressedSnapshotFromArchive(
    archivePath,
    EXTRACTION_PATH,
    TEST_PASSWORD_ALT
  );

  // Do a quick check to make sure that the archive was extracted.
  Assert.ok(
    await IOUtils.exists(PathUtils.join(EXTRACTION_PATH)),
    "Found the extracted archive."
  );

  await maybeRemovePath(testProfilePath);
  sandbox.restore();
});
