Files
AnyPS5/demo/web/tests/archive.test.mjs
T

278 lines
16 KiB
JavaScript

import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import path from "node:path";
import { register } from "node:module";
register("./cdn-loader.mjs", import.meta.url, { data: { url: process.env.ANYPS5_TEST_ZIP_URL, path: process.env.ANYPS5_TEST_ZIP_PATH } });
const site = name => pathToFileURL(path.join(process.argv[2], name)).href;
const { Archive, memoryEstimate, MEMORY_LIMIT, ICON_LIMIT, packageArchive } = await import(site("archive.mjs"));
const { BlobReader, BlobWriter, Uint8ArrayReader, Uint8ArrayWriter, ZipReader, ZipWriter } = await import(process.env.ANYPS5_TEST_ZIP_URL);
await import("./archive-reserved.test.mjs");
const { default: createRelinker } = await import(site("native/relinker_wasm.mjs"));
const native = await createRelinker();
const executable = new Uint8Array(await readFile(path.join(process.argv[3], "main.elf")));
const module = new Uint8Array(await readFile(path.join(process.argv[3], "module.elf")));
const resource = new Uint8Array(2 * 1024 * 1024).fill(65);
async function zip(entries) {
const output = new BlobWriter("application/zip");
const writer = new ZipWriter(output, { zip64: true, level: 9, useWebWorkers: false, useCompressionStream: false });
for (const [name, bytes, options] of entries) await writer.add(name, bytes ? new Uint8ArrayReader(bytes) : undefined, options);
return writer.close();
}
const source = await zip([["title/", null, { directory: true }], ["title/program.dat", executable], ["title/sce_module/library.prx.esbak", module], ["title/sce_module/library.prx", new Uint8Array([79, 21, 61, 29])], ["title/sce_module/library.prx.esbak.guest.prx", new Uint8Array([77, 90, 0, 0])], ["title/sce_sys/param.json", new TextEncoder().encode("{}")], ["title/shaders/example.frag", executable], ["title/content/resource.bin", resource]]);
const folder = [
{ path: "title/program.dat", file: new Blob([executable]) },
{ path: "title/sce_module/library.prx.esbak", file: new Blob([module]) },
{ path: "title/sce_module/library.prx", file: new Blob([new Uint8Array([79, 21, 61, 29])]) },
{ path: "title/sce_module/library.prx.esbak.guest.prx", file: new Blob([new Uint8Array([77, 90, 0, 0])]) },
{ path: "title/sce_sys/param.json", file: new Blob(["{}"]) },
{ path: "title/shaders/example.frag", file: new Blob([executable]) },
{ path: "title/content/resource.bin", file: new Blob([resource]) }
];
for (const [kind, selected] of [["ZIP", source], ["folder", folder]]) {
for (const target of ["windows", "linux"]) {
const archive = new Archive(selected);
const summary = await archive.inspect(() => {});
assert.equal(summary.name, "program.dat");
assert.equal(summary.root, "title/");
assert.equal(summary.modules, 1);
assert.equal(summary.selectedBytes, executable.length + module.length);
const input = await archive.conversionInput(target, false, () => {});
assert.equal(input.modules.length, 1);
assert.deepEqual(input.executable, executable);
assert.equal(input.modules[0].name, "sce_module/library.prx.esbak");
const events = [];
const converted = native.Convert(input, event => events.push(event));
assert.equal(converted.ok, true, converted.error);
assert.equal(converted.executable[0], target === "windows" ? 77 : 127);
if (target === "windows") {
const image = new DataView(converted.executable.buffer, converted.executable.byteOffset, converted.executable.byteLength);
assert.equal(image.getUint16(image.getUint32(60, true) + 24 + 68, true), 3);
}
assert(events.some(event => event.stage === 6 && event.completed > 0));
const libraries = await zip([["libs/system.prx", new Uint8Array([1, 2, 3])]]);
const chunks = [];
let maxChunk = 0;
const destination = new WritableStream({ async write(chunk) {
maxChunk = Math.max(maxChunk, chunk.length);
chunks.push(chunk.slice());
await new Promise(resolve => setTimeout(resolve, 1));
} });
await packageArchive(archive, converted, input.outputName, libraries, destination, () => {});
await destination.close();
const reader = new ZipReader(new BlobReader(new Blob(chunks)));
const entries = await reader.getEntries();
const byName = new Map(entries.map(entry => [entry.filename, entry]));
assert(byName.has(input.outputName));
assert(byName.has("libs/system.prx"));
assert.deepEqual(await byName.get("app0/shaders/example.frag").getData(new Uint8ArrayWriter(), { checkSignature: true }), executable);
assert.deepEqual(await byName.get("app0/sce_module/library.prx.esbak.guest.prx").getData(new Uint8ArrayWriter(), { checkSignature: true }), converted.modules[0].bytes);
assert.deepEqual(await byName.get("app0/sce_module/library.prx").getData(new Uint8ArrayWriter(), { checkSignature: true }), new Uint8Array([79, 21, 61, 29]));
assert.deepEqual(await byName.get("app0/program.dat").getData(new Uint8ArrayWriter(), { checkSignature: true }), executable);
assert.deepEqual(await byName.get("app0/content/resource.bin").getData(new Uint8ArrayWriter(), { checkSignature: true }), resource);
assert.equal((byName.get(input.outputName).externalFileAttributes >>> 16) & 0o777, 0o755);
assert(maxChunk < resource.length);
const saved = new Map();
let active = false;
let directoryChunk = 0;
await packageArchive(archive, converted, input.outputName, libraries, {
async directory(name) { saved.set(name, null); },
async file(name) {
assert.equal(active, false);
active = true;
const pieces = [];
return new WritableStream({
async write(chunk) {
directoryChunk = Math.max(directoryChunk, chunk.length);
pieces.push(chunk.slice());
await new Promise(resolve => setTimeout(resolve, 1));
},
async close() {
saved.set(name, new Uint8Array(await new Blob(pieces).arrayBuffer()));
active = false;
}
});
}
}, () => {}, true);
assert.equal(active, false);
assert(directoryChunk < resource.length);
assert.equal(saved.size, entries.length);
for (const entry of entries) {
assert(saved.has(entry.filename));
if (!entry.directory) assert.deepEqual(saved.get(entry.filename), await entry.getData(new Uint8ArrayWriter(), { checkSignature: true }));
}
await reader.close();
await archive.close();
console.log(`${kind}, ${target}: conversion, original files, modules, permissions, streamed ZIP and directory output verified`);
}
}
const bundleArchive = new Archive(source);
await bundleArchive.inspect(() => {});
const bundleInput = await bundleArchive.conversionInput("linux", false, () => {});
const bundleConversion = native.Convert(bundleInput, () => {});
assert.equal(bundleConversion.ok, true, bundleConversion.error);
const bundleLibraries = await zip([["libs/system.prx", new Uint8Array([1, 2, 3])]]);
const bundleWriter = new ZipWriter(new BlobWriter("application/zip"), { zip64: true, level: 9, useWebWorkers: false, useCompressionStream: false });
await packageArchive(bundleArchive, bundleConversion, bundleInput.outputName, bundleLibraries, null, () => {}, false, { prefix: "01-first/", writer: bundleWriter });
await packageArchive(bundleArchive, bundleConversion, bundleInput.outputName, bundleLibraries, null, () => {}, false, { prefix: "02-second/", writer: bundleWriter });
const bundle = await bundleWriter.close();
const bundleReader = new ZipReader(new BlobReader(bundle));
const bundleEntries = await bundleReader.getEntries();
const bundleNames = new Set(bundleEntries.map(entry => entry.filename));
assert(bundleNames.has(`01-first/${bundleInput.outputName}`));
assert(bundleNames.has("01-first/libs/system.prx"));
assert(bundleNames.has("01-first/app0/content/resource.bin"));
assert(bundleNames.has(`02-second/${bundleInput.outputName}`));
assert(bundleNames.has("02-second/libs/system.prx"));
assert(bundleNames.has("02-second/app0/content/resource.bin"));
await bundleReader.close();
await bundleArchive.close();
console.log("Multiple app packages append under separate roots in a single ZIP");
for (const [entries, error] of [
[[["one", executable], ["two", executable]], /exactly one/u],
[[["readme", new Uint8Array([1])]], /exactly one/u],
[[["eboot.bin", new Uint8Array([79, 21, 61, 29])]], /SELF container/u],
[[["eboot.bin", new Uint8Array([84, 20, 245, 238])]], /SELF container/u],
[[["app", executable], ["..\/escape", resource]], /Unsafe ZIP/u],
[[["app", executable], ["APP", resource]], /Duplicate ZIP/u],
[[["app", executable], ["folder", resource], ["folder/file", resource]], /also used as a folder/u],
[[["app", executable], ["link", resource, { versionMadeBy: 0x31e, externalFileAttributes: (0o120777 << 16) >>> 0 }]], /Links and special/u]
]) {
const archive = new Archive(await zip(entries));
await assert.rejects(archive.inspect(() => {}), error);
await archive.close();
}
const missing = new Archive(await zip([["any-name", executable]]));
assert.equal((await missing.inspect(() => {})).warnings.length, 2);
await missing.close();
const selfModule = new Archive(await zip([["program.dat", executable],["sce_module/library.prx", new Uint8Array([84, 20, 245, 238])]]));
const selfModuleSummary = await selfModule.inspect(() => {});
assert.equal(selfModuleSummary.warnings.length, 2);
assert.match(selfModuleSummary.warnings.join(" "), /SELF container/u);
await selfModule.close();
assert(memoryEstimate(200 * 1024 ** 3, 1024, 1024, 100) > MEMORY_LIMIT);
const invalid = native.Convert({ target: "unsupported" }, () => {});
assert.equal(invalid.ok, false);
console.log("Archive validation, missing-folder warnings and large-download budget verified");
const iconBytes = new Uint8Array(1024 * 1024);
const iconArchive = new Archive([
{ path: "title/program.elf", file: new Blob([new Uint8Array([0x7f, 69, 76, 70])]) },
{ path: "title/sce_sys/icon0.png", file: new Blob([iconBytes]) }
]);
const iconSummary = await iconArchive.inspect(() => {});
assert.equal(iconSummary.selectedBytes, 4);
assert.equal(iconSummary.windowsSelectedBytes, iconBytes.length + 4);
assert.equal((await iconArchive.conversionInput("linux", false, () => {})).icon, null);
assert.equal((await iconArchive.conversionInput("windows", false, () => {})).icon.length, iconBytes.length);
const librariesAtBudgetEdge = MEMORY_LIMIT
- memoryEstimate(iconSummary.totalBytes, iconSummary.selectedBytes, 0, iconSummary.files)
- iconBytes.length * 2;
assert(memoryEstimate(iconSummary.totalBytes, iconSummary.selectedBytes, librariesAtBudgetEdge, iconSummary.files) < MEMORY_LIMIT);
assert(memoryEstimate(iconSummary.totalBytes, iconSummary.windowsSelectedBytes, librariesAtBudgetEdge, iconSummary.files) > MEMORY_LIMIT);
await iconArchive.close();
console.log("Linux conversion input and memory estimate exclude the Windows-only icon");
const oversizedIconArchive = new Archive([
{ path: "title/program.elf", file: new Blob([new Uint8Array([0x7f, 69, 76, 70])]) },
{ path: "title/sce_sys/icon0.png", file: new Blob([new Uint8Array(ICON_LIMIT + 1)]) }
]);
const oversizedIconSummary = await oversizedIconArchive.inspect(() => {});
assert.equal(oversizedIconSummary.windowsIconTooLarge, true);
assert.equal(oversizedIconArchive.selectedBytesForTarget("linux"), 4);
assert.throws(() => oversizedIconArchive.selectedBytesForTarget("windows"), /16 MiB/u);
assert.equal((await oversizedIconArchive.conversionInput("linux", false, () => {})).icon, null);
await oversizedIconArchive.close();
console.log("Oversized Windows-only icons do not block Linux conversion");
const workerMessages = [];
globalThis.self = { postMessage: message => workerMessages.push(message) };
const worker = await import(site("worker.mjs"));
const libraryArchive = await zip([["libs/system.prx", new Uint8Array([1, 2, 3])]]);
const libraryBytes = new Uint8Array(await libraryArchive.arrayBuffer());
const libraryPath = "libraries/windows-acde1234.zip";
const librarySha256 = [...new Uint8Array(await crypto.subtle.digest("SHA-256", libraryBytes))]
.map(byte => byte.toString(16).padStart(2, "0"))
.join("");
const release = {
tag: "v1",
platforms: {
windows: {
path: libraryPath,
size: libraryBytes.byteLength,
expandedBytes: 3,
sha256: librarySha256
}
}
};
const originalFetch = globalThis.fetch;
let latestResponse = new Response(null, { status: 403, headers: { "X-RateLimit-Remaining": "0" } });
globalThis.fetch = async url => {
if (url === "./release.json") return new Response(JSON.stringify(release));
if (url === "https://api.github.com/repos/boykopovar/AnyPS5/releases/latest") return latestResponse;
if (url === libraryPath) return new Response(libraryBytes);
throw new Error(`Unexpected fetch: ${url}`);
};
try {
const downloaded = await worker.downloadLibraries("windows");
assert.equal(downloaded.release.tag, release.tag);
assert.deepEqual(new Uint8Array(await downloaded.file.arrayBuffer()), libraryBytes);
assert(workerMessages.some(message => message.type === "progress" && /rate limit reached/u.test(message.message ?? "")));
latestResponse = new Response(null, { status: 429 });
const rateLimitedDownload = await worker.downloadLibraries("windows");
assert.deepEqual(new Uint8Array(await rateLimitedDownload.file.arrayBuffer()), libraryBytes);
latestResponse = new Response(null, { status: 403 });
await assert.rejects(worker.downloadLibraries("windows"), /Cannot verify the latest release \(403\)/u);
latestResponse = new Response(JSON.stringify({ tag_name: "v2" }));
await assert.rejects(worker.downloadLibraries("windows"), /A new release is being published/u);
latestResponse = new Response(null, { status: 429 });
await self.onmessage({ data: { type: "inspect", inputs: [
{ name: "First App.zip", file: source },
{ name: "First App.zip", file: source }
] } });
assert.equal(workerMessages.filter(message => message.type === "inspected").length, 2);
assert(workerMessages.some(message => message.type === "inspection-complete" && message.count === 2));
await self.onmessage({ data: { type: "convert", target: "windows", toIntel: false, mode: "memory" } });
const batchComplete = workerMessages.find(message => message.type === "complete");
assert(batchComplete, workerMessages.find(message => message.type === "error")?.message);
assert.equal(batchComplete.apps, 2);
const batchReader = new ZipReader(new BlobReader(batchComplete.blob));
const batchNames = new Set((await batchReader.getEntries()).map(entry => entry.filename));
assert(batchNames.has("01-First-App/program.exe"));
assert(batchNames.has("01-First-App/libs/system.prx"));
assert(batchNames.has("01-First-App/app0/content/resource.bin"));
assert(batchNames.has("02-First-App/program.exe"));
assert(batchNames.has("02-First-App/libs/system.prx"));
assert(batchNames.has("02-First-App/app0/content/resource.bin"));
await batchReader.close();
workerMessages.length = 0;
} finally {
globalThis.fetch = originalFetch;
workerMessages.length = 0;
}
await self.onmessage({ data: { type: "convert", target: "windows", mode: "disk" } });
assert.equal(workerMessages.length, 1);
assert.equal(workerMessages[0].type, "error");
assert.match(workerMessages[0].message, /browsers block DLL creation/u);
workerMessages.length = 0;
await self.onmessage({ data: { type: "inspect", inputs: [
{ name: "valid.zip", file: source },
{ name: "invalid.zip", file: new Blob(["not a ZIP"]) }
] } });
assert.equal(workerMessages.at(-1).type, "error");
assert.match(workerMessages.at(-1).message, /invalid\.zip/u);
assert.equal(workerMessages.some(message => message.type === "inspection-complete"), false);
delete globalThis.self;
console.log("Rate-limited release checks use the verified site manifest; other release-check failures remain errors");
console.log("Windows folder output rejected before conversion or file writes");
console.log("Multiple ZIP inputs stream into separate app roots; invalid inputs name the failing archive");