mirror of
https://github.com/boykopovar/AnyPS5.git
synced 2026-10-18 23:07:07 -05:00
fix(web): recognize AppleDouble ZIP metadata
This commit is contained in:
1 parent
4524944396
commit
3a0b032c59
3 files changed
+176
-9
No files matched your search
@@ -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
@@ -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("/"))) {
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in new issue
Block a user