/** * 7-Zip's command-line program (7zz), run once per request in a fresh * instance, as from a shell: list an archive, or extract chosen files from * it. Used by worker.mjs in the browser and by the site's tests in Node. * * The archive is given either as a Blob (in a worker: mounted read-only with * WORKERFS, so a multi-GB disk image is read only where 7-Zip looks) or as * bytes (written to the in-memory file system). What is extracted is written * to memory and read back. * * `type` forces the archive type (7-Zip's -t switch), as 'Compound' does to * see an MSI's streams instead of the cabinet inside it. `inner` names a file * inside the archive to extract first and then work on, as for one of the * cabinets in an MSI holding several: that takes two instances, because each * exits when 7zz does. `partition` picks which partition of a disk with * several to open (a patch to 7-Zip's GPT and MBR readers, which otherwise * list the partitions as files). * * Written for johnmcm.com (2026); part of the module published at /source/7zip/. */ const BASE_ARGS = ['-bsp0', '-bb0', '-scsUTF-8', '-sccUTF-8', '-y', // A password is never typed in: an encrypted file fails to extract rather than waiting for one. '-pjohnmcm-no-password']; /** The archive's file name inside 7-Zip: its own extension kept, which 7-Zip uses to pick a format first. */ function safeName(name) { const ext = /\.[A-Za-z0-9]{1,8}$/.exec(name || '')?.[0] ?? ''; return 'archive' + ext.toLowerCase(); } /** * One run of 7zz with the archive at /in/. Resolves * { code, out: string[], err: string[], FS } where FS still holds what was written. */ async function exec(create, input, args, options, extra = {}) { const out = []; const err = []; const m = await create({ print: (l) => out.push(l), printErr: (l) => { if (!/unsupported syscall/i.test(l)) err.push(l); }, ...options, // Set before 7zz's C runtime starts, which is when it reads its environment. preRun: [(mod) => { if (input.partition != null) mod.ENV.JOHNMCM_PARTITION = String(input.partition); }], }); const name = safeName(input.name); m.FS.mkdir('/out'); // The archive sits a few folders down, so files it names by a relative path // ("..\Base\disk.vhdx", ".\disk-s001.vmdk") can be put where it will look. const home = placeFiles(m, { path: name, blob: input.blob, bytes: input.bytes }, input.others ?? []); for (const [path, data] of Object.entries(extra)) m.FS.writeFile(path, data); const full = args.map((a) => (a === '$ARCHIVE' ? `${home}/${name}` : a)); let code = 0; try { code = m.callMain(full) ?? 0; } catch (e) { if (typeof e?.status === 'number') code = e.status; else throw e; } return { code, out, err, FS: m.FS }; } /** The folder the archive is put in: deep enough for "..\..\" in a parent disk's path. */ const HOME = '/in/a/b/c'; /** * Puts the archive at HOME and each other file (a differencing disk's * parents, a VMDK's extent files) at its path relative to it, given with * either slash (`paths`: every name it may be asked for; `path`: one). A path that would leave /in, or has nothing but * dots, goes beside the archive under its last part. Returns HOME. * Blobs share one read-only mount and are linked into place. */ function placeFiles(m, main, others) { const byDir = new Map(); const put = (dir, file) => { if (!byDir.has(dir)) byDir.set(dir, []); byDir.get(dir).push(file); }; put(HOME, main); for (const o of others) { // Every name the archive may ask for this file by: its relative path, its absolute // path, its bare name. Readers that pass a Windows path on untouched ("..\\x.vhdx") // find it under that literal name beside the archive; the others look the path up. const names = [...new Set((o.paths ?? [o.path]).filter(Boolean).map(String))]; for (const name of names) { if (name.includes('\\')) put(HOME, { ...o, path: name }); const parts = HOME.split('/').filter(Boolean); const rel = name.replace(/\\/g, '/').split('/').filter((p) => p && p !== '.'); // A drive letter ("C:") starts an absolute path: only its last part is used. let ok = rel.length > 0 && !/^[A-Za-z]:$/.test(rel[0]); for (const p of rel.slice(0, -1)) { if (!ok) break; if (p === '..') { if (parts.length <= 1) ok = false; else parts.pop(); } else parts.push(p); } const base = rel[rel.length - 1]; if (ok && base && base !== '..') put('/' + parts.join('/'), { ...o, path: base }); else if (base && base !== '..') put(HOME, { ...o, path: base }); } } const mkdirs = (dir) => { let at = ''; for (const p of dir.split('/').filter(Boolean)) { at += '/' + p; try { m.FS.mkdir(at); } catch { // Already there. } } }; // Every blob in one read-only mount (WORKERFS reads a multi-GB disk only where 7-Zip looks), each // linked from where 7-Zip will look for it; bytes are written in place. const blobs = []; for (const [dir, files] of byDir) { mkdirs(dir); // The same name twice in one folder: the first one given wins. const seen = new Set(); for (const f of files) { if (seen.has(f.path)) continue; seen.add(f.path); if (f.blob) { const id = `f${blobs.length}`; blobs.push({ name: id, data: f.blob, link: `${dir}/${f.path}` }); } else { m.FS.writeFile(`${dir}/${f.path}`, f.bytes); } } } if (blobs.length) { m.FS.mkdir('/blobs'); m.FS.mount(m.WORKERFS, { blobs: blobs.map((b) => ({ name: b.name, data: b.data })) }, '/blobs'); for (const b of blobs) m.FS.symlink(`/blobs/${b.name}`, b.link); } return HOME; } /** The first line 7-Zip printed that explains a failure, if any. */ function problem(err, out) { const lines = [...err, ...out].map((l) => l.trim()).filter(Boolean); return lines.find((l) => /^ERROR:|Can ?not open|Unsupported|Is not archive|Wrong password|Data Error|Headers Error|Unexpected end/i.test(l)) ?? lines.find((l) => /error/i.test(l)) ?? ''; } function typeArgs(type) { return type ? [`-t${type}`] : []; } /** 7-Zip's NTFS reader lists files deleted but still recorded on the disk when asked (its "ld" property). */ function deletedArgs(op) { return op?.deleted ? ['-mld=on'] : []; } /** The bytes of one file inside the archive, for `inner`. */ async function extractOne(create, input, type, path, options) { const files = await extractFrom(create, input, type, [path], options); const f = files[0]; if (!f?.data) throw new Error(f?.error || `${path} could not be extracted`); return { name: path.split('/').pop(), bytes: f.data }; } async function resolve(create, input, op, options) { const withPartition = (i) => (op.partition != null ? { ...i, partition: op.partition } : i); if (!op.inner) return { input: withPartition(input), type: op.type }; return { input: withPartition(await extractOne(create, input, op.type, op.inner, options)), type: '' }; } /** * Every item in the archive: { path, size, folder, encrypted, link, stream, fileSystem, modified, deleted }. * `op.deleted` asks for deleted files too (NTFS). * Rejects with what 7-Zip said when it cannot open the archive at all. */ export async function list(create, input, op = {}, options = {}) { const r = await resolve(create, input, op, options); const { code, out, err } = await exec(create, r.input, ['l', '-slt', '-ba', ...BASE_ARGS, ...typeArgs(r.type), ...deletedArgs(op), '$ARCHIVE'], options); const entries = []; let cur = null; const flush = () => { if (cur && cur.Path != null) { entries.push({ path: cur.Path, size: Number(cur.Size) || 0, folder: cur.Folder === '+' || (/^D/.test(cur.Attributes ?? '') && !cur.Size), encrypted: cur.Encrypted === '+', link: !!cur['Symbolic Link'] || /^l/.test(cur.Mode ?? '') || /^\S* l/.test(cur.Attributes ?? ''), // A Mac file's resource fork, or an NTFS file's other streams, shown as "name:stream". stream: cur['Alternate Stream'] === '+', // Set on a disk's partitions: "NTFS", "FAT32" and so on. fileSystem: cur['File System'] ?? '', // When it was last changed, as 7-Zip prints it ("2026-10-17 20:43:01"), or ''. modified: cur.Modified ?? '', // A file deleted on an NTFS disk but still recorded there (listed only when asked for). deleted: cur.Deleted === '+', }); } cur = null; }; for (const line of out) { const m = /^([A-Za-z][\w ]*?) = (.*)$/.exec(line); if (m) { // A new Path starts the next item even if 7-Zip printed no blank line between. if (m[1] === 'Path' && cur?.Path != null) flush(); (cur ??= {})[m[1]] = m[2]; } else if (!line.trim()) flush(); } flush(); if (code >= 2 && !entries.length) throw new Error(problem(err, out) || `7-Zip stopped with code ${code}`); return { entries, warning: code ? problem(err, out) : '' }; } async function extractFrom(create, input, type, paths, options, deleted = false) { const list = new TextEncoder().encode(paths.join('\n') + '\n'); // -spd: the names in the list are matched exactly, never as wildcards. const args = ['x', ...BASE_ARGS, ...typeArgs(type), ...deletedArgs({ deleted }), '-spd', '-o/out', '$ARCHIVE', '@/list.txt']; const run = await exec(create, input, args, options, { '/list.txt': list }); const reason = problem(run.err, run.out); // Read once per name: an installer can hold the same name twice, and 7-Zip writes the last. const read = new Map(); return paths.map((path) => { const at = `/out/${path}`; // Each copy is its own buffer, as each is handed over to the page separately. if (read.has(path)) return { path, data: read.get(path).slice(), error: '' }; try { const st = run.FS.lstat(at); if (run.FS.isLink(st.mode)) return { path, data: null, error: 'is a link to another file' }; if (!run.FS.isFile(st.mode)) return { path, data: null, error: 'is not a file' }; // A file 7-Zip reported trouble with may be partly written: only a clean run vouches for it. const failed = run.code >= 2 && run.err.some((l) => l.includes(path)); if (failed) return { path, data: null, error: reason || 'could not be extracted' }; const data = run.FS.readFile(at); run.FS.unlink(at); read.set(path, data); return { path, data, error: '' }; } catch { return { path, data: null, error: reason || 'could not be extracted' }; } }); } /** * The contents of the given paths (as list() gave them): * { path, data: Uint8Array | null, error }[] in the same order. */ export async function extract(create, input, op, options = {}) { const r = await resolve(create, input, op, options); return extractFrom(create, r.input, r.type, op.paths, options, !!op.deleted); }