AnyPS5/demo/web/archive.mjs

239 lines
12 KiB
JavaScript

import { BlobReader, Uint8ArrayReader, ZipReader, ZipWriter, configure } from "https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.57/lib/zip-no-worker.js/+esm";
configure({ useWebWorkers: false, useCompressionStream: false, chunkSize: 65536 });
export const MEMORY_LIMIT = 256 * 1024 * 1024;
export const CONVERSION_LIMIT = 256 * 1024 * 1024;
export const ICON_LIMIT = 16 * 1024 * 1024;
const MAX_ENTRIES = 100000;
class BoundedBlobReader extends BlobReader {
async readUint8Array(offset, length) {
if (length > 16 * 1024 * 1024) throw new Error("ZIP directory metadata exceeds 16 MiB");
return super.readUint8Array(offset, length);
}
}
export function validPath(name) {
if (!name || name.includes("\\") || /[\x00-\x1f<>:"|?*]/u.test(name)) throw new Error(`Unsafe ZIP path: ${name}`);
const parts = name.replace(/\/$/u, "").split("/");
if (parts.some(part => !part || part === "." || part === ".." || /[. ]$/u.test(part) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(part))) throw new Error(`Unsafe ZIP path: ${name}`);
return name;
}
function safeSize(value) {
if (!Number.isSafeInteger(value) || value < 0) throw new Error("ZIP contains an unsupported file size");
return value;
}
async function signature(entry) {
if (entry.uncompressedSize < 4) return false;
const stop = new AbortController();
const header = new Uint8Array(4);
let filled = 0;
try {
await entry.getData(new WritableStream({
write(chunk) {
const length = Math.min(4 - filled, chunk.length);
header.set(chunk.subarray(0, length), filled);
filled += length;
if (filled === 4) stop.abort();
}
}), { signal: stop.signal, checkSignature: false });
} catch (error) {
if (!stop.signal.aborted || filled !== 4) throw error;
}
return header[0] === 0x7f && header[1] === 69 && header[2] === 76 && header[3] === 70;
}
async function boundedBytes(entry, limit) {
if (entry.uncompressedSize > limit) throw new Error(`${entry.filename} exceeds the conversion memory limit`);
let size = 0;
const chunks = [];
await entry.getData(new WritableStream({
write(chunk) {
size += chunk.byteLength;
if (size > limit || size > entry.uncompressedSize) throw new Error(`Unexpected expanded size: ${entry.filename}`);
chunks.push(chunk);
}
}), { checkSignature: true });
if (size !== entry.uncompressedSize) throw new Error(`Incorrect expanded size: ${entry.filename}`);
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
return bytes;
}
class FolderReader {
constructor(files) {
this.files = files;
}
async *getEntriesGenerator() {
for (const { path, file } of this.files) {
if (!(file instanceof Blob)) throw new Error(`Invalid folder file: ${path}`);
yield {
filename: path, uncompressedSize: file.size, directory: false, compressionMethod: 0,
externalFileAttributes: (0o100644 << 16) >>> 0,
getData(destination, options = {}) {
return file.stream().pipeTo(destination, { signal: options.signal });
}
};
}
}
async close() {}
}
export class Archive {
constructor(file) {
this.reader = Array.isArray(file) ? new FolderReader(file) : new ZipReader(new BoundedBlobReader(file));
this.entries = [];
}
async inspect(report) {
const paths = new Map();
let total = 0;
for await (const entry of this.reader.getEntriesGenerator()) {
if (this.entries.length >= MAX_ENTRIES) throw new Error("Input contains more than 100,000 entries");
validPath(entry.filename);
const key = entry.filename.replace(/\/$/u, "").toLowerCase();
if (paths.has(key)) throw new Error(`Duplicate ZIP path: ${entry.filename}`);
if (entry.encrypted) throw new Error(`Encrypted ZIP entries are not supported: ${entry.filename}`);
if (!entry.directory && entry.compressionMethod !== 0 && entry.compressionMethod !== 8) throw new Error(`Unsupported ZIP compression: ${entry.filename}`);
const mode = (entry.externalFileAttributes >>> 16) & 0xf000;
if (mode && mode !== 0x8000 && mode !== 0x4000) throw new Error(`Links and special files are not supported: ${entry.filename}`);
paths.set(key, entry.directory);
total = safeSize(total + safeSize(entry.uncompressedSize));
this.entries.push(entry);
}
if (!this.entries.some(entry => !entry.directory)) throw new Error("No files were selected");
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}`);
}
}
let prefix = "";
const files = this.entries.filter(entry => !entry.directory);
while (files.every(entry => entry.filename.slice(prefix.length).includes("/"))) {
const first = files[0].filename.slice(prefix.length).split("/")[0];
if (["sce_sys", "sce_module"].includes(first) || !this.entries.every(entry => entry.filename === prefix || entry.filename.startsWith(prefix + first + "/"))) break;
prefix += first + "/";
}
this.prefix = prefix;
this.files = this.entries.filter(entry => entry.filename.startsWith(prefix) && entry.filename !== prefix).map(entry => ({ entry, name: entry.filename.slice(prefix.length) }));
const executables = [];
this.modules = [];
for (let index = 0; index < this.files.length; index++) {
const item = this.files[index];
report({ phase: "Inspecting files", input: item.name, completed: index, total: this.files.length });
if (item.entry.directory || item.name.startsWith("sce_sys/")) continue;
if (!item.name.startsWith("sce_module/") && item.name.includes("/")) continue;
if (item.name.startsWith("sce_module/") && item.name.endsWith(".guest.prx")) continue;
const elf = await signature(item.entry);
if (item.name.startsWith("sce_module/")) {
if (elf) this.modules.push(item);
} else if (elf) executables.push(item);
}
if (executables.length !== 1) throw new Error(`Expected exactly one executable ELF in the app root; found ${executables.length}`);
this.executable = executables[0];
if (this.executable.name.includes("/")) throw new Error("Place the executable next to sce_sys and sce_module, not inside another folder");
this.icon = this.files.find(item => item.name === "sce_sys/icon0.png" && !item.entry.directory);
if (this.icon && this.icon.entry.uncompressedSize > ICON_LIMIT) throw new Error("sce_sys/icon0.png exceeds 16 MiB");
this.selectedBytes = [this.executable, ...this.modules, ...(this.icon ? [this.icon] : [])].reduce((sum, item) => sum + item.entry.uncompressedSize, 0);
if (this.selectedBytes > CONVERSION_LIMIT) throw new Error("Executable, modules and icon exceed the 256 MiB conversion limit. Use the native relinker for this app.");
const warnings = [];
if (!this.files.some(item => item.name.startsWith("sce_sys/"))) warnings.push("sce_sys is missing. The Windows executable will have no app icon.");
else if (!this.icon) warnings.push("sce_sys/icon0.png is missing. The Windows executable will have no app icon.");
if (!this.files.some(item => item.name.startsWith("sce_module/"))) warnings.push("sce_module is missing. If the original app included this folder, leaving it out can prevent the app from starting.");
this.totalBytes = total;
return { name: this.executable.name, files: files.length, modules: this.modules.length, totalBytes: total, selectedBytes: this.selectedBytes, root: prefix, warnings };
}
async conversionInput(target, toIntel, report) {
let remaining = CONVERSION_LIMIT;
const read = async item => {
report({ phase: "Reading conversion input", input: item.name });
const bytes = await boundedBytes(item.entry, remaining);
remaining -= bytes.length;
return bytes;
};
const name = this.executable.name;
const stem = name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : name;
const outputName = (stem || "app") + (target === "windows" ? ".exe" : ".elf");
const input = { name, outputName, target, toIntel, executable: await read(this.executable), modules: [], icon: null };
for (const item of this.modules) input.modules.push({ name: item.name, bytes: await read(item) });
if (this.icon && target === "windows") input.icon = await read(this.icon);
return input;
}
async close() { await this.reader.close(); }
}
export function memoryEstimate(archiveBytes, selectedBytes, libraryBytes, entries) {
return safeSize(Math.ceil(archiveBytes * 1.02) + selectedBytes * 4 + libraryBytes + entries * 1024 + 8 * 1024 * 1024);
}
async function appendEntry(writer, name, entry, report) {
const stream = new TransformStream();
let produced = 0;
const limit = new TransformStream({ transform(chunk, controller) {
produced += chunk.length;
if (produced > entry.uncompressedSize) throw new Error(`Unexpected expanded size: ${name}`);
controller.enqueue(chunk);
}, flush() {
if (produced !== entry.uncompressedSize) throw new Error(`Incorrect expanded size: ${name}`);
} });
await Promise.all([
entry.getData(stream.writable, { checkSignature: true }),
writer.add(name, stream.readable.pipeThrough(limit), {
versionMadeBy: 0x31e, externalFileAttributes: entry.externalFileAttributes,
onprogress: completed => report({ phase: "Compressing ZIP", input: name, completed, total: entry.uncompressedSize })
})
]);
}
export async function packageArchive(archive, converted, outputName, libraryFile, destination, report) {
const libraries = new ZipReader(new BoundedBlobReader(libraryFile));
const writer = new ZipWriter(destination, { level: 9, zip64: true, bufferedWrite: false, useWebWorkers: false, useCompressionStream: false, preventClose: true });
const names = new Set();
const claim = name => {
validPath(name);
const key = name.replace(/\/$/u, "").toLowerCase();
if (names.has(key)) throw new Error(`Output path collision: ${name}`);
names.add(key);
};
try {
const libraryEntries = await libraries.getEntries();
if (!libraryEntries.length || libraryEntries.length > 4096) throw new Error("Invalid library archive");
for (const entry of libraryEntries) {
if (!entry.filename.startsWith("libs/") || entry.encrypted || ((entry.externalFileAttributes >>> 16) & 0xf000) === 0xa000) throw new Error("Invalid library archive layout");
claim(entry.filename);
}
const replaced = new Set(converted.modules.map(module => module.name));
const originalFiles = archive.files.filter(item => !replaced.has("app0/" + item.name));
for (const item of originalFiles) claim("app0/" + item.name);
claim(outputName);
for (const module of converted.modules) {
if (!module.name.startsWith("app0/sce_module/")) throw new Error("Invalid converted module path");
claim(module.name);
}
const binary = { versionMadeBy: 0x31e, externalFileAttributes: (0o100755 << 16) >>> 0 };
await writer.add(outputName, new Uint8ArrayReader(converted.executable), binary);
for (const module of converted.modules) await writer.add(module.name, new Uint8ArrayReader(module.bytes), binary);
for (const entry of libraryEntries) {
if (entry.directory) await writer.add(entry.filename, undefined, { directory: true });
else await appendEntry(writer, entry.filename, entry, report);
}
for (const { entry, name } of originalFiles) {
if (entry.directory) await writer.add("app0/" + name, undefined, { directory: true });
else await appendEntry(writer, "app0/" + name, entry, report);
}
await writer.close();
} finally {
await libraries.close();
}
}