Files
duplicati_preview/test/media-core.test.js
T

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
});
}
});