359 lines
10 KiB
JavaScript
359 lines
10 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import {
|
|
clearAllCachedVolumes,
|
|
clearCachedVolumesForSource,
|
|
decryptAesCryptV2Bytes,
|
|
decryptAesCryptV2StreamToWriter,
|
|
extractZipEntryBytesFromBuffer,
|
|
getCachedVolumeStats,
|
|
getGlobalCacheStats,
|
|
listCachedVolumeNames,
|
|
parseSingleRange,
|
|
selectSegmentsForRange
|
|
} from '../public/modules/media-core.js';
|
|
import { scanZipEntries } from '../src/lib/zip.js';
|
|
import { createDuplicatiFixture } from './support/duplicatiFixture.js';
|
|
|
|
function createNotFoundError(message = 'Not found.') {
|
|
const error = new Error(message);
|
|
error.name = 'NotFoundError';
|
|
return error;
|
|
}
|
|
|
|
class FakeFile {
|
|
constructor(bytes) {
|
|
this.bytes = Uint8Array.from(bytes);
|
|
this.size = this.bytes.length;
|
|
}
|
|
|
|
slice(start = 0, end = this.bytes.length) {
|
|
const bytes = this.bytes.slice(start, end);
|
|
return {
|
|
async arrayBuffer() {
|
|
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
}
|
|
};
|
|
}
|
|
|
|
async arrayBuffer() {
|
|
return this.bytes.buffer.slice(this.bytes.byteOffset, this.bytes.byteOffset + this.bytes.byteLength);
|
|
}
|
|
}
|
|
|
|
class FakeFileHandle {
|
|
constructor(name, bytes) {
|
|
this.kind = 'file';
|
|
this.name = name;
|
|
this.file = new FakeFile(bytes);
|
|
}
|
|
|
|
async getFile() {
|
|
return this.file;
|
|
}
|
|
}
|
|
|
|
class FakeDirectoryHandle {
|
|
constructor(name = '') {
|
|
this.kind = 'directory';
|
|
this.name = name;
|
|
this.children = new Map();
|
|
}
|
|
|
|
setFile(name, bytes) {
|
|
this.children.set(name, new FakeFileHandle(name, bytes));
|
|
return this;
|
|
}
|
|
|
|
setDirectory(name, directoryHandle) {
|
|
this.children.set(name, directoryHandle);
|
|
return this;
|
|
}
|
|
|
|
async getDirectoryHandle(name, options = {}) {
|
|
const existing = this.children.get(name);
|
|
if (existing?.kind === 'directory') {
|
|
return existing;
|
|
}
|
|
|
|
if (options.create) {
|
|
const directory = new FakeDirectoryHandle(name);
|
|
this.children.set(name, directory);
|
|
return directory;
|
|
}
|
|
|
|
throw createNotFoundError(`Directory ${name} was not found.`);
|
|
}
|
|
|
|
async getFileHandle(name, options = {}) {
|
|
const existing = this.children.get(name);
|
|
if (existing?.kind === 'file') {
|
|
return existing;
|
|
}
|
|
|
|
if (options.create) {
|
|
const fileHandle = new FakeFileHandle(name, new Uint8Array(0));
|
|
this.children.set(name, fileHandle);
|
|
return fileHandle;
|
|
}
|
|
|
|
throw createNotFoundError(`File ${name} was not found.`);
|
|
}
|
|
|
|
async removeEntry(name) {
|
|
if (!this.children.has(name)) {
|
|
throw createNotFoundError(`Entry ${name} was not found.`);
|
|
}
|
|
|
|
this.children.delete(name);
|
|
}
|
|
|
|
async *entries() {
|
|
for (const entry of this.children.entries()) {
|
|
yield entry;
|
|
}
|
|
}
|
|
}
|
|
|
|
test('parseSingleRange supports explicit, open, and suffix byte ranges', () => {
|
|
assert.deepEqual(parseSingleRange('bytes=0-99', 1000), {
|
|
start: 0,
|
|
endInclusive: 99,
|
|
endExclusive: 100,
|
|
contentLength: 100,
|
|
partial: true
|
|
});
|
|
|
|
assert.deepEqual(parseSingleRange('bytes=200-', 1000), {
|
|
start: 200,
|
|
endInclusive: 999,
|
|
endExclusive: 1000,
|
|
contentLength: 800,
|
|
partial: true
|
|
});
|
|
|
|
assert.deepEqual(parseSingleRange('bytes=-128', 1000), {
|
|
start: 872,
|
|
endInclusive: 999,
|
|
endExclusive: 1000,
|
|
contentLength: 128,
|
|
partial: true
|
|
});
|
|
});
|
|
|
|
test('selectSegmentsForRange preserves cross-block slicing metadata', () => {
|
|
const overlaps = selectSegmentsForRange(
|
|
[
|
|
{ segmentIndex: 0, logicalOffset: 0, logicalSize: 8 },
|
|
{ segmentIndex: 1, logicalOffset: 8, logicalSize: 8 },
|
|
{ segmentIndex: 2, logicalOffset: 16, logicalSize: 8 }
|
|
],
|
|
4,
|
|
20
|
|
);
|
|
|
|
assert.deepEqual(
|
|
overlaps.map((entry) => ({
|
|
segmentIndex: entry.segment.segmentIndex,
|
|
offsetWithinSegmentStart: entry.offsetWithinSegmentStart,
|
|
offsetWithinSegmentEnd: entry.offsetWithinSegmentEnd
|
|
})),
|
|
[
|
|
{ segmentIndex: 0, offsetWithinSegmentStart: 4, offsetWithinSegmentEnd: 8 },
|
|
{ segmentIndex: 1, offsetWithinSegmentStart: 0, offsetWithinSegmentEnd: 8 },
|
|
{ segmentIndex: 2, offsetWithinSegmentStart: 0, offsetWithinSegmentEnd: 4 }
|
|
]
|
|
);
|
|
});
|
|
|
|
test('browser media helpers decrypt AES v2 volumes and restore zip entries', async () => {
|
|
const fixture = createDuplicatiFixture({
|
|
withRemoteVolumes: true,
|
|
includeArchiveEntryIndex: false,
|
|
includeVolumeCryptoCache: false
|
|
});
|
|
const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'media-core-'));
|
|
const plainZipPath = path.join(tempDirectory, 'demo.zip');
|
|
|
|
try {
|
|
const encryptedDemoBytes = await fs.readFile(fixture.remoteFilesByName['duplicati-demo.dblock.zip.aes']);
|
|
const decryptedDemo = await decryptAesCryptV2Bytes(encryptedDemoBytes, fixture.passphrase);
|
|
await fs.writeFile(plainZipPath, decryptedDemo.plainBytes);
|
|
|
|
const scanResult = await scanZipEntries(plainZipPath);
|
|
const firstEntry = scanResult.entries.find(
|
|
(entry) => entry.entryName === '0td8NEaS7SMrQc5Gs0Sdxjb_1MXEEuwkyxRpguDiWsY='
|
|
);
|
|
assert.ok(firstEntry);
|
|
|
|
const firstBlock = await extractZipEntryBytesFromBuffer(decryptedDemo.plainBytes, {
|
|
dataOffsetPlain: firstEntry.dataOffset,
|
|
compressedSize: firstEntry.compressedSize,
|
|
uncompressedSize: firstEntry.uncompressedSize,
|
|
compressionMethod: firstEntry.compressionMethod
|
|
});
|
|
assert.equal(firstBlock.length, 102400);
|
|
assert.equal(firstBlock[0], 0x11);
|
|
assert.equal(firstBlock[firstBlock.length - 1], 0x11);
|
|
|
|
const encryptedTextBytes = await fs.readFile(fixture.remoteFilesByName['duplicati-text.dblock.zip.aes']);
|
|
const decryptedText = await decryptAesCryptV2Bytes(encryptedTextBytes, fixture.passphrase);
|
|
await fs.writeFile(plainZipPath, decryptedText.plainBytes);
|
|
|
|
const textScanResult = await scanZipEntries(plainZipPath);
|
|
const textEntry = textScanResult.entries.find((entry) => entry.entryName === 'ySjwbaRrk-rm6Vx0W0xP8A==');
|
|
assert.ok(textEntry);
|
|
|
|
const textBytes = await extractZipEntryBytesFromBuffer(decryptedText.plainBytes, {
|
|
dataOffsetPlain: textEntry.dataOffset,
|
|
compressedSize: textEntry.compressedSize,
|
|
uncompressedSize: textEntry.uncompressedSize,
|
|
compressionMethod: textEntry.compressionMethod
|
|
});
|
|
assert.equal(new TextDecoder().decode(textBytes), 'hello');
|
|
} finally {
|
|
fixture.cleanup();
|
|
await fs.rm(tempDirectory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('streaming AES v2 decrypt writes plain zip bytes without buffering the whole response first', async () => {
|
|
const fixture = createDuplicatiFixture({
|
|
withRemoteVolumes: true,
|
|
includeArchiveEntryIndex: false,
|
|
includeVolumeCryptoCache: false
|
|
});
|
|
|
|
try {
|
|
const encryptedBytes = await fs.readFile(fixture.remoteFilesByName['duplicati-demo.dblock.zip.aes']);
|
|
const expected = await decryptAesCryptV2Bytes(encryptedBytes, fixture.passphrase);
|
|
|
|
const writtenChunks = [];
|
|
const writer = {
|
|
async write(bytes) {
|
|
writtenChunks.push(Uint8Array.from(bytes));
|
|
},
|
|
async close() {},
|
|
async abort() {
|
|
writtenChunks.length = 0;
|
|
}
|
|
};
|
|
|
|
const streamed = await decryptAesCryptV2StreamToWriter(
|
|
new Blob([encryptedBytes]).stream(),
|
|
fixture.passphrase,
|
|
writer
|
|
);
|
|
|
|
const actual = Uint8Array.from(writtenChunks.flatMap((chunk) => [...chunk]));
|
|
assert.deepEqual(actual, expected.plainBytes);
|
|
assert.deepEqual(streamed.meta, expected.meta);
|
|
} finally {
|
|
fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
test('streaming AES v2 decrypt stops when the caller aborts the active request', async () => {
|
|
const fixture = createDuplicatiFixture({
|
|
withRemoteVolumes: true,
|
|
includeArchiveEntryIndex: false,
|
|
includeVolumeCryptoCache: false
|
|
});
|
|
|
|
try {
|
|
const encryptedBytes = await fs.readFile(fixture.remoteFilesByName['duplicati-demo.dblock.zip.aes']);
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
|
|
await assert.rejects(
|
|
() =>
|
|
decryptAesCryptV2StreamToWriter(
|
|
new Blob([encryptedBytes]).stream(),
|
|
fixture.passphrase,
|
|
{
|
|
async write() {},
|
|
async close() {},
|
|
async abort() {}
|
|
},
|
|
() => {},
|
|
controller.signal
|
|
),
|
|
(error) => error?.name === 'AbortError' && error?.code === 'MEDIA_REQUEST_ABORTED'
|
|
);
|
|
} finally {
|
|
fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
test('browser media cache helpers summarize and clear source-scoped OPFS entries', async () => {
|
|
const root = new FakeDirectoryHandle('root');
|
|
const cacheRoot = new FakeDirectoryHandle('duplicati-media-cache');
|
|
const sourceA = new FakeDirectoryHandle('source-a')
|
|
.setFile('vol-1.zip', new Uint8Array(10))
|
|
.setFile('vol-2.zip', new Uint8Array(22));
|
|
const sourceB = new FakeDirectoryHandle('source-b')
|
|
.setFile('vol-3.zip', new Uint8Array(5));
|
|
cacheRoot.setDirectory('source-a', sourceA);
|
|
cacheRoot.setDirectory('source-b', sourceB);
|
|
root.setDirectory('duplicati-media-cache', cacheRoot);
|
|
|
|
const originalNavigator = globalThis.navigator;
|
|
Object.defineProperty(globalThis, 'navigator', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: {
|
|
storage: {
|
|
async getDirectory() {
|
|
return root;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
try {
|
|
assert.deepEqual(await listCachedVolumeNames('source-a'), ['vol-1.zip', 'vol-2.zip']);
|
|
assert.deepEqual(await getCachedVolumeStats('source-a'), {
|
|
sourceCount: 0,
|
|
volumeCount: 2,
|
|
totalBytes: 32
|
|
});
|
|
assert.deepEqual(await getGlobalCacheStats(), {
|
|
sourceCount: 2,
|
|
volumeCount: 3,
|
|
totalBytes: 37
|
|
});
|
|
|
|
assert.deepEqual(await clearCachedVolumesForSource('source-a'), {
|
|
removedSources: 1,
|
|
removedVolumes: 2,
|
|
removedBytes: 32
|
|
});
|
|
assert.deepEqual(await getGlobalCacheStats(), {
|
|
sourceCount: 1,
|
|
volumeCount: 1,
|
|
totalBytes: 5
|
|
});
|
|
|
|
assert.deepEqual(await clearAllCachedVolumes(), {
|
|
removedSources: 1,
|
|
removedVolumes: 1,
|
|
removedBytes: 5
|
|
});
|
|
assert.deepEqual(await getGlobalCacheStats(), {
|
|
sourceCount: 0,
|
|
volumeCount: 0,
|
|
totalBytes: 0
|
|
});
|
|
} finally {
|
|
Object.defineProperty(globalThis, 'navigator', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: originalNavigator
|
|
});
|
|
}
|
|
});
|