mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-08-27 21:19:56 +00:00
679 lines
31 KiB
JavaScript
679 lines
31 KiB
JavaScript
// Copyright (c) 2026 Pyxis contributors
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
import assert from 'node:assert/strict';
|
|
import {readFile} from 'node:fs/promises';
|
|
import test from 'node:test';
|
|
import {
|
|
decodeActiveSelection,
|
|
encodeActiveMapSet,
|
|
getMuiStyleProfile,
|
|
inspectMuiZip,
|
|
installMuiZip,
|
|
parseSparseManifest,
|
|
resolveMuiStyleProfile,
|
|
suggestMapIdentity,
|
|
} from '../../docs/flasher/js/map-installer.js';
|
|
|
|
const PNG = Buffer.from(
|
|
'iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAAA1UlEQVR4nO3BMQEAAADCoPVP7WULoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAGwEtAAHMpTgHAAAAAElFTkSuQmCC',
|
|
'base64',
|
|
);
|
|
|
|
test('each selected archive receives a fresh suggested name and pack ID', () => {
|
|
assert.deepEqual(suggestMapIdentity('regional-overview.zip'), {
|
|
name: 'regional-overview',
|
|
packId: 'regional-overview',
|
|
});
|
|
assert.deepEqual(suggestMapIdentity('Local Detail Tiles.ZIP'), {
|
|
name: 'Local Detail Tiles',
|
|
packId: 'local-detail-tiles',
|
|
});
|
|
assert.deepEqual(suggestMapIdentity('.zip'), {
|
|
name: 'Offline map',
|
|
packId: 'offline-map',
|
|
});
|
|
});
|
|
|
|
function crc32(bytes) {
|
|
let crc = 0xffffffff;
|
|
for (const byte of bytes) {
|
|
crc ^= byte;
|
|
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0);
|
|
}
|
|
return (~crc) >>> 0;
|
|
}
|
|
|
|
function pngWithChunkBeforeIdat(png, type, payload) {
|
|
let position = 8;
|
|
while (png.toString('ascii', position + 4, position + 8) !== 'IDAT') {
|
|
position += 12 + png.readUInt32BE(position);
|
|
}
|
|
const name = Buffer.from(type, 'ascii');
|
|
const data = Buffer.from(payload);
|
|
const chunk = Buffer.alloc(12 + data.length);
|
|
chunk.writeUInt32BE(data.length, 0); name.copy(chunk, 4); data.copy(chunk, 8);
|
|
chunk.writeUInt32BE(crc32(Buffer.concat([name, data])), 8 + data.length);
|
|
return Buffer.concat([png.subarray(0, position), chunk, png.subarray(position)]);
|
|
}
|
|
|
|
function rewriteFirstIdat(png, transform) {
|
|
let position = 8;
|
|
while (png.toString('ascii', position + 4, position + 8) !== 'IDAT') position += 12 + png.readUInt32BE(position);
|
|
const oldLength = png.readUInt32BE(position);
|
|
const payload = Buffer.from(transform(png.subarray(position + 8, position + 8 + oldLength)));
|
|
const type = Buffer.from('IDAT');
|
|
const chunk = Buffer.alloc(12 + payload.length);
|
|
chunk.writeUInt32BE(payload.length, 0); type.copy(chunk, 4); payload.copy(chunk, 8);
|
|
chunk.writeUInt32BE(crc32(Buffer.concat([type, payload])), 8 + payload.length);
|
|
return Buffer.concat([png.subarray(0, position), chunk, png.subarray(position + 12 + oldLength)]);
|
|
}
|
|
|
|
function storedZip(entries) {
|
|
const local = [];
|
|
const central = [];
|
|
let offset = 0;
|
|
for (const [name, payload] of entries) {
|
|
const filename = Buffer.from(name, 'utf8');
|
|
const data = Buffer.from(payload);
|
|
const checksum = crc32(data);
|
|
const header = Buffer.alloc(30);
|
|
header.writeUInt32LE(0x04034b50, 0);
|
|
header.writeUInt16LE(20, 4);
|
|
header.writeUInt16LE(0, 6);
|
|
header.writeUInt16LE(0, 8);
|
|
header.writeUInt32LE(checksum, 14);
|
|
header.writeUInt32LE(data.length, 18);
|
|
header.writeUInt32LE(data.length, 22);
|
|
header.writeUInt16LE(filename.length, 26);
|
|
local.push(header, filename, data);
|
|
|
|
const record = Buffer.alloc(46);
|
|
record.writeUInt32LE(0x02014b50, 0);
|
|
record.writeUInt16LE(20, 4);
|
|
record.writeUInt16LE(20, 6);
|
|
record.writeUInt16LE(0, 8);
|
|
record.writeUInt16LE(0, 10);
|
|
record.writeUInt32LE(checksum, 16);
|
|
record.writeUInt32LE(data.length, 20);
|
|
record.writeUInt32LE(data.length, 24);
|
|
record.writeUInt16LE(filename.length, 28);
|
|
record.writeUInt32LE(offset, 42);
|
|
central.push(record, filename);
|
|
offset += header.length + filename.length + data.length;
|
|
}
|
|
const centralBytes = Buffer.concat(central);
|
|
const end = Buffer.alloc(22);
|
|
end.writeUInt32LE(0x06054b50, 0);
|
|
end.writeUInt16LE(entries.length, 8);
|
|
end.writeUInt16LE(entries.length, 10);
|
|
end.writeUInt32LE(centralBytes.length, 12);
|
|
end.writeUInt32LE(offset, 16);
|
|
return new Blob([...local, centralBytes, end], {type: 'application/zip'});
|
|
}
|
|
|
|
class MemoryFileHandle {
|
|
constructor(name, log) { this.name = name; this.kind = 'file'; this.bytes = new Uint8Array(); this.log = log; }
|
|
async createWritable() {
|
|
const handle = this;
|
|
let pending = new Uint8Array();
|
|
return {
|
|
async write(value) { pending = new Uint8Array(value instanceof Blob ? await value.arrayBuffer() : value); },
|
|
async close() { handle.bytes = pending; handle.log.push(`write:${handle.name}`); },
|
|
async abort() {},
|
|
};
|
|
}
|
|
async getFile() { return new Blob([this.bytes]); }
|
|
}
|
|
|
|
class MemoryDirectoryHandle {
|
|
constructor(name = '', log = []) { this.name = name; this.kind = 'directory'; this.log = log; this.children = new Map(); }
|
|
async getDirectoryHandle(name, options = {}) {
|
|
const current = this.children.get(name);
|
|
if (current) {
|
|
if (current.kind !== 'directory') throw new DOMException('type', 'TypeMismatchError');
|
|
return current;
|
|
}
|
|
if (!options.create) throw new DOMException('missing', 'NotFoundError');
|
|
const child = new MemoryDirectoryHandle(name, this.log);
|
|
this.children.set(name, child);
|
|
return child;
|
|
}
|
|
async getFileHandle(name, options = {}) {
|
|
const current = this.children.get(name);
|
|
if (current) {
|
|
if (current.kind !== 'file') throw new DOMException('type', 'TypeMismatchError');
|
|
return current;
|
|
}
|
|
if (!options.create) throw new DOMException('missing', 'NotFoundError');
|
|
const child = new MemoryFileHandle(name, this.log);
|
|
this.children.set(name, child);
|
|
return child;
|
|
}
|
|
async *entries() { for (const entry of this.children.entries()) yield entry; }
|
|
async isSameEntry(other) { return this === other; }
|
|
async removeEntry(name, options = {}) {
|
|
const current = this.children.get(name);
|
|
if (!current) throw new DOMException('missing', 'NotFoundError');
|
|
if (current.kind === 'directory' && current.children.size && !options.recursive) throw new DOMException('not empty', 'InvalidModificationError');
|
|
this.children.delete(name);
|
|
}
|
|
}
|
|
|
|
class DirectoryHandleAlias {
|
|
constructor(target) { this.target = target; this.name = target.name; this.kind = 'directory'; }
|
|
async getDirectoryHandle(...args) { return this.target.getDirectoryHandle(...args); }
|
|
async getFileHandle(...args) { return this.target.getFileHandle(...args); }
|
|
async *entries() { yield* this.target.entries(); }
|
|
async removeEntry(...args) { return this.target.removeEntry(...args); }
|
|
async isSameEntry(other) { return this.target === (other?.target || other); }
|
|
}
|
|
|
|
class MemoryLockManager {
|
|
constructor() { this.tails = new Map(); }
|
|
async request(name, _options, operation) {
|
|
const previous = this.tails.get(name) || Promise.resolve();
|
|
let release;
|
|
const gate = new Promise(resolve => { release = resolve; });
|
|
const tail = previous.then(() => gate);
|
|
this.tails.set(name, tail);
|
|
await previous;
|
|
try { return await operation(); }
|
|
finally {
|
|
release();
|
|
if (this.tails.get(name) === tail) this.tails.delete(name);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function child(root, path) {
|
|
let current = root;
|
|
for (const part of path.split('/')) current = current.children.get(part);
|
|
return current;
|
|
}
|
|
|
|
const metadata = {
|
|
packId: 'overview',
|
|
mapSetId: 'osm-bright',
|
|
name: 'Overview',
|
|
attribution: '(c) OpenMapTiles (c) OpenStreetMap contributors',
|
|
source: "Oxed's Map Tile Downloader (OSM Bright)",
|
|
license: 'OSM ODbL; style CC-BY-4.0/BSD-3-Clause',
|
|
};
|
|
|
|
test('maps supported Coalition styles to separate map sets and provenance', async () => {
|
|
const expected = [
|
|
['osm-bright', 'OSM Bright', '(c) OpenMapTiles (c) OpenStreetMap contributors',
|
|
'OSM ODbL; style CC-BY-4.0/BSD-3-Clause'],
|
|
['dark-matter', 'Dark Matter', '(c) OpenMapTiles (c) OpenStreetMap contributors; style (c) CARTO',
|
|
'OSM ODbL; style CC-BY-4.0/BSD-3-Clause (CARTO CC-BY-3.0)'],
|
|
['positron', 'Positron', '(c) OpenMapTiles (c) OpenStreetMap contributors; style (c) CARTO',
|
|
'OSM ODbL; style CC-BY-4.0/BSD-3-Clause (CARTO CC-BY-3.0)'],
|
|
['toner', 'Toner', '(c) MapTiler (c) OpenStreetMap contributors',
|
|
'OSM ODbL; style CC-BY-4.0/BSD-3-Clause (Stamen ISC)'],
|
|
];
|
|
for (const [styleId, label, attribution, license] of expected) {
|
|
assert.deepEqual(getMuiStyleProfile(styleId), {
|
|
id: styleId,
|
|
label,
|
|
attribution,
|
|
source: `Oxed's Map Tile Downloader (${label})`,
|
|
license,
|
|
});
|
|
const report = await inspectMuiZip(storedZip([[`maps/${styleId}/2/1/1.png`, PNG]]));
|
|
assert.equal(report.styleId, styleId);
|
|
}
|
|
assert.throws(() => getMuiStyleProfile('unknown-style'), /unsupported MUI map style/i);
|
|
});
|
|
|
|
test('rootless archive requires explicit style before SD mutation and then installs', async () => {
|
|
const archive = storedZip([['2/1/1.png', PNG]]);
|
|
const report = await inspectMuiZip(archive);
|
|
assert.equal(report.styleId, null);
|
|
const root = new MemoryDirectoryHandle();
|
|
assert.throws(() => resolveMuiStyleProfile(report.styleId, ''), /select map style/i);
|
|
assert.equal(root.children.has('pyxis-map'), false);
|
|
|
|
const style = resolveMuiStyleProfile(report.styleId, 'positron');
|
|
assert.equal(style.id, 'positron');
|
|
const result = await installMuiZip({archive, rootDirectory:root, metadata:{
|
|
...metadata, mapSetId:style.id, attribution:style.attribution,
|
|
source:style.source, license:style.license,
|
|
}});
|
|
assert.equal(result.tileCount, 1);
|
|
assert.ok(root.children.has('pyxis-map'));
|
|
});
|
|
|
|
test('rejects installing a detected style into a different map set', async () => {
|
|
const archive = storedZip([['maps/dark-matter/2/1/1.png', PNG]]);
|
|
const root = new MemoryDirectoryHandle();
|
|
await assert.rejects(
|
|
installMuiZip({archive, rootDirectory:root, metadata}),
|
|
/style.*selected map set/i,
|
|
);
|
|
assert.equal(root.children.has('pyxis-map'), false);
|
|
});
|
|
|
|
test('rejects altered required style attribution before touching the SD root', async () => {
|
|
const root = new MemoryDirectoryHandle();
|
|
await assert.rejects(
|
|
installMuiZip({archive:storedZip([['2/1/1.png', PNG]]),rootDirectory:root,
|
|
metadata:{...metadata,attribution:'Map data'}}),
|
|
/attribution|provenance/i,
|
|
);
|
|
assert.equal(root.children.has('pyxis-map'), false);
|
|
});
|
|
|
|
test('active map-set wire format matches the cross-language golden record', () => {
|
|
const record = encodeActiveMapSet({generation:1,mapSetId:'osm-bright',attribution:'Map data attribution',packs:[
|
|
{packId:'detail',rowSpans:[{zoom:2,y:1,xMinimum:1,xMaximum:1},{zoom:4,y:5,xMinimum:5,xMaximum:5}]},
|
|
{packId:'state',rowSpans:[{zoom:2,y:1,xMinimum:1,xMaximum:2}]},
|
|
]});
|
|
assert.equal(Buffer.from(record).toString('hex'), '504d415302006900010000000a6f736d2d627269676874144d61702064617461206174747269627574696f6e020664657461696c020002010000000100000001000000040500000005000000050000000573746174650100020100000001000000020000009de230d6');
|
|
assert.deepEqual(decodeActiveSelection(record).packs.map(pack => pack.packId), ['detail','state']);
|
|
});
|
|
|
|
test('accepts a sanitized downloader-shaped rootless stored ZIP fixture', async () => {
|
|
const bytes = await readFile(new URL('./fixtures/downloader-rootless-stored-descriptor.zip', import.meta.url));
|
|
const archive = new Blob([bytes], {type: 'application/zip'});
|
|
|
|
// Walk the ZIP structures instead of searching for signatures, which may also
|
|
// occur in PNG payloads. This fixture intentionally exercises signed streaming
|
|
// descriptors, a stricter shape than inspectMuiZip itself requires.
|
|
const requireRange = (offset, length, label) => {
|
|
assert(Number.isSafeInteger(offset) && offset >= 0 && length >= 0 && offset + length <= bytes.length,
|
|
`${label} is outside the fixture`);
|
|
};
|
|
const u16 = (offset, label) => { requireRange(offset, 2, label); return bytes.readUInt16LE(offset); };
|
|
const u32 = (offset, label) => { requireRange(offset, 4, label); return bytes.readUInt32LE(offset); };
|
|
const eocdOffset = bytes.length - 22;
|
|
requireRange(eocdOffset, 22, 'EOCD');
|
|
assert.equal(u32(eocdOffset, 'EOCD signature'), 0x06054b50);
|
|
assert.equal(u16(eocdOffset + 4, 'EOCD disk number'), 0);
|
|
assert.equal(u16(eocdOffset + 6, 'EOCD central-directory disk'), 0);
|
|
assert.equal(u16(eocdOffset + 8, 'EOCD disk record count'), 3);
|
|
assert.equal(u16(eocdOffset + 10, 'EOCD record count'), 3);
|
|
assert.equal(u16(eocdOffset + 20, 'EOCD comment length'), 0);
|
|
const centralSize = u32(eocdOffset + 12, 'central-directory size');
|
|
const centralOffset = u32(eocdOffset + 16, 'central-directory offset');
|
|
requireRange(centralOffset, centralSize, 'central directory');
|
|
assert.equal(centralOffset + centralSize, eocdOffset);
|
|
|
|
const expectedEntries = [
|
|
{name: '0/0/0.png', size: 270},
|
|
{name: '1/0/0.png', size: 270},
|
|
{name: '2/1/1.png', size: 270},
|
|
];
|
|
const records = [];
|
|
let position = centralOffset;
|
|
for (const expected of expectedEntries) {
|
|
requireRange(position, 46, `central header for ${expected.name}`);
|
|
assert.equal(u32(position, `central signature for ${expected.name}`), 0x02014b50);
|
|
const nameLength = u16(position + 28, `central name length for ${expected.name}`);
|
|
const extraLength = u16(position + 30, `central extra length for ${expected.name}`);
|
|
const commentLength = u16(position + 32, `central comment length for ${expected.name}`);
|
|
const recordLength = 46 + nameLength + extraLength + commentLength;
|
|
requireRange(position, recordLength, `central record for ${expected.name}`);
|
|
const name = bytes.toString('utf8', position + 46, position + 46 + nameLength);
|
|
assert.equal(name, expected.name);
|
|
assert.equal(u16(position + 4, `creator for ${name}`), 20); // DOS creator, ZIP 2.0
|
|
assert.equal(u16(position + 8, `flags for ${name}`), 0x0008);
|
|
assert.equal(u16(position + 10, `method for ${name}`), 0);
|
|
assert.equal(extraLength, 0);
|
|
assert.equal(commentLength, 0);
|
|
assert.equal(u16(position + 34, `disk start for ${name}`), 0);
|
|
assert.equal(u32(position + 38, `external attributes for ${name}`), 0);
|
|
const crc = u32(position + 16, `CRC for ${name}`);
|
|
const compressedSize = u32(position + 20, `compressed size for ${name}`);
|
|
const uncompressedSize = u32(position + 24, `uncompressed size for ${name}`);
|
|
assert.equal(compressedSize, expected.size);
|
|
assert.equal(uncompressedSize, expected.size);
|
|
records.push({name, crc, compressedSize, uncompressedSize, localOffset: u32(position + 42, `local offset for ${name}`)});
|
|
position += recordLength;
|
|
}
|
|
assert.equal(position, centralOffset + centralSize, 'central directory must contain exactly three records');
|
|
|
|
for (let index = 0; index < records.length; index++) {
|
|
const record = records[index];
|
|
const local = record.localOffset;
|
|
requireRange(local, 30, `local header for ${record.name}`);
|
|
assert.equal(u32(local, `local signature for ${record.name}`), 0x04034b50);
|
|
assert.equal(u16(local + 6, `local flags for ${record.name}`), 0x0008);
|
|
assert.equal(u16(local + 8, `local method for ${record.name}`), 0);
|
|
assert.equal(u32(local + 14, `local CRC for ${record.name}`), 0);
|
|
assert.equal(u32(local + 18, `local compressed size for ${record.name}`), 0);
|
|
assert.equal(u32(local + 22, `local uncompressed size for ${record.name}`), 0);
|
|
const localNameLength = u16(local + 26, `local name length for ${record.name}`);
|
|
const localExtraLength = u16(local + 28, `local extra length for ${record.name}`);
|
|
assert.equal(localExtraLength, 0);
|
|
requireRange(local + 30, localNameLength, `local name for ${record.name}`);
|
|
assert.equal(bytes.toString('utf8', local + 30, local + 30 + localNameLength), record.name);
|
|
|
|
const descriptor = local + 30 + localNameLength + localExtraLength + record.compressedSize;
|
|
requireRange(descriptor, 16, `data descriptor for ${record.name}`);
|
|
assert.equal(u32(descriptor, `descriptor signature for ${record.name}`), 0x08074b50);
|
|
assert.equal(u32(descriptor + 4, `descriptor CRC for ${record.name}`), record.crc);
|
|
assert.equal(u32(descriptor + 8, `descriptor compressed size for ${record.name}`), record.compressedSize);
|
|
assert.equal(u32(descriptor + 12, `descriptor uncompressed size for ${record.name}`), record.uncompressedSize);
|
|
assert.equal(descriptor + 16, records[index + 1]?.localOffset ?? centralOffset,
|
|
`data descriptor for ${record.name} must immediately follow its payload`);
|
|
}
|
|
|
|
const report = await inspectMuiZip(archive);
|
|
assert.equal(report.tileCount, 3);
|
|
assert.equal(report.minZoom, 0);
|
|
assert.equal(report.maxZoom, 2);
|
|
assert.deepEqual(report.rowSpans, [
|
|
{zoom: 0, y: 0, xMinimum: 0, xMaximum: 0},
|
|
{zoom: 1, y: 0, xMinimum: 0, xMaximum: 0},
|
|
{zoom: 2, y: 1, xMinimum: 1, xMaximum: 1},
|
|
]);
|
|
|
|
const root = new MemoryDirectoryHandle();
|
|
const result = await installMuiZip({archive, rootDirectory: root, metadata});
|
|
assert.equal(result.tileCount, 3);
|
|
assert.deepEqual(
|
|
[...(await child(root, 'pyxis-map/packs/overview/tiles/0/0/0.png')).bytes],
|
|
[...PNG],
|
|
);
|
|
});
|
|
|
|
test('inspects and transactionally installs a sparse stored MUI XYZ ZIP', async () => {
|
|
const archive = storedZip([
|
|
['2/1/1.png', PNG],
|
|
['2/1/2.png', PNG],
|
|
['2/2/1.png', PNG],
|
|
]);
|
|
const report = await inspectMuiZip(archive);
|
|
assert.equal(report.tileCount, 3);
|
|
assert.equal(report.minZoom, 2);
|
|
assert.equal(report.maxZoom, 2);
|
|
assert.equal(report.styleId, null);
|
|
assert.deepEqual(report.rowSpans, [
|
|
{zoom: 2, y: 1, xMinimum: 1, xMaximum: 2},
|
|
{zoom: 2, y: 2, xMinimum: 1, xMaximum: 1},
|
|
]);
|
|
|
|
const root = new MemoryDirectoryHandle();
|
|
const result = await installMuiZip({archive, rootDirectory: root, metadata});
|
|
assert.equal(result.tileCount, 3);
|
|
const pack = await child(root, 'pyxis-map/packs/overview');
|
|
const manifest = pack.children.get('manifest.pmp').bytes;
|
|
assert.equal(Buffer.from(manifest.subarray(0, 4)).toString('ascii'), 'PMPK');
|
|
assert.equal(manifest[4], 2);
|
|
const parsed = parseSparseManifest(manifest);
|
|
assert.equal(parsed.tileCount, 3);
|
|
assert.equal(parsed.rowSpans.length, 2);
|
|
const selection = decodeActiveSelection((await child(root, 'pyxis-map/active-pack.0')).bytes);
|
|
assert.equal(selection.version, 2);
|
|
assert.equal(selection.mapSetId, 'osm-bright');
|
|
assert.deepEqual(selection.packs.map(pack => pack.packId), ['overview']);
|
|
assert.equal(selection.generation, 1);
|
|
const firstCatalog = decodeActiveSelection(
|
|
(await child(root, 'pyxis-map/map-sets/osm-bright.pmas')).bytes,
|
|
);
|
|
assert.equal(firstCatalog.mapSetId, 'osm-bright');
|
|
assert.deepEqual(firstCatalog.packs.map(pack => pack.packId), ['overview']);
|
|
const firstManifestWrite = root.log.indexOf('write:manifest.pmp');
|
|
assert(firstManifestWrite > root.log.lastIndexOf('write:1.png'));
|
|
assert(root.log.indexOf('write:active-pack.0') > firstManifestWrite);
|
|
|
|
const second = await installMuiZip({
|
|
archive, rootDirectory:root,
|
|
metadata:{...metadata, packId:'overview-two', name:'Overview Two'},
|
|
});
|
|
assert.equal(second.selectionFile, 'active-pack.1');
|
|
const secondSelection = decodeActiveSelection((await child(root, 'pyxis-map/active-pack.1')).bytes);
|
|
assert.equal(secondSelection.generation, 2);
|
|
assert.deepEqual(secondSelection.packs.map(pack => pack.packId), ['overview-two','overview']);
|
|
const secondCatalog = decodeActiveSelection(
|
|
(await child(root, 'pyxis-map/map-sets/osm-bright.pmas')).bytes,
|
|
);
|
|
assert.deepEqual(secondCatalog.packs.map(pack => pack.packId), ['overview-two','overview']);
|
|
const third = await installMuiZip({
|
|
archive, rootDirectory:root,
|
|
metadata:{...metadata, packId:'overview-three', name:'Overview Three'},
|
|
});
|
|
assert.equal(third.selectionFile, 'active-pack.0');
|
|
const thirdSelection = decodeActiveSelection((await child(root, 'pyxis-map/active-pack.0')).bytes);
|
|
assert.equal(thirdSelection.generation, 3);
|
|
assert.deepEqual(thirdSelection.packs.map(pack => pack.packId), ['overview-three','overview-two','overview']);
|
|
|
|
const resumed = await installMuiZip({
|
|
archive, rootDirectory:root,
|
|
metadata:{...metadata, packId:'overview-two', name:'Overview Two'},
|
|
});
|
|
assert.equal(resumed.resumed, true);
|
|
const resumedSelection = decodeActiveSelection((await child(root, 'pyxis-map/active-pack.1')).bytes);
|
|
assert.equal(resumedSelection.generation, 4);
|
|
assert.deepEqual(resumedSelection.packs.map(pack => pack.packId), ['overview-two','overview-three','overview']);
|
|
});
|
|
|
|
test('preserves separate installed-style PMAS snapshots when activation changes', async () => {
|
|
const root = new MemoryDirectoryHandle();
|
|
const bright = getMuiStyleProfile('osm-bright');
|
|
const dark = getMuiStyleProfile('dark-matter');
|
|
await installMuiZip({
|
|
archive: storedZip([['maps/osm-bright/2/1/1.png', PNG]]),
|
|
rootDirectory: root,
|
|
metadata: {...metadata, packId:'bright-pack', mapSetId:bright.id,
|
|
attribution:bright.attribution, source:bright.source, license:bright.license},
|
|
});
|
|
await installMuiZip({
|
|
archive: storedZip([['maps/dark-matter/2/1/1.png', PNG]]),
|
|
rootDirectory: root,
|
|
metadata: {...metadata, packId:'dark-pack', mapSetId:dark.id,
|
|
attribution:dark.attribution, source:dark.source, license:dark.license},
|
|
});
|
|
const brightCatalog = decodeActiveSelection(
|
|
(await child(root, 'pyxis-map/map-sets/osm-bright.pmas')).bytes,
|
|
);
|
|
const darkCatalog = decodeActiveSelection(
|
|
(await child(root, 'pyxis-map/map-sets/dark-matter.pmas')).bytes,
|
|
);
|
|
assert.equal(brightCatalog.mapSetId, 'osm-bright');
|
|
assert.deepEqual(brightCatalog.packs.map(pack => pack.packId), ['bright-pack']);
|
|
assert.equal(darkCatalog.mapSetId, 'dark-matter');
|
|
assert.deepEqual(darkCatalog.packs.map(pack => pack.packId), ['dark-pack']);
|
|
const active = decodeActiveSelection((await child(root, 'pyxis-map/active-pack.1')).bytes);
|
|
assert.equal(active.mapSetId, 'dark-matter');
|
|
});
|
|
|
|
test('rejects traversal, duplicate keys, unsupported compression, and malformed PNGs', async () => {
|
|
await assert.rejects(inspectMuiZip(storedZip([['../2/1/1.png', PNG]])), /path|traversal/i);
|
|
await assert.rejects(inspectMuiZip(storedZip([['2/1/1.png', PNG], ['2/1/1.png', PNG]])), /duplicate/i);
|
|
const malformed = Buffer.from(PNG); malformed[12] ^= 1;
|
|
await assert.rejects(inspectMuiZip(storedZip([['2/1/1.png', malformed]])), /PNG/i);
|
|
const invalidTransparency = pngWithChunkBeforeIdat(PNG, 'tRNS', [0]);
|
|
await assert.rejects(inspectMuiZip(storedZip([['2/1/1.png', invalidTransparency]])), /transparency/i);
|
|
const badAdler = rewriteFirstIdat(PNG, payload => {const changed=Buffer.from(payload);changed[changed.length-1]^=1;return changed;});
|
|
await assert.rejects(inspectMuiZip(storedZip([['2/1/1.png', badAdler]])), /checksum|zlib|deflate/i);
|
|
const trailingDeflate = rewriteFirstIdat(PNG, payload => Buffer.concat([payload.subarray(0,-4),Buffer.from([0]),payload.subarray(-4)]));
|
|
await assert.rejects(inspectMuiZip(storedZip([['2/1/1.png', trailingDeflate]])), /trailing|zlib|deflate/i);
|
|
});
|
|
|
|
test('removes only its receipt-owned contents after an interrupted write', async () => {
|
|
const archive = storedZip([
|
|
['2/1/1.png', PNG],
|
|
['2/1/2.png', PNG],
|
|
]);
|
|
const root = new MemoryDirectoryHandle();
|
|
await assert.rejects(installMuiZip({
|
|
archive,
|
|
rootDirectory:root,
|
|
metadata:{...metadata, packId:'interrupted', name:'Interrupted'},
|
|
onProgress(progress) { if (progress.phase === 'write') throw new Error('cancelled'); },
|
|
}), /cancelled/);
|
|
const packs = root.children.get('pyxis-map').children.get('packs');
|
|
const interrupted = packs.children.get('interrupted');
|
|
assert.ok(interrupted);
|
|
assert.deepEqual([...interrupted.children.keys()], []);
|
|
const retried = await installMuiZip({
|
|
archive,
|
|
rootDirectory:root,
|
|
metadata:{...metadata, packId:'interrupted', name:'Interrupted'},
|
|
});
|
|
assert.equal(retried.resumed, false);
|
|
assert.ok(interrupted.children.has('manifest.pmp'));
|
|
});
|
|
|
|
test('cleanup preserves an unrelated file that appears in the destination', async () => {
|
|
const archive = storedZip([['2/1/1.png', PNG]]);
|
|
const root = new MemoryDirectoryHandle();
|
|
await assert.rejects(installMuiZip({
|
|
archive,
|
|
rootDirectory:root,
|
|
metadata:{...metadata, packId:'raced', name:'Raced'},
|
|
onProgress(progress) {
|
|
if (progress.phase !== 'write') return;
|
|
const pack = root.children.get('pyxis-map').children.get('packs').children.get('raced');
|
|
const foreign = new MemoryFileHandle('foreign.txt', root.log);
|
|
foreign.bytes = new Uint8Array([9]); pack.children.set('foreign.txt', foreign);
|
|
throw new Error('interrupted');
|
|
},
|
|
}), /could not be safely removed/i);
|
|
const pack = await child(root, 'pyxis-map/packs/raced');
|
|
assert.deepEqual([...pack.children.get('foreign.txt').bytes], [9]);
|
|
});
|
|
|
|
test('receipt cleanup failure preserves a fully published pack', async () => {
|
|
const archive = storedZip([['2/1/1.png', PNG]]);
|
|
const root = new MemoryDirectoryHandle();
|
|
let injected = false;
|
|
await assert.rejects(installMuiZip({
|
|
archive, rootDirectory:root, metadata:{...metadata,packId:'published',name:'Published'},
|
|
onProgress(progress) {
|
|
if (injected || progress.phase !== 'write') return;
|
|
injected = true;
|
|
const pack = root.children.get('pyxis-map').children.get('packs').children.get('published');
|
|
const remove = pack.removeEntry.bind(pack);
|
|
pack.removeEntry = async (name, options) => {
|
|
if (name.startsWith('.pyxis-install-owner-')) { pack.removeEntry = remove; throw new Error('injected receipt failure'); }
|
|
return remove(name, options);
|
|
};
|
|
},
|
|
}), /installed and verified.*receipt/i);
|
|
const pack = await child(root, 'pyxis-map/packs/published');
|
|
assert.ok(pack.children.has('manifest.pmp'));
|
|
assert.deepEqual([...pack.children.get('tiles').children.get('2').children.get('1').children.get('1.png').bytes], [...PNG]);
|
|
});
|
|
|
|
test('concurrent installs on one selected root are serialized', async () => {
|
|
const archive = storedZip([['2/1/1.png', PNG]]);
|
|
const root = new MemoryDirectoryHandle();
|
|
await Promise.all([
|
|
installMuiZip({archive, rootDirectory:root, metadata:{...metadata, packId:'first-pack', name:'First Pack'}}),
|
|
installMuiZip({archive, rootDirectory:root, metadata:{...metadata, packId:'second-pack', name:'Second Pack'}}),
|
|
]);
|
|
const pyxis = root.children.get('pyxis-map');
|
|
const slots = ['active-pack.0', 'active-pack.1']
|
|
.map(name => pyxis.children.get(name))
|
|
.filter(Boolean)
|
|
.map(handle => decodeActiveSelection(handle.bytes));
|
|
const newest = slots.sort((left, right) => right.generation - left.generation)[0];
|
|
assert.equal(newest.generation, 2);
|
|
assert.deepEqual(new Set(newest.packs.map(pack => pack.packId)), new Set(['first-pack', 'second-pack']));
|
|
});
|
|
|
|
test('distinct handles for one selected root are serialized across tabs', async () => {
|
|
const archive = storedZip([['2/1/1.png', PNG]]);
|
|
const root = new MemoryDirectoryHandle('sd-card');
|
|
const firstHandle = new DirectoryHandleAlias(root);
|
|
const secondHandle = new DirectoryHandleAlias(root);
|
|
const previousNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
|
Object.defineProperty(globalThis, 'navigator', {
|
|
configurable:true,
|
|
value:{locks:new MemoryLockManager()},
|
|
});
|
|
try {
|
|
await Promise.all([
|
|
installMuiZip({archive, rootDirectory:firstHandle, metadata:{...metadata, packId:'tab-one', name:'Tab One'}}),
|
|
installMuiZip({archive, rootDirectory:secondHandle, metadata:{...metadata, packId:'tab-two', name:'Tab Two'}}),
|
|
]);
|
|
} finally {
|
|
if (previousNavigator) Object.defineProperty(globalThis, 'navigator', previousNavigator);
|
|
else delete globalThis.navigator;
|
|
}
|
|
const pyxis = root.children.get('pyxis-map');
|
|
const slots = ['active-pack.0', 'active-pack.1']
|
|
.map(name => pyxis.children.get(name))
|
|
.filter(Boolean)
|
|
.map(handle => decodeActiveSelection(handle.bytes));
|
|
const newest = slots.sort((left, right) => right.generation - left.generation)[0];
|
|
assert.equal(newest.generation, 2);
|
|
assert.deepEqual(new Set(newest.packs.map(pack => pack.packId)), new Set(['tab-one', 'tab-two']));
|
|
});
|
|
|
|
test('an empty directory created during destination creation is never removed', async () => {
|
|
const archive = storedZip([['2/1/1.png', PNG]]);
|
|
const root = new MemoryDirectoryHandle();
|
|
const pyxis = await root.getDirectoryHandle('pyxis-map', {create:true});
|
|
class EmptyRaceDirectory extends MemoryDirectoryHandle {
|
|
async getDirectoryHandle(name, options = {}) {
|
|
if (name === metadata.packId && options.create && !this.raced) {
|
|
this.raced = true;
|
|
const injected = new MemoryDirectoryHandle(name, this.log);
|
|
this.children.set(name, injected);
|
|
return injected;
|
|
}
|
|
return super.getDirectoryHandle(name, options);
|
|
}
|
|
}
|
|
const packs = new EmptyRaceDirectory('packs', root.log);
|
|
pyxis.children.set('packs', packs);
|
|
await assert.rejects(
|
|
installMuiZip({
|
|
archive,
|
|
rootDirectory:root,
|
|
metadata,
|
|
onProgress: progress => { if (progress.phase === 'write') throw new Error('forced interruption'); },
|
|
}),
|
|
/forced interruption/i,
|
|
);
|
|
const raced = packs.children.get(metadata.packId);
|
|
assert.ok(raced, 'concurrently created empty directory must remain');
|
|
assert.deepEqual([...raced.children.keys()], []);
|
|
});
|
|
|
|
test('destination-creation race preserves a pre-existing pack directory', async () => {
|
|
const archive = storedZip([['2/1/1.png', PNG]]);
|
|
const root = new MemoryDirectoryHandle();
|
|
const pyxis = await root.getDirectoryHandle('pyxis-map', {create:true});
|
|
class RacingPacksDirectory extends MemoryDirectoryHandle {
|
|
async getDirectoryHandle(name, options = {}) {
|
|
if (name === metadata.packId && options.create && !this.raced) {
|
|
this.raced = true;
|
|
const injected = new MemoryDirectoryHandle(name, this.log);
|
|
const manifest = new MemoryFileHandle('manifest.pmp', this.log);
|
|
manifest.bytes = new Uint8Array([9, 8, 7]);
|
|
injected.children.set('manifest.pmp', manifest);
|
|
this.children.set(name, injected);
|
|
return injected;
|
|
}
|
|
return super.getDirectoryHandle(name, options);
|
|
}
|
|
}
|
|
const packs = new RacingPacksDirectory('packs', root.log);
|
|
pyxis.children.set('packs', packs);
|
|
await assert.rejects(
|
|
installMuiZip({archive, rootDirectory:root, metadata}),
|
|
/destination.*changed|created concurrently|not empty/i,
|
|
);
|
|
const raced = packs.children.get(metadata.packId);
|
|
assert.deepEqual([...raced.children.get('manifest.pmp').bytes], [9, 8, 7]);
|
|
assert.equal(raced.children.has('tiles'), false);
|
|
});
|
|
|
|
test('map-set capacity is checked before a ninth pack is published', async () => {
|
|
const archive = storedZip([['2/1/1.png', PNG]]);
|
|
const root = new MemoryDirectoryHandle();
|
|
const pyxis = await root.getDirectoryHandle('pyxis-map', {create:true});
|
|
const slot = await pyxis.getFileHandle('active-pack.0', {create:true});
|
|
const writable = await slot.createWritable();
|
|
await writable.write(encodeActiveMapSet({generation:8,mapSetId:'osm-bright',attribution:metadata.attribution,packs:
|
|
Array.from({length:8}, (_,index) => ({packId:`pack-${index}`,rowSpans:[{zoom:2,y:1,xMinimum:1,xMaximum:1}]}))}));
|
|
await writable.close();
|
|
await assert.rejects(installMuiZip({archive,rootDirectory:root,metadata:{...metadata,packId:'pack-nine',name:'Pack Nine'}}), /active map set/i);
|
|
assert.equal(pyxis.children.has('packs'), false);
|
|
});
|