mirror of
https://github.com/boykopovar/AnyPS5.git
synced 2026-10-30 12:25:49 +00:00
278 lines
16 KiB
JavaScript
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");
|