diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index e2b5aa9186..41602ab6fe 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -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 diff --git a/demo/web/archive.mjs b/demo/web/archive.mjs index f1a30d2e42..82b00bed6f 100644 --- a/demo/web/archive.mjs +++ b/demo/web/archive.mjs @@ -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("/"))) { diff --git a/demo/web/tests/archive-root.test.mjs b/demo/web/tests/archive-root.test.mjs index deba9ee898..8a77539c39 100644 --- a/demo/web/tests/archive-root.test.mjs +++ b/demo/web/tests/archive-root.test.mjs @@ -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([