import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { access, constants as fsConstants, copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile, } from "node:fs/promises"; import { homedir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; export const JAVA_LOCK = Object.freeze({ bridgeAbi: 1, engineIdentity: "TeaVM java.util.regex 0.15.0", engineVersion: "0.15.0", sourceDateEpoch: 1_780_759_630, maximumPatternUtf16: 65_536, maximumSubjectBytes: 16_777_216, maximumReplacementUtf16: 65_536, maximumMatches: 10_000, maximumCaptureRows: 100_000, maximumCaptureGroups: 1_000, maximumOutputBytes: 67_108_864, source: Object.freeze({ repository: "https://github.com/konsoletyper/teavm.git", tag: "0.15.0", commit: "ee91b03e616c4b45401cd11fb0cd7eb0daf6649b", tree: "1cdc5c332d5809828c8952f96d73f9e7504a0cec", license: "Apache-2.0", licenseSha256: "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", noticeSha256: "6c2b3372a6beee7fbaad482248ac0f5ad2b099d92ff2a528c04261cc9c0636f4", }), toolchain: Object.freeze({ mavenVersion: "3.9.15", javaVersion: "25.0.3", artifacts: Object.freeze([ Object.freeze({ coordinate: "org.teavm:teavm-classlib:0.15.0", relativePath: "org/teavm/teavm-classlib/0.15.0/teavm-classlib-0.15.0.jar", sha256: "7d805be4670a892a34c89d9d750a8afd65e2f8201b7b3a10343634ea19d1c410", }), Object.freeze({ coordinate: "org.teavm:teavm-jso:0.15.0", relativePath: "org/teavm/teavm-jso/0.15.0/teavm-jso-0.15.0.jar", sha256: "58b337b93924106bf819c45890c7c231a0c740145afe77795f1e0f099f621d71", }), Object.freeze({ coordinate: "org.teavm:teavm-maven-plugin:0.15.0", relativePath: "org/teavm/teavm-maven-plugin/0.15.0/teavm-maven-plugin-0.15.0.jar", sha256: "1133e16e45f1fd3cb93129fd96d142e1f5ef3608891d83b2273442d5446a3879", }), ]), }), }); const PACK_FILES = Object.freeze([ "LICENSE.txt", "NOTICE.txt", "SHA256SUMS", "engine-metadata.json", "java-regex.mjs", ]); const CHECKSUM_FILES = Object.freeze([ "LICENSE.txt", "NOTICE.txt", "engine-metadata.json", "java-regex.mjs", ]); const BRIDGE_SOURCE_FILES = Object.freeze([ "engines/java/pom.xml", "engines/java/README.md", "engines/java/LICENSE-TeaVM.txt", "engines/java/NOTICE-TeaVM.txt", "engines/java/src/main/java/de/addideas/regextools/javaengine/RegexBridge.java", ]); const EXPORTED_FUNCTIONS = Object.freeze([ "bridgeAbiVersion", "engineIdentity", "execute", "replace", "selfTest", ]); function sha256(data) { return createHash("sha256").update(data).digest("hex"); } async function sha256File(file) { return sha256(await readFile(file)); } async function assertRealDirectory(candidate, label) { const details = await lstat(candidate).catch(() => null); if (!details?.isDirectory() || details.isSymbolicLink()) { throw new Error(`${label} must be a real directory: ${candidate}`); } return realpath(candidate); } async function assertRegularFile(candidate, label) { const details = await lstat(candidate).catch(() => null); if (!details?.isFile() || details.isSymbolicLink()) { throw new Error( `${label} must be a regular, non-symlink file: ${candidate}`, ); } return realpath(candidate); } async function resolveExecutable(name, environment = process.env) { for (const directory of (environment.PATH ?? "").split(path.delimiter)) { if (!directory) continue; const candidate = path.join(directory, name); try { await access(candidate, fsConstants.X_OK); if ((await stat(candidate)).isFile()) return realpath(candidate); } catch { // Continue through PATH. } } throw new Error(`${name} is required on PATH for the TeaVM Java build.`); } function commandLabel(command, arguments_) { return [command, ...arguments_] .map((part) => /^[A-Za-z0-9_./:=,+-]+$/u.test(part) ? part : JSON.stringify(part), ) .join(" "); } async function run(command, arguments_, options = {}) { const child = spawn(command, arguments_, { cwd: options.cwd, env: options.env, stdio: ["ignore", "pipe", "pipe"], }); const stdout = []; const stderr = []; child.stdout.on("data", (chunk) => stdout.push(chunk)); child.stderr.on("data", (chunk) => stderr.push(chunk)); const result = await new Promise((resolve, reject) => { child.once("error", reject); child.once("close", (code, signal) => resolve({ code, signal })); }); const output = Buffer.concat(stdout).toString("utf8"); const errorOutput = Buffer.concat(stderr).toString("utf8"); if (result.code !== 0) { const detail = [output, errorOutput].filter(Boolean).join("\n").trim(); throw new Error( `${commandLabel(command, arguments_)} failed with ${ result.signal ? `signal ${result.signal}` : `exit code ${result.code}` }${detail ? `:\n${detail}` : "."}`, ); } return { stdout: output, stderr: errorOutput }; } async function verifyToolchain() { const maven = await resolveExecutable("mvn"); const java = await resolveExecutable("java"); const mavenVersion = await run(maven, ["--version"]); const javaVersion = await run(java, ["-version"]); if ( !mavenVersion.stdout.startsWith( `Apache Maven ${JAVA_LOCK.toolchain.mavenVersion} `, ) ) { throw new Error( `The Java pack requires Maven ${JAVA_LOCK.toolchain.mavenVersion}.`, ); } const javaText = `${javaVersion.stdout}\n${javaVersion.stderr}`; if ( !javaText.includes(`openjdk version "${JAVA_LOCK.toolchain.javaVersion}`) ) { throw new Error( `The Java pack requires OpenJDK ${JAVA_LOCK.toolchain.javaVersion}.`, ); } const repository = path.join(homedir(), ".m2", "repository"); for (const artifact of JAVA_LOCK.toolchain.artifacts) { const file = await assertRegularFile( path.join(repository, artifact.relativePath), artifact.coordinate, ); const actual = await sha256File(file); if (actual !== artifact.sha256) { throw new Error( `${artifact.coordinate} SHA-256 mismatch: expected ${artifact.sha256}, got ${actual}.`, ); } } return { maven }; } function cleanBuildEnvironment() { const environment = { ...process.env }; for (const variable of [ "CLASSPATH", "JAVA_TOOL_OPTIONS", "MAVEN_ARGS", "MAVEN_OPTS", "_JAVA_OPTIONS", ]) { delete environment[variable]; } environment.LANG = "C"; environment.LC_ALL = "C"; environment.SOURCE_DATE_EPOCH = String(JAVA_LOCK.sourceDateEpoch); environment.TZ = "UTC"; return environment; } async function sourceFiles(root) { return Promise.all( BRIDGE_SOURCE_FILES.map(async (relativePath) => { const file = await assertRegularFile( path.join(root, relativePath), `Java bridge source ${relativePath}`, ); const contents = await readFile(file); return { path: relativePath, sha256: sha256(contents), bytes: contents.byteLength, }; }), ); } async function fileMetadata(pack, name) { const contents = await readFile(path.join(pack, name)); return { path: name, sha256: sha256(contents), bytes: contents.byteLength }; } async function expectedMetadata(root, pack) { return { schemaVersion: 1, engine: "java", engineName: "TeaVM java.util.regex", engineVersion: JAVA_LOCK.engineVersion, semanticIdentity: "TeaVM 0.15.0 classlib java.util.regex; Apache Harmony-derived; not OpenJDK", status: "production-compatibility-runtime", bridge: { abiVersion: JAVA_LOCK.bridgeAbi, maximumPatternUtf16: JAVA_LOCK.maximumPatternUtf16, maximumSubjectBytes: JAVA_LOCK.maximumSubjectBytes, maximumReplacementUtf16: JAVA_LOCK.maximumReplacementUtf16, maximumMatches: JAVA_LOCK.maximumMatches, maximumCaptureRows: JAVA_LOCK.maximumCaptureRows, maximumCaptureGroups: JAVA_LOCK.maximumCaptureGroups, maximumOutputBytes: JAVA_LOCK.maximumOutputBytes, offsetUnit: "utf16", exports: [...EXPORTED_FUNCTIONS], sourceFiles: await sourceFiles(root), }, source: { repository: JAVA_LOCK.source.repository, tag: JAVA_LOCK.source.tag, commitSha1: JAVA_LOCK.source.commit, treeSha1: JAVA_LOCK.source.tree, license: JAVA_LOCK.source.license, upstreamLicenseSha256: JAVA_LOCK.source.licenseSha256, upstreamNoticeSha256: JAVA_LOCK.source.noticeSha256, }, toolchain: { mavenVersion: JAVA_LOCK.toolchain.mavenVersion, javaVersion: JAVA_LOCK.toolchain.javaVersion, teaVmArtifacts: JAVA_LOCK.toolchain.artifacts.map( ({ coordinate, sha256: digest }) => ({ coordinate, sha256: digest, }), ), }, configuration: { target: "JavaScript ES2015 module", minified: true, optimizationLevel: "ADVANCED", sourceMaps: false, filesystem: false, supportedApplicationFlags: ["g", "i", "d", "m", "s", "u", "x", "U"], unixLines: "d maps to TeaVM Pattern.UNIX_LINES.", unicodeCharacterClassCompatibility: "TeaVM 0.15.0 lacks OpenJDK UNICODE_CHARACTER_CLASS; U implies Unicode case folding only.", replacement: "TeaVM 0.15.0 Matcher appendReplacement/appendTail semantics: only single-digit $n references; ${name} is rejected; not OpenJDK Matcher parity.", }, sourceDateEpoch: JAVA_LOCK.sourceDateEpoch, files: await Promise.all( ["LICENSE.txt", "NOTICE.txt", "java-regex.mjs"].map((name) => fileMetadata(pack, name), ), ), }; } async function expectedChecksums(pack) { const lines = await Promise.all( CHECKSUM_FILES.map( async (name) => `${await sha256File(path.join(pack, name))} ${name}`, ), ); return `${lines.join("\n")}\n`; } function equalJson(left, right) { return JSON.stringify(left) === JSON.stringify(right); } function readField(payload, state) { const colon = payload.indexOf(":", state.cursor); if (colon < 0) throw new Error("Malformed Java smoke-test payload."); const length = Number.parseInt(payload.slice(state.cursor, colon), 10); if (!Number.isSafeInteger(length) || length < 0) { throw new Error("Malformed Java smoke-test field length."); } const start = colon + 1; const end = start + length; if (end > payload.length) { throw new Error("Truncated Java smoke-test payload."); } state.cursor = end; return payload.slice(start, end); } async function smokeJavaPack(pack) { const moduleFile = path.join(pack, "java-regex.mjs"); const imported = await import( `${pathToFileURL(moduleFile).href}?verify=${await sha256File(moduleFile)}` ); for (const name of EXPORTED_FUNCTIONS) { if (typeof imported[name] !== "function") { throw new Error(`The Java module is missing export ${name}.`); } } if ( imported.bridgeAbiVersion() !== JAVA_LOCK.bridgeAbi || imported.engineIdentity() !== JAVA_LOCK.engineIdentity || imported.selfTest() !== 0 ) { throw new Error("The TeaVM Java bridge identity or self-test failed."); } const execution = imported.execute("(?<=a)(b)\\1", "abb", 0, true, 10, 10); const executionState = { cursor: 0 }; const executionFields = Array.from({ length: 9 }, () => readField(execution, executionState), ); if ( executionState.cursor !== execution.length || JSON.stringify(executionFields) !== JSON.stringify(["1", "ok", "1", "0", "1", "1", "3", "1", "2"]) ) { throw new Error("The TeaVM Java matching smoke test failed."); } const replacement = imported.replace("(a)", "a a", "<$1>", 0, true, 10, 10); const replacementState = { cursor: 0 }; const replacementFields = []; while (replacementState.cursor < replacement.length) { replacementFields.push(readField(replacement, replacementState)); } if ( replacementFields.at(-2) !== "0" || replacementFields.at(-1) !== " " ) { throw new Error("The TeaVM Java replacement smoke test failed."); } } export async function verifyJavaPack(packDirectory, root) { const repositoryRoot = await assertRealDirectory( root, "Regex Tools repository", ); const pack = await assertRealDirectory(packDirectory, "Java engine pack"); const entries = (await readdir(pack, { withFileTypes: true })).sort( (left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0, ); if ( entries.length !== PACK_FILES.length || entries.some( (entry, index) => entry.name !== PACK_FILES[index] || !entry.isFile() || entry.isSymbolicLink(), ) ) { throw new Error( `The staged Java pack must contain only: ${PACK_FILES.join(", ")}.`, ); } if ( (await sha256File(path.join(pack, "LICENSE.txt"))) !== JAVA_LOCK.source.licenseSha256 ) { throw new Error("The staged TeaVM licence does not match tag 0.15.0."); } const moduleText = await readFile(path.join(pack, "java-regex.mjs"), "utf8"); if ( moduleText.includes("sourceMappingURL") || moduleText.includes("/mnt/") || moduleText.includes("/home/") || !moduleText.includes("as bridgeAbiVersion") || !moduleText.includes("as engineIdentity") || !moduleText.includes("as execute") || !moduleText.includes("as replace") || !moduleText.includes("as selfTest") ) { throw new Error( "java-regex.mjs leaks a build path, has a source map, or lacks bridge exports.", ); } let metadata; try { metadata = JSON.parse( await readFile(path.join(pack, "engine-metadata.json"), "utf8"), ); } catch (error) { throw new Error("Java engine-metadata.json is not valid JSON.", { cause: error, }); } const expected = await expectedMetadata(repositoryRoot, pack); if (!equalJson(metadata, expected)) { throw new Error( "The staged Java metadata does not match the pinned build contract.", ); } if ( (await readFile(path.join(pack, "SHA256SUMS"), "utf8")) !== (await expectedChecksums(pack)) ) { throw new Error("The staged Java SHA256SUMS file is incorrect."); } await smokeJavaPack(pack); return metadata; } async function replaceDirectory(stage, target, label) { const targetDetails = await lstat(target).catch(() => null); if ( targetDetails && (!targetDetails.isDirectory() || targetDetails.isSymbolicLink()) ) { throw new Error(`${label} must be a real directory.`); } if (!targetDetails) { await rename(stage, target); return; } const backup = `${target}.replaced-${process.pid}`; await rename(target, backup); try { await rename(stage, target); } catch (error) { await rename(backup, target); throw error; } await rm(backup, { recursive: true }); } export async function buildJavaPack(root, outputDirectory) { const repositoryRoot = await assertRealDirectory( root, "Regex Tools repository", ); const engine = await assertRealDirectory( path.join(repositoryRoot, "engines", "java"), "Java engine source", ); const { maven } = await verifyToolchain(); const environment = cleanBuildEnvironment(); const buildArguments = [ "--batch-mode", "--no-transfer-progress", "--offline", "clean", "package", ]; await run(maven, buildArguments, { cwd: engine, env: environment }); const generated = await assertRegularFile( path.join(engine, "target", "generated", "java-regex.mjs"), "generated TeaVM module", ); const firstBuild = await readFile(generated); await run(maven, buildArguments, { cwd: engine, env: environment }); const secondBuild = await readFile(generated); if (sha256(firstBuild) !== sha256(secondBuild)) { throw new Error( "Two clean TeaVM builds produced different Java module hashes.", ); } const output = path.resolve( repositoryRoot, outputDirectory ?? path.join(".engine-build", "java"), ); await mkdir(path.dirname(output), { recursive: true }); const stage = await mkdtemp(path.join(path.dirname(output), ".java-build-")); try { await copyFile( path.join(engine, "LICENSE-TeaVM.txt"), path.join(stage, "LICENSE.txt"), fsConstants.COPYFILE_EXCL, ); await copyFile( path.join(engine, "NOTICE-TeaVM.txt"), path.join(stage, "NOTICE.txt"), fsConstants.COPYFILE_EXCL, ); await writeFile(path.join(stage, "java-regex.mjs"), secondBuild, { flag: "wx", }); const metadata = await expectedMetadata(repositoryRoot, stage); await writeFile( path.join(stage, "engine-metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", flag: "wx" }, ); await writeFile( path.join(stage, "SHA256SUMS"), await expectedChecksums(stage), { encoding: "utf8", flag: "wx" }, ); await verifyJavaPack(stage, repositoryRoot); await replaceDirectory(stage, output, "Java build output"); return { output, metadata, moduleSha256: sha256(secondBuild), moduleBytes: secondBuild.byteLength, }; } finally { await rm(stage, { recursive: true, force: true }); } } export async function installJavaPack(packDirectory, root) { const repositoryRoot = await assertRealDirectory( root, "Regex Tools repository", ); const source = await assertRealDirectory(packDirectory, "verified Java pack"); const metadata = await verifyJavaPack(source, repositoryRoot); const engineRoot = await assertRealDirectory( path.join(repositoryRoot, "public", "engines"), "public engine directory", ); const target = path.join(engineRoot, "java"); const stage = await mkdtemp(path.join(engineRoot, ".java-install-")); try { for (const name of PACK_FILES) { await copyFile( path.join(source, name), path.join(stage, name), fsConstants.COPYFILE_EXCL, ); } await verifyJavaPack(stage, repositoryRoot); await replaceDirectory(stage, target, "public/engines/java"); return { output: target, metadata }; } finally { await rm(stage, { recursive: true, force: true }); } } const invokedFile = process.argv[1] ? path.resolve(process.argv[1]) : ""; if (invokedFile === fileURLToPath(import.meta.url)) { if (process.argv.length > 3) { throw new Error( "Usage: node scripts/java-engine-pack.mjs [.engine-build/java]", ); } const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const result = await buildJavaPack(root, process.argv[2]); console.log( `Built deterministic ${result.metadata.semanticIdentity} module: ${result.moduleSha256} (${result.moduleBytes.toLocaleString()} bytes).`, ); }