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