mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
- run-emulator.sh: drop wait_for_condition poll interval from 1s to 0.2s - emulator.ts: replace existsSync+readFileSync TOCTOU in readInternalPck with try/ENOENT; tighten initial backoff to 50ms; drop redundant mkdirSync in startEmulator; surface stop-failure on stderr instead of swallowing silently - iso.ts: inline trivial buildRootDirRecordInVD wrapper
400 lines
12 KiB
TypeScript
400 lines
12 KiB
TypeScript
// Minimal ISO 9660 + Joliet writer used to package the runtime config blob
|
|
// that the emulator VM mounts at boot via /dev/disk/by-label/STACKCFG.
|
|
//
|
|
// Replaces the host-side dependency on hdiutil/mkisofs/genisoimage. Only the
|
|
// subset of ECMA-119 needed for a single-level root directory of small UTF-8
|
|
// text files is implemented: PVD + Joliet SVD + path tables + root dir + file
|
|
// data. Names are emitted in both ISO 9660 ("BASE.ENV;1") and Joliet
|
|
// (lower-case UCS-2) form so Linux mounts the Joliet view by default and the
|
|
// guest's `source /mnt/stack-runtime/runtime.env` works unchanged.
|
|
|
|
import { writeFileSync } from "fs";
|
|
|
|
const SECTOR = 2048;
|
|
|
|
function bothEndian32(n: number): Buffer {
|
|
const b = Buffer.alloc(8);
|
|
b.writeUInt32LE(n, 0);
|
|
b.writeUInt32BE(n, 4);
|
|
return b;
|
|
}
|
|
|
|
function bothEndian16(n: number): Buffer {
|
|
const b = Buffer.alloc(4);
|
|
b.writeUInt16LE(n, 0);
|
|
b.writeUInt16BE(n, 2);
|
|
return b;
|
|
}
|
|
|
|
function padString(s: string, len: number, fill = " "): Buffer {
|
|
const buf = Buffer.alloc(len, fill.charCodeAt(0));
|
|
buf.write(s.slice(0, len), 0, "ascii");
|
|
return buf;
|
|
}
|
|
|
|
function ucs2BE(s: string): Buffer {
|
|
const buf = Buffer.alloc(s.length * 2);
|
|
for (let i = 0; i < s.length; i++) {
|
|
buf.writeUInt16BE(s.charCodeAt(i), i * 2);
|
|
}
|
|
return buf;
|
|
}
|
|
|
|
function padUcs2BE(s: string, byteLen: number): Buffer {
|
|
const buf = Buffer.alloc(byteLen);
|
|
const wholeChars = Math.floor(byteLen / 2);
|
|
for (let i = 0; i < wholeChars; i++) {
|
|
buf.writeUInt16BE(i < s.length ? s.charCodeAt(i) : 0x0020, i * 2);
|
|
}
|
|
// Odd-length fields (e.g. 37-byte Copyright/Abstract/Bibliographic IDs) get
|
|
// a trailing space byte; spec allows either NUL or 0x20 padding.
|
|
if (byteLen % 2 === 1) {
|
|
buf[byteLen - 1] = 0x20;
|
|
}
|
|
return buf;
|
|
}
|
|
|
|
function dirRecordingDate(d: Date): Buffer {
|
|
const buf = Buffer.alloc(7);
|
|
buf[0] = d.getUTCFullYear() - 1900;
|
|
buf[1] = d.getUTCMonth() + 1;
|
|
buf[2] = d.getUTCDate();
|
|
buf[3] = d.getUTCHours();
|
|
buf[4] = d.getUTCMinutes();
|
|
buf[5] = d.getUTCSeconds();
|
|
buf[6] = 0;
|
|
return buf;
|
|
}
|
|
|
|
function volumeDate(d: Date): Buffer {
|
|
const pad = (n: number, w: number) => String(n).padStart(w, "0");
|
|
const s =
|
|
pad(d.getUTCFullYear(), 4) +
|
|
pad(d.getUTCMonth() + 1, 2) +
|
|
pad(d.getUTCDate(), 2) +
|
|
pad(d.getUTCHours(), 2) +
|
|
pad(d.getUTCMinutes(), 2) +
|
|
pad(d.getUTCSeconds(), 2) +
|
|
"00";
|
|
const buf = Buffer.alloc(17);
|
|
buf.write(s, 0, 16, "ascii");
|
|
buf[16] = 0;
|
|
return buf;
|
|
}
|
|
|
|
const UNUSED_VOLUME_DATE = (() => {
|
|
const buf = Buffer.alloc(17, "0".charCodeAt(0));
|
|
buf[16] = 0;
|
|
return buf;
|
|
})();
|
|
|
|
// Encodes an ISO 9660 file identifier ("FILENAME.EXT;1"). Caller must pass an
|
|
// already-uppercased 8.3 name without the version suffix.
|
|
function isoFileIdentifier(name: string): Buffer {
|
|
const upper = name.toUpperCase();
|
|
return Buffer.from(`${upper};1`, "ascii");
|
|
}
|
|
|
|
// Builds a single directory record. `idBytes` is the file identifier bytes
|
|
// (ASCII for ISO, UCS-2 BE for Joliet); `idForDot` overrides with a single
|
|
// 0x00 / 0x01 byte for "." / ".." entries.
|
|
function buildDirRecord(
|
|
extentSector: number,
|
|
dataLength: number,
|
|
isDir: boolean,
|
|
recDate: Buffer,
|
|
idBytes: Buffer,
|
|
): Buffer {
|
|
const lenFi = idBytes.length;
|
|
const pad = lenFi % 2 === 0 ? 1 : 0;
|
|
const lenDr = 33 + lenFi + pad;
|
|
const buf = Buffer.alloc(lenDr);
|
|
buf[0] = lenDr;
|
|
buf[1] = 0;
|
|
bothEndian32(extentSector).copy(buf, 2);
|
|
bothEndian32(dataLength).copy(buf, 10);
|
|
recDate.copy(buf, 18);
|
|
buf[25] = isDir ? 0x02 : 0x00;
|
|
buf[26] = 0;
|
|
buf[27] = 0;
|
|
bothEndian16(1).copy(buf, 28);
|
|
buf[32] = lenFi;
|
|
idBytes.copy(buf, 33);
|
|
return buf;
|
|
}
|
|
|
|
function buildRootDirEntries(
|
|
rootSector: number,
|
|
rootSize: number,
|
|
recDate: Buffer,
|
|
files: { idBytes: Buffer, sector: number, size: number }[],
|
|
): Buffer {
|
|
const records: Buffer[] = [];
|
|
records.push(buildDirRecord(rootSector, rootSize, true, recDate, Buffer.from([0x00])));
|
|
records.push(buildDirRecord(rootSector, rootSize, true, recDate, Buffer.from([0x01])));
|
|
for (const f of files) {
|
|
records.push(buildDirRecord(f.sector, f.size, false, recDate, f.idBytes));
|
|
}
|
|
|
|
// Records may not span sector boundaries; pack them with sector padding.
|
|
const sectors: Buffer[] = [];
|
|
let current = Buffer.alloc(0);
|
|
for (const r of records) {
|
|
if (current.length + r.length > SECTOR) {
|
|
sectors.push(Buffer.concat([current, Buffer.alloc(SECTOR - current.length)]));
|
|
current = Buffer.alloc(0);
|
|
}
|
|
current = Buffer.concat([current, r]);
|
|
}
|
|
if (current.length > 0) {
|
|
sectors.push(Buffer.concat([current, Buffer.alloc(SECTOR - current.length)]));
|
|
}
|
|
return Buffer.concat(sectors);
|
|
}
|
|
|
|
// Single-entry path table for the root directory. Used for both L (LE) and M
|
|
// (BE) tables; pass writeUInt32LE/BE accordingly.
|
|
function buildPathTable(rootSector: number, byteOrder: "LE" | "BE"): Buffer {
|
|
const buf = Buffer.alloc(10);
|
|
buf[0] = 1; // LEN_DI
|
|
buf[1] = 0; // EAR length
|
|
if (byteOrder === "LE") {
|
|
buf.writeUInt32LE(rootSector, 2);
|
|
buf.writeUInt16LE(1, 6);
|
|
} else {
|
|
buf.writeUInt32BE(rootSector, 2);
|
|
buf.writeUInt16BE(1, 6);
|
|
}
|
|
buf[8] = 0; // root identifier
|
|
buf[9] = 0; // pad
|
|
return buf;
|
|
}
|
|
|
|
function padToSector(buf: Buffer): Buffer {
|
|
const rem = buf.length % SECTOR;
|
|
if (rem === 0) return buf;
|
|
return Buffer.concat([buf, Buffer.alloc(SECTOR - rem)]);
|
|
}
|
|
|
|
// Build a Volume Descriptor (PVD or Joliet SVD). `joliet` switches volume-name
|
|
// fields to UCS-2 BE and sets the Joliet escape sequence.
|
|
function buildVolumeDescriptor(opts: {
|
|
joliet: boolean,
|
|
volumeId: string,
|
|
volumeSpaceSize: number,
|
|
pathTableSize: number,
|
|
lPathSector: number,
|
|
mPathSector: number,
|
|
rootDirRecord: Buffer,
|
|
date: Buffer,
|
|
}): Buffer {
|
|
const buf = Buffer.alloc(SECTOR);
|
|
buf[0] = opts.joliet ? 2 : 1;
|
|
buf.write("CD001", 1, 5, "ascii");
|
|
buf[6] = 1;
|
|
buf[7] = 0;
|
|
|
|
// System Identifier (32 bytes)
|
|
if (opts.joliet) {
|
|
padUcs2BE("", 32).copy(buf, 8);
|
|
} else {
|
|
padString("", 32).copy(buf, 8);
|
|
}
|
|
|
|
// Volume Identifier (32 bytes) — must be "STACKCFG" so udev exposes it as
|
|
// /dev/disk/by-label/STACKCFG. blkid reads from PVD by default but Joliet
|
|
// takes precedence when both are present.
|
|
if (opts.joliet) {
|
|
padUcs2BE(opts.volumeId, 32).copy(buf, 40);
|
|
} else {
|
|
padString(opts.volumeId, 32).copy(buf, 40);
|
|
}
|
|
|
|
bothEndian32(opts.volumeSpaceSize).copy(buf, 80);
|
|
|
|
if (opts.joliet) {
|
|
// Escape sequence for UCS-2 Level 3 ("%/E") at offset 88 (32 bytes).
|
|
buf[88] = 0x25;
|
|
buf[89] = 0x2f;
|
|
buf[90] = 0x45;
|
|
}
|
|
|
|
bothEndian16(1).copy(buf, 120); // Volume Set Size
|
|
bothEndian16(1).copy(buf, 124); // Volume Sequence Number
|
|
bothEndian16(SECTOR).copy(buf, 128); // Logical Block Size
|
|
bothEndian32(opts.pathTableSize).copy(buf, 132);
|
|
buf.writeUInt32LE(opts.lPathSector, 140);
|
|
buf.writeUInt32LE(0, 144); // optional L
|
|
buf.writeUInt32BE(opts.mPathSector, 148);
|
|
buf.writeUInt32BE(0, 152); // optional M
|
|
|
|
opts.rootDirRecord.copy(buf, 156);
|
|
|
|
const padFn = opts.joliet
|
|
? (s: string, n: number) => padUcs2BE(s, n)
|
|
: (s: string, n: number) => padString(s, n);
|
|
|
|
padFn("", 128).copy(buf, 190); // Volume Set Identifier
|
|
padFn("", 128).copy(buf, 318); // Publisher Identifier
|
|
padFn("", 128).copy(buf, 446); // Data Preparer Identifier
|
|
padFn("", 128).copy(buf, 574); // Application Identifier
|
|
padFn("", 37).copy(buf, 702); // Copyright File Identifier
|
|
padFn("", 37).copy(buf, 739); // Abstract File Identifier
|
|
padFn("", 37).copy(buf, 776); // Bibliographic File Identifier
|
|
|
|
opts.date.copy(buf, 813); // Creation
|
|
opts.date.copy(buf, 830); // Modification
|
|
UNUSED_VOLUME_DATE.copy(buf, 847); // Expiration
|
|
UNUSED_VOLUME_DATE.copy(buf, 864); // Effective
|
|
|
|
buf[881] = 1; // File Structure Version
|
|
return buf;
|
|
}
|
|
|
|
function buildVolumeDescriptorTerminator(): Buffer {
|
|
const buf = Buffer.alloc(SECTOR);
|
|
buf[0] = 0xff;
|
|
buf.write("CD001", 1, 5, "ascii");
|
|
buf[6] = 1;
|
|
return buf;
|
|
}
|
|
|
|
export type IsoFile = { name: string, data: Buffer };
|
|
|
|
export function buildIso(volumeId: string, files: IsoFile[]): Buffer {
|
|
const date = new Date();
|
|
const recDate = dirRecordingDate(date);
|
|
const volDateBuf = volumeDate(date);
|
|
|
|
// Compute per-file directory record sizes for both views.
|
|
const isoEntries = files.map((f) => ({
|
|
file: f,
|
|
idBytes: isoFileIdentifier(f.name),
|
|
}));
|
|
const jolietEntries = files.map((f) => ({
|
|
file: f,
|
|
idBytes: ucs2BE(f.name),
|
|
}));
|
|
|
|
// We need root sector + size before we know file sectors — but file sectors
|
|
// depend only on the root dir size, which depends only on the file count.
|
|
// Compute the root dir buffer twice if needed (sizes are stable since they
|
|
// depend only on identifier bytes, not on file extents).
|
|
const dirRecLen = (lenFi: number) => 33 + lenFi + (lenFi % 2 === 0 ? 1 : 0);
|
|
const isoRootSize = 34 + 34 + isoEntries.reduce((acc, e) => acc + dirRecLen(e.idBytes.length), 0);
|
|
const jolietRootSize = 34 + 34 + jolietEntries.reduce((acc, e) => acc + dirRecLen(e.idBytes.length), 0);
|
|
if (isoRootSize > SECTOR || jolietRootSize > SECTOR) {
|
|
throw new Error(`Root directory exceeds ${SECTOR} bytes; multi-sector root not supported.`);
|
|
}
|
|
|
|
// Sector layout.
|
|
const sysAreaSectors = 16;
|
|
const pvdSector = sysAreaSectors;
|
|
const svdSector = pvdSector + 1;
|
|
const termSector = svdSector + 1;
|
|
const isoLPathSector = termSector + 1;
|
|
const isoMPathSector = isoLPathSector + 1;
|
|
const jolietLPathSector = isoMPathSector + 1;
|
|
const jolietMPathSector = jolietLPathSector + 1;
|
|
const isoRootSector = jolietMPathSector + 1;
|
|
const jolietRootSector = isoRootSector + 1;
|
|
let nextSector = jolietRootSector + 1;
|
|
|
|
const fileLayout = files.map((f) => {
|
|
const sector = nextSector;
|
|
const sectors = Math.max(1, Math.ceil(f.data.length / SECTOR));
|
|
nextSector += sectors;
|
|
return { file: f, sector, size: f.data.length };
|
|
});
|
|
|
|
const totalSectors = nextSector;
|
|
const pathTableSize = 10;
|
|
|
|
// Root directory record inside the volume descriptor (BP 157-190 of PVD/SVD):
|
|
// same layout as a regular dir record but the identifier is the single byte 0x00.
|
|
const rootIdent = Buffer.from([0x00]);
|
|
const isoRootDirRecordVD = buildDirRecord(isoRootSector, SECTOR, true, recDate, rootIdent);
|
|
const jolietRootDirRecordVD = buildDirRecord(jolietRootSector, SECTOR, true, recDate, rootIdent);
|
|
|
|
const pvd = buildVolumeDescriptor({
|
|
joliet: false,
|
|
volumeId,
|
|
volumeSpaceSize: totalSectors,
|
|
pathTableSize,
|
|
lPathSector: isoLPathSector,
|
|
mPathSector: isoMPathSector,
|
|
rootDirRecord: isoRootDirRecordVD,
|
|
date: volDateBuf,
|
|
});
|
|
|
|
const svd = buildVolumeDescriptor({
|
|
joliet: true,
|
|
volumeId,
|
|
volumeSpaceSize: totalSectors,
|
|
pathTableSize,
|
|
lPathSector: jolietLPathSector,
|
|
mPathSector: jolietMPathSector,
|
|
rootDirRecord: jolietRootDirRecordVD,
|
|
date: volDateBuf,
|
|
});
|
|
|
|
const term = buildVolumeDescriptorTerminator();
|
|
const isoLPath = padToSector(buildPathTable(isoRootSector, "LE"));
|
|
const isoMPath = padToSector(buildPathTable(isoRootSector, "BE"));
|
|
const jolietLPath = padToSector(buildPathTable(jolietRootSector, "LE"));
|
|
const jolietMPath = padToSector(buildPathTable(jolietRootSector, "BE"));
|
|
|
|
const isoRoot = buildRootDirEntries(
|
|
isoRootSector,
|
|
SECTOR,
|
|
recDate,
|
|
isoEntries.map((e, i) => ({
|
|
idBytes: e.idBytes,
|
|
sector: fileLayout[i].sector,
|
|
size: fileLayout[i].size,
|
|
})),
|
|
);
|
|
const jolietRoot = buildRootDirEntries(
|
|
jolietRootSector,
|
|
SECTOR,
|
|
recDate,
|
|
jolietEntries.map((e, i) => ({
|
|
idBytes: e.idBytes,
|
|
sector: fileLayout[i].sector,
|
|
size: fileLayout[i].size,
|
|
})),
|
|
);
|
|
|
|
// Each file must occupy the exact number of sectors the layout reserved for
|
|
// it. An empty file reserves 1 sector (via Math.max(1, …)) but
|
|
// padToSector(Buffer.alloc(0)) returns 0 bytes — that would desync every
|
|
// subsequent file's extent. Explicitly pad to the reserved size instead.
|
|
const fileBuffers = fileLayout.map((f) => {
|
|
const reservedSectors = Math.max(1, Math.ceil(f.file.data.length / SECTOR));
|
|
const reservedBytes = reservedSectors * SECTOR;
|
|
if (f.file.data.length === reservedBytes) return f.file.data;
|
|
const out = Buffer.alloc(reservedBytes);
|
|
f.file.data.copy(out, 0);
|
|
return out;
|
|
});
|
|
|
|
return Buffer.concat([
|
|
Buffer.alloc(sysAreaSectors * SECTOR),
|
|
pvd,
|
|
svd,
|
|
term,
|
|
isoLPath,
|
|
isoMPath,
|
|
jolietLPath,
|
|
jolietMPath,
|
|
isoRoot,
|
|
jolietRoot,
|
|
...fileBuffers,
|
|
]);
|
|
}
|
|
|
|
export function writeIso(path: string, volumeId: string, files: IsoFile[]): void {
|
|
const buf = buildIso(volumeId, files);
|
|
writeFileSync(path, buf);
|
|
}
|