Source code

Revision control

Copy as Markdown

Other Tools

Test Info: Warnings

/* Any copyright is dedicated to the Public Domain.
"use strict";
const { ArchiveEncryptionState } = ChromeUtils.importESModule(
"resource:///modules/backup/ArchiveEncryptionState.sys.mjs"
);
const { ArchiveUtils } = ChromeUtils.importESModule(
"resource:///modules/backup/ArchiveUtils.sys.mjs"
);
const { ArchiveDecryptor } = ChromeUtils.importESModule(
"resource:///modules/backup/ArchiveEncryption.sys.mjs"
);
const { DecoderDecryptorTransformer, FileWriterStream } =
ChromeUtils.importESModule(
"resource:///modules/backup/BackupService.sys.mjs"
);
let testProfilePath;
let fakeCompressedStagingPath;
let archiveTemplateFile = do_get_file("data/test_archive.template.html");
let archiveTemplateURI = Services.io.newFileURI(archiveTemplateFile).spec;
const SIZE_IN_BYTES = 125123;
let fakeBytes;
async function assertExtractionsMatch(extractionPath) {
let writtenBytes = await IOUtils.read(extractionPath);
assertUint8ArraysSimilarity(
writtenBytes,
fakeBytes,
true /* expectSimilar */
);
}
add_setup(async () => {
testProfilePath = await IOUtils.createUniqueDirectory(
PathUtils.tempDir,
"testCreateArchive"
);
fakeCompressedStagingPath = PathUtils.join(
testProfilePath,
"fake-compressed-staging.zip"
);
// Let's create a large chunk of nonsense data that we can pretend is the
// compressed archive just to make sure that we can get it back out again.
// Instead of putting a large file inside of version control, we
// deterministically generate some nonsense data inside of a Uint8Array to
// encode. Generating the odd positive integer sequence seems like a decent
// enough mechanism for deterministically generating nonsense data. We ensure
// that the number of bytes written is not a multiple of 6 so that we can
// ensure that base64 padding is working.
fakeBytes = new Uint8Array(SIZE_IN_BYTES);
// seededRandomNumberGenerator is defined in head.js, but eslint doesn't seem
// happy about it. Maybe that's because it's a generator function.
// eslint-disable-next-line no-undef
let gen = seededRandomNumberGenerator();
for (let i = 0; i < SIZE_IN_BYTES; ++i) {
fakeBytes.set(gen.next().value, i);
}
await IOUtils.write(fakeCompressedStagingPath, fakeBytes);
OSKeyStoreTestUtils.setup();
registerCleanupFunction(async () => {
await OSKeyStoreTestUtils.cleanup();
await IOUtils.remove(testProfilePath, { recursive: true });
});
});
/**
* Tests that a single-file archive can be created from some file on the
* file system and not be encrypted. This is a bit more integration-y, since
* it's also testing the Archive.worker.mjs script - but that script is
* basically an extension of createArchive that lets it operate off of the
* main thread.
*/
add_task(async function test_createArchive_unencrypted() {
let bs = new BackupService();
const FAKE_ARCHIVE_PATH = PathUtils.join(
testProfilePath,
"fake-unencrypted-archive.html"
);
await bs.createArchive(
FAKE_ARCHIVE_PATH,
archiveTemplateURI,
fakeCompressedStagingPath,
null /* no ArchiveEncryptionState */,
FAKE_METADATA
);
let { isEncrypted, archiveJSON } = await bs.sampleArchive(FAKE_ARCHIVE_PATH);
Assert.ok(!isEncrypted, "Should not be considered encrypted.");
Assert.deepEqual(
archiveJSON.meta,
FAKE_METADATA,
"Metadata was encoded in the archive JSON block."
);
const EXTRACTION_PATH = PathUtils.join(testProfilePath, "extraction.bin");
await bs.extractCompressedSnapshotFromArchive(
FAKE_ARCHIVE_PATH,
EXTRACTION_PATH
);
assertExtractionsMatch(EXTRACTION_PATH);
await IOUtils.remove(FAKE_ARCHIVE_PATH);
await IOUtils.remove(EXTRACTION_PATH);
});
/**
* Tests that a single-file archive can be created from some file on the
* file system and be encrypted and decrypted. This is a bit more integration-y,
* since it's also testing the Archive.worker.mjs script - but that script is
* basically an extension of createArchive that lets it operate off of the
* main thread.
*/
add_task(async function test_createArchive_encrypted() {
const TEST_RECOVERY_CODE = "This is some recovery code.";
let bs = new BackupService();
let { instance: encState } =
await ArchiveEncryptionState.initialize(TEST_RECOVERY_CODE);
const FAKE_ARCHIVE_PATH = PathUtils.join(
testProfilePath,
"fake-encrypted-archive.html"
);
await bs.createArchive(
FAKE_ARCHIVE_PATH,
archiveTemplateURI,
fakeCompressedStagingPath,
encState,
FAKE_METADATA
);
let { isEncrypted, archiveJSON } = await bs.sampleArchive(FAKE_ARCHIVE_PATH);
Assert.ok(isEncrypted, "Should be considered encrypted.");
Assert.deepEqual(
archiveJSON.meta,
FAKE_METADATA,
"Metadata was encoded in the archive JSON block."
);
const EXTRACTION_PATH = PathUtils.join(testProfilePath, "extraction.bin");
// This should fail, since the archive is encrypted.
await Assert.rejects(
bs.extractCompressedSnapshotFromArchive(FAKE_ARCHIVE_PATH, EXTRACTION_PATH),
/recovery code is required/
);
await bs.extractCompressedSnapshotFromArchive(
FAKE_ARCHIVE_PATH,
EXTRACTION_PATH,
TEST_RECOVERY_CODE
);
assertExtractionsMatch(EXTRACTION_PATH);
await IOUtils.remove(FAKE_ARCHIVE_PATH);
await IOUtils.remove(EXTRACTION_PATH);
});
/**
* Tests that an archive can be created where the bytes of the archive are
* a multiple of 6, but the individual chunks of those bytes are not a multiple
* of 6 (which will necessitate base64 padding).
*/
add_task(async function test_createArchive_multiple_of_six_test() {
let bs = new BackupService();
const FAKE_ARCHIVE_PATH = PathUtils.join(
testProfilePath,
"fake-unencrypted-archive.html"
);
const FAKE_COMPRESSED_FILE = PathUtils.join(
testProfilePath,
"fake-compressed-staging-mul6.zip"
);
// Instead of generating a gigantic chunk of data to test this particular
// case, we'll override the default chunk size. We'll choose a chunk size of
// 500 bytes, which doesn't divide evenly by 6 - but we'll encode a set of
// 6 * 500 bytes, which will naturally divide evenly by 6.
const NOT_MULTIPLE_OF_SIX_OVERRIDE_CHUNK_SIZE = 500;
const MULTIPLE_OF_SIX_SIZE_IN_BYTES = 6 * 500;
let multipleOfSixBytes = new Uint8Array(MULTIPLE_OF_SIX_SIZE_IN_BYTES);
// seededRandomNumberGenerator is defined in head.js, but eslint doesn't seem
// happy about it. Maybe that's because it's a generator function.
// eslint-disable-next-line no-undef
let gen = seededRandomNumberGenerator();
for (let i = 0; i < MULTIPLE_OF_SIX_SIZE_IN_BYTES; ++i) {
multipleOfSixBytes.set(gen.next().value, i);
}
await IOUtils.write(FAKE_COMPRESSED_FILE, multipleOfSixBytes);
await bs.createArchive(
FAKE_ARCHIVE_PATH,
archiveTemplateURI,
FAKE_COMPRESSED_FILE,
null /* no ArchiveEncryptionState */,
FAKE_METADATA,
{
chunkSize: NOT_MULTIPLE_OF_SIX_OVERRIDE_CHUNK_SIZE,
}
);
const EXTRACTION_PATH = PathUtils.join(testProfilePath, "extraction.bin");
await bs.extractCompressedSnapshotFromArchive(
FAKE_ARCHIVE_PATH,
EXTRACTION_PATH
);
let writtenBytes = await IOUtils.read(EXTRACTION_PATH);
assertUint8ArraysSimilarity(
writtenBytes,
multipleOfSixBytes,
true /* expectSimilar */
);
await maybeRemovePath(FAKE_COMPRESSED_FILE);
await maybeRemovePath(FAKE_ARCHIVE_PATH);
await maybeRemovePath(EXTRACTION_PATH);
});
/**
* Tests that if an encrypted single-file archive has had its binary blob
* truncated that the decryption fails and the recovery.zip file is
* automatically destroyed.
*/
add_task(async function test_createArchive_encrypted_truncated() {
const TEST_RECOVERY_CODE = "This is some recovery code.";
let bs = new BackupService();
let { instance: encState } =
await ArchiveEncryptionState.initialize(TEST_RECOVERY_CODE);
const FAKE_ARCHIVE_PATH = PathUtils.join(
testProfilePath,
"fake-encrypted-archive.html"
);
const FAKE_COMPRESSED_FILE = PathUtils.join(
testProfilePath,
"fake-compressed-staging-large.zip"
);
const MULTIPLE_OF_MAX_CHUNK_SIZE =
2 * ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE;
let multipleOfMaxChunkSizeBytes = new Uint8Array(MULTIPLE_OF_MAX_CHUNK_SIZE);
// seededRandomNumberGenerator is defined in head.js, but eslint doesn't seem
// happy about it. Maybe that's because it's a generator function.
// eslint-disable-next-line no-undef
let gen = seededRandomNumberGenerator();
for (let i = 0; i < MULTIPLE_OF_MAX_CHUNK_SIZE; ++i) {
multipleOfMaxChunkSizeBytes.set(gen.next().value, i);
}
await IOUtils.write(FAKE_COMPRESSED_FILE, multipleOfMaxChunkSizeBytes);
await bs.createArchive(
FAKE_ARCHIVE_PATH,
archiveTemplateURI,
FAKE_COMPRESSED_FILE,
encState,
FAKE_METADATA
);
// This is a little bit gross - we're going to read out the data from the
// generated file, find the last line longer than ARCHIVE_CHUNK_MAX_BYTES_SIZE
// (which should be the last base64 encoded value), and then splice it out,
// before flushing that change back to disk.
let lines = (await IOUtils.readUTF8(FAKE_ARCHIVE_PATH)).split("\n");
let foundIndex = -1;
// The longest lines will be the base64 encoded chunks. Remove the last one.
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].length > ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE) {
foundIndex = i;
break;
}
}
Assert.notEqual(foundIndex, -1, "Should have found a long line");
lines.splice(foundIndex, 1);
await IOUtils.writeUTF8(FAKE_ARCHIVE_PATH, lines.join("\n"));
let { isEncrypted } = await bs.sampleArchive(FAKE_ARCHIVE_PATH);
Assert.ok(isEncrypted, "Should be considered encrypted.");
const EXTRACTION_PATH = PathUtils.join(testProfilePath, "extraction.bin");
await Assert.rejects(
bs.extractCompressedSnapshotFromArchive(
FAKE_ARCHIVE_PATH,
EXTRACTION_PATH,
TEST_RECOVERY_CODE
),
/Corrupted archive/
);
Assert.ok(
!(await IOUtils.exists(EXTRACTION_PATH)),
"Extraction should have been automatically destroyed."
);
await IOUtils.remove(FAKE_ARCHIVE_PATH);
await IOUtils.remove(FAKE_COMPRESSED_FILE);
});
/**
* Tests that if the BinaryReadableStream closes early before the last chunk
* is decrypted, that the recovery file is destroyed.
*/
add_task(async function test_createArchive_early_binary_stream_close() {
const TEST_RECOVERY_CODE = "This is some recovery code.";
let bs = new BackupService();
let { instance: encState } =
await ArchiveEncryptionState.initialize(TEST_RECOVERY_CODE);
const FAKE_ARCHIVE_PATH = PathUtils.join(
testProfilePath,
"fake-encrypted-archive.html"
);
await bs.createArchive(
FAKE_ARCHIVE_PATH,
archiveTemplateURI,
fakeCompressedStagingPath,
encState,
FAKE_METADATA
);
let { isEncrypted, startByteOffset, contentType, archiveJSON } =
await bs.sampleArchive(FAKE_ARCHIVE_PATH);
Assert.ok(isEncrypted, "Should be considered encrypted.");
let archiveFile = await IOUtils.getFile(FAKE_ARCHIVE_PATH);
let archiveStream = await bs.createBinaryReadableStream(
archiveFile,
startByteOffset,
contentType
);
let decryptor = await ArchiveDecryptor.initialize(
TEST_RECOVERY_CODE,
archiveJSON
);
const EXTRACTION_PATH = PathUtils.join(testProfilePath, "extraction.bin");
let binaryDecoder = new TransformStream(
new DecoderDecryptorTransformer(decryptor)
);
let fileWriter = new WritableStream(
new FileWriterStream(EXTRACTION_PATH, decryptor)
);
// We're going to run the characters from the archiveStream through an
// intermediary TransformStream that is going to cause an abort before the
// the stream can complete. We'll do that by only passing part of the first
// chunk through, and then aborting.
let earlyAborter = new TransformStream({
async transform(chunkPart, controller) {
controller.enqueue(
chunkPart.substring(0, Math.floor(chunkPart.length / 2))
);
controller.error("We're done. Aborting early.");
},
});
let pipePromise = archiveStream
.pipeThrough(earlyAborter)
.pipeThrough(binaryDecoder)
.pipeTo(fileWriter);
await Assert.rejects(pipePromise, /Aborting early/);
Assert.ok(
!(await IOUtils.exists(EXTRACTION_PATH)),
"Extraction should have been automatically destroyed."
);
await IOUtils.remove(FAKE_ARCHIVE_PATH);
});
/**
* Tests that if the nsIZipReader fails the CRC check, that the ZIP recovery
* file is destroyed and an exception is thrown.
*/
add_task(async function test_createArchive_corrupt_zip() {
let bs = new BackupService();
let corruptZipFile = do_get_file("data/corrupt.zip");
let fakeRecoveryFilePath = await IOUtils.createUniqueDirectory(
PathUtils.tempDir,
"testCreateArchiveCorruptZipSource"
);
const CORRUPT_ZIP_SOURCE = PathUtils.join(
fakeRecoveryFilePath,
"corrupt.zip"
);
await IOUtils.copy(corruptZipFile.path, CORRUPT_ZIP_SOURCE);
let fakeRecoveryPath = await IOUtils.createUniqueDirectory(
PathUtils.tempDir,
"testCreateArchiveCorruptZipDest"
);
await Assert.rejects(
bs.decompressRecoveryFile(CORRUPT_ZIP_SOURCE, fakeRecoveryPath),
/Corrupt/
);
let children = await IOUtils.getChildren(fakeRecoveryPath);
Assert.equal(children.length, 0, "Nothing was decompressed.");
Assert.ok(
!(await IOUtils.exists(CORRUPT_ZIP_SOURCE)),
"Corrupt zip was deleted."
);
});
/**
* Tests that if the archive file does not contain a JSON block that
* BackupService.sampleArchive will reject.
*/
add_task(async function test_missing_JSON_block() {
let bs = new BackupService();
let missingJSONBlockFile = do_get_file("data/missing_json_block.html");
await Assert.rejects(
bs.sampleArchive(missingJSONBlockFile.path),
/Could not find JSON block/
);
});
/**
* Tests that if the archive file does not contain a binary block that
* BackupService.extractCompressedSnapshotFromArchive will reject.
*/
add_task(async function test_missing_binary_block() {
let bs = new BackupService();
let fakeRecoveryPath = await IOUtils.createUniqueDirectory(
PathUtils.tempDir,
"testCreateArchiveMissingBinaryBlockDest"
);
let missingBinaryBlockFile = do_get_file("data/missing_binary_block.html");
await Assert.rejects(
bs.extractCompressedSnapshotFromArchive(
missingBinaryBlockFile.path,
fakeRecoveryPath
),
/Could not find binary block/
);
await IOUtils.remove(fakeRecoveryPath);
});
/**
* Tests that if the archive file is constructed in such a way that the
* worker ends up breaking Unicode characters in half when finding the
* JSON block, that we can still extract the JSON block.
*
* See bug 1906912.
*/
add_task(async function test_broken_unicode_characters() {
let bs = new BackupService();
let specialUnicodeFile = do_get_file("data/break_over_unicode.html");
let { archiveJSON } = await bs.sampleArchive(specialUnicodeFile.path);
Assert.ok(
archiveJSON,
"Was able to extract the JSON from the specially created file with " +
"unicode characters"
);
});