import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { isIP } from 'node:net'; import { execFileSync } from 'node:child_process'; import { parseDocument, stringify as yamlStringify } from 'yaml'; import { parse as tomlParse } from 'smol-toml'; const fail = message => { throw new Error(message); }; const hash = (data, algorithm = 'sha256') => crypto.createHash(algorithm).update(data).digest('hex'); const fileHash = file => hash(fs.readFileSync(file)); const plain = value => value !== null && typeof value === 'object' && !Array.isArray(value); const requiredString = (value, label) => typeof value === 'string' && value.length > 0 ? value : fail(`${label} must be a nonempty string`); const secretName = name => (/^\.env(?:\..*)?$/.test(name) && name !== '.env.example') || /^(id_rsa|id_ed25519)$/.test(name); function run(command, args, { cwd, env = process.env, log = () => {} } = {}) { try { const output = execFileSync(command, args, { cwd, env, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }); if (output.trim()) log(output.trim()); return output; } catch (error) { fail(`${path.basename(command)} failed: ${error.stderr?.toString().trim() || error.message}`); } } function yaml(text, label) { const doc = parseDocument(text, { uniqueKeys: true }); if (doc.errors.length || doc.warnings.length) fail(`${label}: ${[...doc.errors, ...doc.warnings].map(e => e.message).join('; ')}`); return doc.toJS({ maxAliasCount: 50 }); } function keys(value, allowed, label) { if (!plain(value)) fail(`${label} must be a mapping`); for (const key of Object.keys(value)) if (!allowed.includes(key)) fail(`Unknown ${label} key: ${key}`); for (const key of allowed) if (!(key in value)) fail(`Missing ${label}.${key}`); } export function validateConfig(config) { keys(config, ['schema_version', 'pack', 'build', 'server', 'deploy'], 'config'); if (config.schema_version !== 1) fail('Unsupported config schema_version'); keys(config.pack, ['directory'], 'pack'); keys(config.build, ['output_directory'], 'build'); keys(config.server, ['host', 'directory', 'memory', 'port'], 'server'); keys(config.deploy, ['transport', 'healthcheck_timeout'], 'deploy'); requiredString(config.pack.directory, 'pack.directory'); requiredString(config.build.output_directory, 'build.output_directory'); if (!/^[1-9]\d*[MG]$/.test(config.server.memory)) fail('server.memory must use M or G, for example 2G'); if (!Number.isInteger(config.server.port) || config.server.port < 1 || config.server.port > 65535) fail('Invalid server.port'); for (const key of ['host', 'directory']) if (config.server[key] !== null) requiredString(config.server[key], `server.${key}`); if ((config.server.host === null) !== (config.server.directory === null)) fail('Set both server.host and server.directory, or leave both null'); if (config.server.directory !== null && !config.server.directory.startsWith('/')) fail('Remote server.directory must be absolute'); if (config.deploy.transport !== 'ssh') fail('Only SSH deploy transport is supported'); if (!Number.isInteger(config.deploy.healthcheck_timeout) || config.deploy.healthcheck_timeout < 1) fail('Invalid healthcheck_timeout'); return config; } function safeRelative(value) { return typeof value === 'string' && value !== '' && !/[\\\r\n\0]/.test(value) && !path.posix.isAbsolute(value) && value.split('/').every(part => part !== '' && part !== '.' && part !== '..'); } function inside(root, value) { const result = path.resolve(root, requiredString(value, 'path')); const relative = path.relative(root, result); if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) fail(`Path must be inside repository: ${value}`); let current = root; for (const part of relative.split(path.sep)) { current = path.join(current, part); try { if (fs.lstatSync(current).isSymbolicLink()) fail(`Symlinks are not allowed: ${value}`); } catch (error) { if (error.code !== 'ENOENT') throw error; } } return result; } function files(directory) { const result = []; function walk(current) { for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { const file = path.join(current, entry.name); if (secretName(entry.name)) fail(`Known secret filename is not allowed in source: ${file}`); if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) fail(`Only regular files/directories are allowed: ${file}`); if (entry.isDirectory()) walk(file); else { const relative = path.relative(directory, file).split(path.sep).join('/'); if (!safeRelative(relative)) fail(`Invalid file path: ${relative}`); result.push(relative); } } } walk(directory); return result.sort(); } function readLock(file) { const result = {}; for (const line of fs.readFileSync(file, 'utf8').split('\n')) { if (!line.trim() || line.trim().startsWith('#')) continue; const match = /^([A-Z][A-Z0-9_]*)=(\S+)$/.exec(line); if (!match || match[1] in result) fail('Invalid or duplicate toolchain lock entry'); result[match[1]] = match[2]; } for (const key of ['PACKWIZ_BINARY_SHA256', 'COMPOSE_SHA256']) if (!/^[a-f0-9]{64}$/.test(result[key])) fail(`Invalid ${key}`); if (!/^[a-f0-9]{40}$/.test(result.PACKWIZ_COMMIT)) fail('Invalid PACKWIZ_COMMIT'); if (!/^itzg\/minecraft-server@sha256:[a-f0-9]{64}$/.test(result.MC_IMAGE)) fail('MC_IMAGE must be pinned by digest'); requiredString(result.COMPOSE_VERSION, 'COMPOSE_VERSION'); return result; } function validatePack(directory, compatibility, image) { const tree = files(directory); const pack = tomlParse(fs.readFileSync(path.join(directory, 'pack.toml'), 'utf8')); for (const key of ['name', 'version']) if (typeof pack[key] !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(pack[key])) fail(`Invalid pack ${key}`); if (pack['pack-format'] !== 'packwiz:1.1.0') fail('Unsupported pack format'); const minecraft = requiredString(pack.versions?.minecraft, 'Minecraft version'); const loaders = Object.keys(pack.versions).filter(key => key !== 'minecraft'); if (loaders.length !== 1) fail('Specify exactly one loader'); const loader = loaders[0], loaderVersion = requiredString(pack.versions[loader], 'loader version'); const verified = compatibility.verified?.find(v => v.minecraft === minecraft && v.loader === loader && v.loader_version === loaderVersion && v.image === image); if (!verified || verified.java_major !== 21) fail('Minecraft/loader/image combination has not been verified by the spike'); if (!safeRelative(pack.index?.file) || pack.index['hash-format'] !== 'sha256') fail('Pack index must have a safe path and SHA256'); const indexFile = path.join(directory, pack.index.file); if (fileHash(indexFile) !== pack.index.hash) fail('Stale pack index hash: run packwiz refresh explicitly'); const index = tomlParse(fs.readFileSync(indexFile, 'utf8')); if (index['hash-format'] !== 'sha256' || !Array.isArray(index.files)) fail('Unsupported or missing index files'); const targets = new Set(), modIds = new Set(), indexed = new Set(); for (const entry of index.files) { if (!safeRelative(entry.file) || indexed.has(entry.file)) fail(`Unsafe or duplicate indexed path: ${entry.file}`); indexed.add(entry.file); const file = path.join(directory, entry.file); if (!tree.includes(entry.file) || fileHash(file) !== entry.hash) fail(`Missing or stale indexed file: ${entry.file}; run packwiz refresh explicitly`); if (entry.metafile) { const mod = tomlParse(fs.readFileSync(file, 'utf8')); if (!safeRelative(mod.filename) || mod.filename.includes('/')) fail(`Invalid mod filename: ${entry.file}`); if (!['client', 'server', 'both'].includes(mod.side)) fail(`Explicit side metadata required: ${entry.file}`); const target = path.posix.join(path.posix.dirname(entry.file), mod.filename); if (targets.has(target)) fail(`Duplicate mod target: ${target}`); targets.add(target); const modId = mod.update?.modrinth?.['mod-id']; if (modId && modIds.has(modId)) fail(`Duplicate Modrinth project: ${modId}`); if (modId) modIds.add(modId); const download = mod.download; let url; try { url = new URL(download?.url); } catch { fail(`Missing download URL: ${entry.file}`); } if (url.protocol !== 'https:' || url.username || url.password) fail(`Download URL must be HTTPS without credentials: ${entry.file}`); const length = { sha512: 128, sha256: 64, sha1: 40, md5: 32 }[download['hash-format']]; if (!length || !new RegExp(`^[a-f0-9]{${length}}$`).test(download.hash)) fail(`Invalid download hash: ${entry.file}`); } } return { pack, minecraft, loader, loaderVersion, verified, tree }; } function generatedServer(root, release, artifact, config, lock) { const source = path.join(root, 'server'); const compose = yaml(fs.readFileSync(path.join(source, 'compose.yaml'), 'utf8'), 'server compose'); const service = compose.services?.minecraft; if (!service || Object.keys(compose.services).length !== 1) fail('Expected one minecraft Compose service'); if (service.environment?.TYPE !== 'MODRINTH' || service.environment?.ONLINE_MODE !== 'false' || service.environment?.ENFORCE_SECURE_PROFILE !== 'false') fail('Compose must use MODRINTH and offline authentication'); service.image = lock.MC_IMAGE; service.ports = [`\${MC_BIND_ADDRESS:-127.0.0.1}:\${MC_PORT:-${config.server.port}}:25565`]; service.environment.MEMORY = `\${MC_MEMORY:-${config.server.memory}}`; service.environment.MODRINTH_MODPACK = `/modpacks/${artifact}`; service.environment.SYNC_SKIP_NEWER_IN_DESTINATION = 'false'; service.volumes = [ { type: 'bind', source: '${MC_ROOT:?Set absolute server root}/runtime', target: '/data' }, { type: 'bind', source: '${MC_RELEASE_DIR:?Set absolute release directory}', target: '/modpacks', read_only: true }, { type: 'bind', source: '${MC_RELEASE_DIR:?Set absolute release directory}/server/config', target: '/config', read_only: true } ]; const destination = path.join(release, 'server'); fs.mkdirSync(destination); fs.writeFileSync(path.join(destination, 'compose.yaml'), yamlStringify(compose)); // Allowlist local settings; never copy server/.env or deployment target. const example = readLockExample(path.join(source, '.env.example')); example.MC_EULA = 'FALSE'; example.MC_MEMORY = config.server.memory; example.MC_PORT = String(config.server.port); fs.writeFileSync(path.join(destination, '.env.example'), '# Accept Minecraft EULA before setting MC_EULA=TRUE.\n' + Object.entries(example).map(([key, value]) => `${key}=${value}\n`).join('')); fs.cpSync(path.join(source, 'config'), path.join(destination, 'config'), { recursive: true }); fs.writeFileSync(path.join(destination, 'README.md'), `# Immutable server release\n\nCopy server/.env.example to a local settings file outside this release.\nAccept the Minecraft EULA before setting MC_EULA=TRUE.\n\nSet MC_ROOT to an absolute server root and MC_RELEASE_DIR to this release.\nUse the same Compose project name for all releases:\n\n\`\`\`bash\ndocker compose --project-name minecraft-lab-spike --env-file /absolute/local-settings.env -f "$MC_RELEASE_DIR/server/compose.yaml" config --quiet\n\`\`\`\n\nThis release uses offline authentication. MC_BIND_ADDRESS selects the IPv4 interface; default is 127.0.0.1, use 0.0.0.0 for remote LAN access.\nserver/config overrides corresponding pack settings at startup.\nRuntime-generated server.properties is configured through Compose environment.\n`); return compose; } function readLockExample(file) { const result = {}; const allowed = ['MC_EULA', 'MC_MEMORY', 'MC_PORT', 'MC_UID', 'MC_GID', 'MC_BIND_ADDRESS']; for (const line of fs.readFileSync(file, 'utf8').split('\n')) { if (!line.trim() || line.trim().startsWith('#')) continue; const match = /^([A-Z_]+)=([A-Za-z0-9.]+)$/.exec(line); if (!match || !allowed.includes(match[1]) || match[1] in result) fail('Unexpected setting in server/.env.example'); result[match[1]] = match[2]; } if (result.MC_BIND_ADDRESS && isIP(result.MC_BIND_ADDRESS) !== 4) fail('Invalid MC_BIND_ADDRESS in env example'); return result; } function managedFiles(extracted, index, release) { const sides = { client: new Map(), server: new Map() }; for (const file of index.files ?? []) { if (!safeRelative(file.path) || !plain(file.hashes) || !file.hashes.sha512 || !file.hashes.sha1) fail('Invalid exported mod metadata'); for (const side of ['client', 'server']) if (file.env?.[side] !== 'unsupported') { if (sides[side].has(file.path)) fail(`Duplicate exported target: ${file.path}`); sides[side].set(file.path, { path: file.path, owner: 'pack', policy: 'replace', hashes: file.hashes }); } } for (const prefix of ['overrides', 'client-overrides', 'server-overrides']) { const directory = path.join(extracted, prefix); if (!fs.existsSync(directory)) continue; for (const file of files(directory)) for (const side of ['client', 'server']) { if (prefix !== 'overrides' && prefix !== `${side}-overrides`) continue; sides[side].set(file, { path: file, owner: 'pack', policy: 'replace', hashes: { sha256: fileHash(path.join(directory, file)) } }); } } const directory = path.join(release, 'server/config'); for (const file of files(directory)) { const target = `config/${file}`; const previous = sides.server.get(target); sides.server.set(target, { path: target, owner: 'server', policy: 'replace', hashes: { sha256: fileHash(path.join(directory, file)) }, ...(previous ? { overrides: previous.owner } : {}) }); } return Object.fromEntries(Object.entries(sides).map(([side, entries]) => [side, [...entries.values()].sort((a, b) => a.path.localeCompare(b.path))])); } export function packwizContext({ root, configFile = 'platform/config.yaml' }) { root = fs.realpathSync(root); const config = validateConfig(yaml(fs.readFileSync(inside(root, configFile), 'utf8'), 'platform config')); if (path.isAbsolute(config.pack.directory)) fail('Pack directory must be relative to repository'); const source = inside(root, config.pack.directory); inside(root, path.join(config.pack.directory, 'pack.toml')); if (!fs.statSync(path.join(source, 'pack.toml')).isFile()) fail('pack.toml must be a regular file'); const lock = readLock(inside(root, 'toolchain.lock.env')); const executable = path.join(root, '.tools/packwiz/packwiz'); if (!fs.existsSync(executable)) fail('Tools missing: run scripts/spike-tools.sh'); if (fileHash(executable) !== lock.PACKWIZ_BINARY_SHA256) fail('Pinned packwiz checksum mismatch'); return { source, executable, args: ['--cache', path.join(root, '.tools/packwiz/cache'), '--config', path.join(root, '.tools/packwiz/config.toml')] }; } export function build({ root, configFile = 'platform/config.yaml', releaseOnly = false, outputDirectory, log = console.log }) { root = fs.realpathSync(root); const configuration = inside(root, configFile); const config = validateConfig(yaml(fs.readFileSync(configuration, 'utf8'), 'platform config')); if (path.isAbsolute(config.pack.directory) || path.isAbsolute(config.build.output_directory)) fail('Pack/output directories must be relative to repository'); const source = inside(root, config.pack.directory), output = inside(root, outputDirectory ?? config.build.output_directory); // Build may never write into source, runtime, tooling, or a parent thereof. const protectedPaths = [source, ...['server', 'platform', 'scripts', 'runtime', '.tools', '.git', 'node_modules', 'docs', 'examples', 'testdata'].map(v => path.join(root, v))]; for (const protectedPath of protectedPaths) if (output === protectedPath || output.startsWith(protectedPath + path.sep) || protectedPath.startsWith(output + path.sep)) fail('Output overlaps a protected/source directory'); for (const value of ['server/compose.yaml', 'server/.env.example', 'server/config', 'toolchain.lock.env', 'platform/compatibility.json', 'package-lock.json']) inside(root, value); const lock = readLock(path.join(root, 'toolchain.lock.env')); const compatibility = JSON.parse(fs.readFileSync(path.join(root, 'platform/compatibility.json'), 'utf8')); const validated = validatePack(source, compatibility, lock.MC_IMAGE); const { pack, minecraft, loader, loaderVersion, verified } = validated; const toolFiles = { packwiz: path.join(root, '.tools/packwiz/packwiz'), compose: path.join(root, '.tools/docker-compose') }; for (const [tool, file] of Object.entries(toolFiles)) { if (!fs.existsSync(file)) fail('Tools missing: run scripts/spike-tools.sh'); if (fileHash(file) !== lock[tool === 'packwiz' ? 'PACKWIZ_BINARY_SHA256' : 'COMPOSE_SHA256']) fail(`Pinned ${tool} checksum mismatch`); } const gitCommit = run('git', ['rev-parse', 'HEAD'], { cwd: root }).trim(); const tracked = run('git', ['ls-files', '-z'], { cwd: root }).split('\0').filter(Boolean); const outputRelative = path.relative(root, output).split(path.sep).join('/'); if (tracked.some(file => file !== `${outputRelative}/.gitkeep` && (file === outputRelative || file.startsWith(outputRelative + '/')))) fail('Output directory contains tracked files'); const statusArgs = ['status', '--porcelain', '--untracked-files=all', '--', '.', `:(exclude)${outputRelative}`]; const gitStatus = run('git', statusArgs, { cwd: root }); const gitDirty = gitStatus.length > 0; if (tracked.some(file => file.split('/').some(secretName))) fail('Known secret path is tracked by Git'); if (releaseOnly && gitDirty) fail('--release requires a clean Git worktree'); const releaseName = `${pack.name}-${pack.version}`, artifact = `${releaseName}.mrpack`, destination = path.join(output, releaseName); if (fs.existsSync(destination)) fail(`Release already exists: ${destination}; choose a new pack version or output directory`); fs.mkdirSync(output, { recursive: true }); const lockDirectory = path.join(output, `.build-${releaseName}.lock`); try { fs.mkdirSync(lockDirectory); } catch { fail(`Build is already locked: ${lockDirectory}`); } let temporary; try { temporary = fs.mkdtempSync(path.join(output, `.build-${releaseName}-`)); const copied = path.join(temporary, 'source'), release = path.join(temporary, 'release'); fs.cpSync(source, copied, { recursive: true }); fs.mkdirSync(release); const inputFiles = [...validated.tree.map(file => path.join(source, file)), ...['server/compose.yaml', 'server/.env.example', 'toolchain.lock.env', 'platform/compatibility.json', 'package-lock.json', 'package.json', 'platform/build.mjs', 'platform/commands.mjs', 'platform/runtime.mjs', 'platform/backups.mjs', 'platform/health.mjs', 'platform/deployment.mjs', 'platform/ssh-deploy.mjs', 'platform/rollback.mjs', 'scripts/deploy-worker.mjs', 'scripts/mcpack.mjs'].map(file => path.join(root, file)), ...files(path.join(root, 'server/config')).map(file => path.join(root, 'server/config', file)), configuration]; const inputs = [...new Set(inputFiles)].sort().map(file => ({ path: path.relative(root, file).split(path.sep).join('/'), sha256: fileHash(file) })); const copiedHashes = files(copied).map(file => [file, fileHash(path.join(copied, file))]); for (const [file, sha256] of copiedHashes) if (sha256 !== fileHash(path.join(source, file))) fail(`Source changed while copying: ${file}`); const packwizArgs = ['--cache', path.join(root, '.tools/packwiz/cache'), '--config', path.join(root, '.tools/packwiz/config.toml')]; run(toolFiles.packwiz, [...packwizArgs, 'refresh'], { cwd: copied, log }); const refreshed = files(copied).map(file => [file, fileHash(path.join(copied, file))]); if (JSON.stringify(copiedHashes) !== JSON.stringify(refreshed)) fail('Stale source: run packwiz refresh explicitly; build never changes source'); run(toolFiles.packwiz, [...packwizArgs, 'modrinth', 'export', '--output', path.join(release, artifact)], { cwd: copied, log }); const exportedTree = files(copied).map(file => [file, fileHash(path.join(copied, file))]); if (JSON.stringify(copiedHashes) !== JSON.stringify(exportedTree)) fail('Packwiz export changed source metadata'); const archive = path.join(release, artifact), extracted = path.join(temporary, 'extracted'); const zipPaths = run('bsdtar', ['-tf', archive]).trim().split('\n').filter(Boolean); for (const file of zipPaths) if (!safeRelative(file.replace(/\/$/, ''))) fail(`Unsafe archive path: ${file}`); fs.mkdirSync(extracted); run('bsdtar', ['--no-same-owner', '-xf', archive, '-C', extracted]); files(extracted); const exported = JSON.parse(fs.readFileSync(path.join(extracted, 'modrinth.index.json'), 'utf8')); if (exported.name !== pack.name || exported.versionId !== pack.version || exported.dependencies?.minecraft !== minecraft || exported.dependencies?.[loader] !== loaderVersion || Object.keys(exported.dependencies).length !== 2) fail('Export metadata differs from source pack'); generatedServer(root, release, artifact, config, lock); const composeEnv = { PATH: process.env.PATH, HOME: process.env.HOME, MC_ROOT: root, MC_RELEASE_DIR: release }; run(toolFiles.compose, ['--project-name', 'minecraft-lab-spike', '--env-file', path.join(release, 'server/.env.example'), '-f', path.join(release, 'server/compose.yaml'), 'config', '--quiet'], { env: composeEnv }); const managed = managedFiles(extracted, exported, release); const manifest = { schema_version: 1, pack: { name: pack.name, version: pack.version, minecraft, loader: { id: loader, version: loaderVersion } }, artifact, git: { commit: gitCommit, dirty: gitDirty }, tools: { packwiz: { commit: lock.PACKWIZ_COMMIT, sha256: lock.PACKWIZ_BINARY_SHA256 }, compose: { version: lock.COMPOSE_VERSION, sha256: lock.COMPOSE_SHA256 }, node: process.version, npm_lock_sha256: fileHash(path.join(root, 'package-lock.json')) }, server: { image: lock.MC_IMAGE, java_major: verified.java_major, memory: config.server.memory, port: config.server.port, compose_project: 'minecraft-lab-spike' }, source_files: inputs, managed_files: managed, runtime_policy: { unmanaged: 'preserve', defaultconfigs: 'seed-only' }, release_files: files(release).map(file => ({ path: file, sha256: fileHash(path.join(release, file)) })) }; fs.writeFileSync(path.join(release, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n'); const checksums = files(release).map(file => `${fileHash(path.join(release, file))} ${file}\n`).join(''); fs.writeFileSync(path.join(release, 'SHA256SUMS'), checksums); run('sha256sum', ['-c', 'SHA256SUMS'], { cwd: release, log }); for (const input of inputs) if (fileHash(path.join(root, input.path)) !== input.sha256) fail(`Source changed during build: ${input.path}`); for (const [tool, file] of Object.entries(toolFiles)) if (fileHash(file) !== lock[tool === 'packwiz' ? 'PACKWIZ_BINARY_SHA256' : 'COMPOSE_SHA256']) fail(`Pinned ${tool} changed during build`); if (run('git', ['rev-parse', 'HEAD'], { cwd: root }).trim() !== gitCommit || run('git', statusArgs, { cwd: root }) !== gitStatus) fail('Git state changed during build'); if (fs.existsSync(destination)) fail('Release destination appeared during build'); fs.renameSync(release, destination); return destination; } finally { if (temporary) fs.rmSync(temporary, { recursive: true, force: true }); fs.rmdirSync(lockDirectory); } }