109 lines
5.9 KiB
TypeScript
109 lines
5.9 KiB
TypeScript
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<string>();
|
||
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<string> {
|
||
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<GameFileEntry, 'path' | 'size'>): Promise<string> {
|
||
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<void> {
|
||
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<Uint8Array> | null): Promise<void> {
|
||
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) };
|
||
}
|