import { createHash } from 'node:crypto'; import { createReadStream, createWriteStream } from 'node:fs'; import { mkdir, lstat, rename, rm, stat } from 'node:fs/promises'; import { dirname, isAbsolute, resolve, sep } from 'node:path'; import { Readable, Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { randomUUID } from 'node:crypto'; import type { GameFileEntry } from '../../game-files.js'; export function validateGamePath(input: unknown): string { if (typeof input !== 'string' || !input || input.length > 1024 || /[\\\x00-\x1f\x7f:]/.test(input) || Buffer.from(input).toString('utf8') !== input || isAbsolute(input) || input.split('/').some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part))) { throw new Error('Game file paths must be relative, normalized paths without traversal.'); } return input; } export function validateManifest(input: unknown): GameFileEntry[] { if (!Array.isArray(input) || !input.length || input.length > 200_000) throw new Error('Provide a manifest containing 1–200000 files.'); const paths = new Set(); let total = 0; const files = input.map(raw => { if (!raw || typeof raw !== 'object') throw new Error('Invalid manifest entry.'); const path = validateGamePath(raw.path); if (!Number.isSafeInteger(raw.size) || raw.size < 0 || typeof raw.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(raw.sha256)) throw new Error(`Invalid size or SHA256 for ${path}.`); const key = path.toLowerCase(); if (paths.has(key)) throw new Error(`Duplicate game file path: ${path}.`); paths.add(key); total += raw.size; if (!Number.isSafeInteger(total)) throw new Error('Library size exceeds the supported limit.'); return { path, size: raw.size as number, sha256: raw.sha256 as string }; }).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); for (const file of files) { const parts = file.path.toLowerCase().split('/'); parts.pop(); while (parts.length) { if (paths.has(parts.join('/'))) throw new Error('A manifest path cannot be both a file and a directory.'); parts.pop(); } } return files; } export const manifestChecksum = (files: GameFileEntry[]): string => createHash('sha256').update(JSON.stringify(files)).digest('hex'); export function gameFilePath(root: string, path: string): string { const base = resolve(root); const result = resolve(base, validateGamePath(path)); if (!result.startsWith(base + sep)) throw new Error('Invalid game file path.'); return result; } /** Refuse symlinks at every existing component, including the managed root. */ export async function assertRegularPath(root: string, path: string, allowMissing = false): Promise { const result = gameFilePath(root, path); let current = resolve(root); const components = [current, ...validateGamePath(path).split('/').map(part => current = resolve(current, part))]; for (let i = 0; i < components.length; i++) { let info; try { info = await lstat(components[i]); } catch (cause) { if (allowMissing && (cause as NodeJS.ErrnoException).code === 'ENOENT') continue; throw cause; } if (info.isSymbolicLink() || (i < components.length - 1 ? !info.isDirectory() : !info.isFile())) throw new Error('Game storage contains a non-regular path.'); } return result; } /** Compute a file digest without buffering game archives in memory. */ export async function hashGameFile(root: string, file: Pick): Promise { const path = await assertRegularPath(root, file.path); const info = await stat(path); if (info.size !== file.size) throw new Error(`Size verification failed for ${file.path}.`); const hash = createHash('sha256'); for await (const chunk of createReadStream(path)) hash.update(chunk); return hash.digest('hex'); } export async function verifyGameFile(root: string, file: GameFileEntry): Promise { if (await hashGameFile(root, file) !== file.sha256) throw new Error(`Checksum verification failed for ${file.path}.`); } /** Stream to a private temporary file; publish only after byte count and SHA256 match. */ export async function receiveGameFile(root: string, file: GameFileEntry, body: ReadableStream | null): Promise { if (!body) throw new Error('A file body is required.'); const path = await assertRegularPath(root, file.path, true); await mkdir(dirname(path), { recursive: true }); const temp = `${path}.upload-${randomUUID()}`; let size = 0; const hash = createHash('sha256'); const verifier = new Transform({ transform(chunk, _encoding, done) { size += chunk.length; if (size > file.size) return done(new Error('Upload exceeds the manifest size.')); hash.update(chunk); done(null, chunk); } }); try { await pipeline(Readable.fromWeb(body as never), verifier, createWriteStream(temp, { flags: 'wx', mode: 0o600, flush: true })); if (size !== file.size || hash.digest('hex') !== file.sha256) throw new Error(`Upload checksum or size mismatch for ${file.path}.`); await rename(temp, path); } finally { await rm(temp, { force: true }); } } export function parseByteRange(header: string | null, size: number): { start: number; end: number } | null { if (!header) return null; const match = /^bytes=(\d*)-(\d*)$/.exec(header); if (!match || (!match[1] && !match[2]) || size === 0) throw new Error('Unsatisfiable range.'); let start = match[1] ? Number(match[1]) : Math.max(0, size - Number(match[2])); let end = match[1] && match[2] ? Number(match[2]) : size - 1; if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= size || end < start || (!match[1] && Number(match[2]) === 0)) throw new Error('Unsatisfiable range.'); return { start, end: Math.min(end, size - 1) }; }