fix(web): recognize AppleDouble ZIP metadata

This commit is contained in:
Zinebyte committed 2026-10-16 11:49:31 -04:00
1 parent 4524944396
commit 3a0b032c59
3 files changed
+176 -9

No files matched your search

+6
View File
@@ -22,6 +22,12 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Test archive root selection
shell: bash
run: |
zip_url=$(python3 -c 'from pathlib import Path; import re; print(re.search(r"from \"(https://cdn.jsdelivr.net/[^\"]+)\"", Path("demo/web/archive.mjs").read_text(encoding="utf-8")).group(1))')
curl -fsSL "$zip_url" -o "$RUNNER_TEMP/zip.mjs"
ANYPS5_TEST_ZIP_URL="$zip_url" ANYPS5_TEST_ZIP_PATH="$(python3 -c 'from pathlib import Path; import sys; print(Path(sys.argv[1]).resolve().as_uri())' "$RUNNER_TEMP/zip.mjs")" node demo/web/tests/archive-root.test.mjs demo/web
- name: Check browser module syntax
run: |
node --check demo/web/app.mjs
+47 -8
View File
@@ -27,24 +27,45 @@ function safeSize(value) {
return value;
}
async function headerBytes(entry) {
if (entry.uncompressedSize < 4) return null;
async function headerBytes(entry, size = 4) {
if (entry.uncompressedSize < size) return null;
const stop = new AbortController();
const header = new Uint8Array(4);
const header = new Uint8Array(size);
let filled = 0;
try {
await entry.getData(new WritableStream({
write(chunk) {
const length = Math.min(4 - filled, chunk.length);
const length = Math.min(size - filled, chunk.length);
header.set(chunk.subarray(0, length), filled);
filled += length;
if (filled === 4) stop.abort();
if (filled === size) stop.abort();
}
}), { signal: stop.signal, checkSignature: false });
} catch (error) {
if (!stop.signal.aborted || filled !== 4) throw error;
if (!stop.signal.aborted || filled !== size) throw error;
}
return header;
return filled === size ? header : null;
}
async function isAppleDouble(entry) {
const header = await headerBytes(entry, 26);
const view = header && new DataView(header.buffer, header.byteOffset, header.byteLength);
if (!view || view.getUint32(0) !== 0x00051607 || view.getUint32(4) !== 0x00020000) return false;
const size = 26 + view.getUint16(24) * 12;
const table = size === 26 ? header : await headerBytes(entry, size);
if (!table) return false;
const descriptors = new DataView(table.buffer, table.byteOffset, table.byteLength);
const identifiers = new Set();
for (let position = 26; position < size; position += 12) {
const identifier = descriptors.getUint32(position);
const offset = descriptors.getUint32(position + 4);
const length = descriptors.getUint32(position + 8);
if (identifier === 0 || identifier === 1 || identifiers.has(identifier)
|| offset > entry.uncompressedSize || length > entry.uncompressedSize - offset
|| ((offset !== 0 || length !== 0) && offset < size)) return false;
identifiers.add(identifier);
}
return true;
}
const SELF_MAGICS = [
@@ -122,12 +143,30 @@ export class Archive {
this.entries.push(entry);
}
if (!this.entries.some(entry => !entry.directory)) throw new Error("No files were selected");
const directories = new Set();
for (const entry of this.entries) {
const parts = entry.filename.split("/");
for (let index = 1; index < parts.length; index++) {
if (paths.get(parts.slice(0, index).join("/").toLowerCase()) === false) throw new Error(`A file is also used as a folder: ${entry.filename}`);
const parent = parts.slice(0, index).join("/").toLowerCase();
if (paths.get(parent) === false) throw new Error(`A file is also used as a folder: ${entry.filename}`);
if (!entry.filename.toLowerCase().startsWith("__macosx/")) directories.add(parent);
}
}
const applicationPaths = new Map([...paths].filter(([name]) => name !== "__macosx" && !name.startsWith("__macosx/")));
const metadata = this.entries.filter(entry => entry.filename.startsWith("__MACOSX/"));
let appleDouble = metadata.some(entry => !entry.directory);
for (const entry of metadata) {
if (entry.directory) {
const companion = entry.filename.slice("__MACOSX/".length).replace(/\/$/u, "").toLowerCase();
if (companion && applicationPaths.get(companion) !== true && !directories.has(companion)) { appleDouble = false; break; }
continue;
}
const match = /^__MACOSX\/(.*\/)?\._([^/]+)$/u.exec(entry.filename);
const companion = match && ((match[1] ?? "") + match[2]).toLowerCase();
if (!companion || (!applicationPaths.has(companion) && !directories.has(companion)) || !await isAppleDouble(entry)) { appleDouble = false; break; }
}
if (appleDouble) this.entries = this.entries.filter(entry => !entry.filename.startsWith("__MACOSX/"));
if (!this.entries.some(entry => !entry.directory)) throw new Error("No files were selected");
let prefix = "";
const files = this.entries.filter(entry => !entry.directory);
while (files.every(entry => entry.filename.slice(prefix.length).includes("/"))) {
+123 -1
View File
@@ -10,10 +10,132 @@ const header = new Uint8Array([127, 69, 76, 70]);
async function zipped(entries) {
const writer = new ZipWriter(new BlobWriter(), { useWebWorkers: false, useCompressionStream: false });
for (const [name, directory] of entries) await writer.add(name, directory ? undefined : new Uint8ArrayReader(header), { directory });
for (const [name, directory, bytes = header, options = {}] of entries) await writer.add(name, directory ? undefined : new Uint8ArrayReader(bytes), { directory, ...options });
return writer.close();
}
const metadata = new Uint8Array(86);
const metadataView = new DataView(metadata.buffer);
metadataView.setUint32(0, 0x00051607);
metadataView.setUint32(4, 0x00020000);
metadataView.setUint16(24, 2);
metadataView.setUint32(26, 9);
metadataView.setUint32(30, 50);
metadataView.setUint32(34, 32);
metadataView.setUint32(38, 2);
metadataView.setUint32(42, 82);
metadataView.setUint32(46, 4);
const app = [["Title/main.elf", false], ["Title/sce_module/provider.prx", false]];
const sidecars = [["__MACOSX/Title/._main.elf", false, metadata], ["__MACOSX/Title/sce_module/._provider.prx", false, metadata]];
for (const directories of [[], ["Title/", "Title/sce_module/", "__MACOSX/", "__MACOSX/Title/", "__MACOSX/Title/sce_module/"]]) {
const archive = new Archive(await zipped([...directories.map(name => [name, true]), ...app, ...sidecars, ["__MACOSX/._Title", false, metadata]]));
try {
const summary = await archive.inspect(() => {});
assert.equal(summary.root, "Title/");
assert.equal(summary.files, 2);
assert.equal(summary.modules, 1);
assert.equal(summary.selectedBytes, 8);
assert.equal(summary.totalBytes, 8 + metadata.length * 3);
assert(!archive.files.some(item => item.name.includes("__MACOSX")));
for (const target of ["linux", "windows"]) {
const input = await archive.conversionInput(target, false, () => {});
assert.deepEqual(input.executable, header);
assert.equal(input.modules[0].name, "sce_module/provider.prx");
assert.deepEqual(input.modules[0].bytes, header);
}
} finally { await archive.close(); }
}
const wrongMagic = metadata.slice();
wrongMagic[3] = 0;
const wrongVersion = metadata.slice();
wrongVersion[5] = 1;
const oversizedTable = metadata.slice();
new DataView(oversizedTable.buffer).setUint16(24, 100);
const invalidDescriptors = [];
for (const [position, value] of [
[26, 0], [26, 1], [38, 9], [30, 0], [30, 49],
[30, 0xffffffff], [34, 0xffffffff], [42, 83], [42, 87]
]) {
const invalid = metadata.slice();
new DataView(invalid.buffer).setUint32(position, value);
invalidDescriptors.push(invalid);
}
const emptyBeforeTable = metadata.slice();
new DataView(emptyBeforeTable.buffer).setUint32(30, 25);
new DataView(emptyBeforeTable.buffer).setUint32(34, 0);
invalidDescriptors.push(emptyBeforeTable);
const emptyPastEnd = metadata.slice();
new DataView(emptyPastEnd.buffer).setUint32(42, 87);
new DataView(emptyPastEnd.buffer).setUint32(46, 0);
invalidDescriptors.push(emptyPastEnd);
for (const extra of [
[["__MACOSX/Title/._main.elf", false, wrongMagic]],
[["__MACOSX/Title/._main.elf", false, wrongVersion]],
[["__MACOSX/Title/._main.elf", false, metadata.subarray(0, 25)]],
[["__MACOSX/Title/._main.elf", false, oversizedTable]],
...invalidDescriptors.map(bytes => [["__MACOSX/Title/._main.elf", false, bytes]]),
[["__MACOSX/Title/._absent.elf", false, metadata]],
[...sidecars, ["__MACOSX/Title/readme", false, new Uint8Array([1])]],
[...sidecars, ["__MACOSX/unrelated/", true]],
[...sidecars, ["other/resource", false, new Uint8Array([1])]]
]) {
const archive = new Archive(await zipped([...app, ...extra]));
try { await assert.rejects(archive.inspect(() => {}), /exactly one executable ELF/); }
finally { await archive.close(); }
}
for (const [offset, length] of [[0, 0], [50, 0], [86, 0]]) {
const emptyEntry = metadata.slice();
new DataView(emptyEntry.buffer).setUint32(30, offset);
new DataView(emptyEntry.buffer).setUint32(34, length);
const archive = new Archive(await zipped([["main.elf", false], ["__MACOSX/._main.elf", false, emptyEntry]]));
try {
assert.equal((await archive.inspect(() => {})).files, 1);
assert.deepEqual(archive.files.map(item => item.name), ["main.elf"]);
} finally { await archive.close(); }
}
for (const bytes of [wrongMagic, wrongVersion, metadata.subarray(0, 25), oversizedTable, ...invalidDescriptors]) {
const name = "__MACOSX/._main.elf";
const archive = new Archive(await zipped([["main.elf", false], [name, false, bytes]]));
try {
assert.equal((await archive.inspect(() => {})).files, 2);
assert.deepEqual(archive.files.map(item => item.name), ["main.elf", name]);
} finally { await archive.close(); }
}
for (const unrelated of [
["__MACOSX/readme", false, new Uint8Array([1])], ["__MACOSX/unrelated/", true],
["__MACOSX/__MACOSX/._._main.elf", false, metadata], ["__MACOSX/__MACOSX/", true]
]) {
const archive = new Archive(await zipped([["main.elf", false], ["__MACOSX/._main.elf", false, metadata], unrelated]));
try {
await archive.inspect(() => {});
assert.deepEqual(archive.files.map(item => item.name), ["main.elf", "__MACOSX/._main.elf", unrelated[0]]);
} finally { await archive.close(); }
}
const knownDirectory = new Archive(await zipped([
["main.elf", false], ["assets/", true], ["__MACOSX/._main.elf", false, metadata], ["__MACOSX/assets/", true]
]));
try {
await knownDirectory.inspect(() => {});
assert.deepEqual(knownDirectory.files.map(item => item.name), ["main.elf", "assets/"]);
} finally { await knownDirectory.close(); }
for (const [entries, message] of [
[[...app, ["__MACOSX/../._main.elf", false, metadata]], /Unsafe ZIP path/],
[[...app, ...sidecars, ["__MACOSX/title/._MAIN.ELF", false, metadata]], /Duplicate ZIP path/],
[[...app, ["__MACOSX/Title/._main.elf", false, metadata, { password: "metadata-password" }]], /Encrypted ZIP entries/],
[[...app, ["__MACOSX/Title/._main.elf", false, metadata, { versionMadeBy: 0x31e, externalFileAttributes: (0o120777 << 16) >>> 0 }]], /Links and special files/],
[[["Title/", true], ["__MACOSX/._Title", false, metadata]], /No files were selected/]
]) {
const archive = new Archive(await zipped(entries));
try { await assert.rejects(archive.inspect(() => {}), message); }
finally { await archive.close(); }
}
const parents = ["outer/", "outer/inner/", "outer/inner/title/"];
for (const directories of [[], parents, [...parents].reverse()]) {
const archive = new Archive(await zipped([