Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+75 -18
View File
@@ -6,41 +6,98 @@ import { getLibraryFile, libraryFilesRetired } from './index.js';
import { assertRegularPath, parseByteRange } from './storage.js';
export function gameFileError(cause: unknown): Response {
const status = typeof cause === 'object' && cause !== null && 'status' in cause && typeof cause.status === 'number' ? cause.status : 400;
const receivedBytes = typeof cause === 'object' && cause !== null && 'receivedBytes' in cause && Number.isSafeInteger(cause.receivedBytes) ? cause.receivedBytes : undefined;
return json({ error: cause instanceof Error ? cause.message : 'Game file request failed.', ...(receivedBytes === undefined ? {} : { receivedBytes }) }, { status });
const status =
typeof cause === 'object' &&
cause !== null &&
'status' in cause &&
typeof cause.status === 'number'
? cause.status
: 400;
const receivedBytes =
typeof cause === 'object' &&
cause !== null &&
'receivedBytes' in cause &&
Number.isSafeInteger(cause.receivedBytes)
? cause.receivedBytes
: undefined;
return json(
{
error: cause instanceof Error ? cause.message : 'Game file request failed.',
...(receivedBytes === undefined ? {} : { receivedBytes })
},
{ status }
);
}
export async function readGameJson(request: Request, maxBytes = 64 * 1024): Promise<Record<string, unknown>> {
export async function readGameJson(
request: Request,
maxBytes = 64 * 1024
): Promise<Record<string, unknown>> {
if (!request.body) throw new Error('A JSON request body is required.');
const reader = request.body.getReader();
const chunks: Uint8Array[] = []; let bytes = 0;
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
while (true) {
const next = await reader.read(); if (next.done) break;
const next = await reader.read();
if (next.done) break;
bytes += next.value.byteLength;
if (bytes > maxBytes) { await reader.cancel(); throw new Error('Request body exceeds its size limit.'); }
if (bytes > maxBytes) {
await reader.cancel();
throw new Error('Request body exceeds its size limit.');
}
chunks.push(next.value);
}
} finally { reader.releaseLock(); }
} finally {
reader.releaseLock();
}
const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Expected a JSON object.');
if (!body || typeof body !== 'object' || Array.isArray(body))
throw new Error('Expected a JSON object.');
return body;
}
export async function serveGameFile(request: Request, versionId: string, requestedPath: string): Promise<Response> {
if (libraryFilesRetired(versionId)) return json({ error: 'These outdated game files have been removed.' }, { status: 410 });
export async function serveGameFile(
request: Request,
versionId: string,
requestedPath: string
): Promise<Response> {
if (libraryFilesRetired(versionId))
return json({ error: 'These outdated game files have been removed.' }, { status: 410 });
const file = getLibraryFile(versionId, requestedPath);
if (!file || file.state !== 'ready') return json({ error: 'File is not in a published manifest.' }, { status: 404 });
if (!file || file.state !== 'ready')
return json({ error: 'File is not in a published manifest.' }, { status: 404 });
const path = await assertRegularPath(file.storageLocation, file.path);
if ((await stat(path)).size !== file.size) return json({ error: 'Stored file failed size verification.' }, { status: 503 });
if ((await stat(path)).size !== file.size)
return json({ error: 'Stored file failed size verification.' }, { status: 503 });
const etag = `"${file.sha256}"`;
const headers = new Headers({ 'Content-Type': 'application/octet-stream', 'Accept-Ranges': 'bytes', ETag: etag, 'Cache-Control': 'private, no-cache', 'X-Content-SHA256': file.sha256 });
if (request.headers.get('if-none-match') === etag) return new Response(null, { status: 304, headers });
const headers = new Headers({
'Content-Type': 'application/octet-stream',
'Accept-Ranges': 'bytes',
ETag: etag,
'Cache-Control': 'private, no-cache',
'X-Content-SHA256': file.sha256
});
if (request.headers.get('if-none-match') === etag)
return new Response(null, { status: 304, headers });
let range;
try { range = parseByteRange(request.headers.has('if-range') && request.headers.get('if-range') !== etag ? null : request.headers.get('range'), file.size); }
catch { headers.set('Content-Range', `bytes */${file.size}`); return new Response(null, { status: 416, headers }); }
try {
range = parseByteRange(
request.headers.has('if-range') && request.headers.get('if-range') !== etag
? null
: request.headers.get('range'),
file.size
);
} catch {
headers.set('Content-Range', `bytes */${file.size}`);
return new Response(null, { status: 416, headers });
}
headers.set('Content-Length', String(range ? range.end - range.start + 1 : file.size));
if (range) headers.set('Content-Range', `bytes ${range.start}-${range.end}/${file.size}`);
const body = request.method === 'HEAD' ? null : Readable.toWeb(createReadStream(path, range ?? undefined)) as ReadableStream<Uint8Array>;
const body =
request.method === 'HEAD'
? null
: (Readable.toWeb(
createReadStream(path, range ?? undefined)
) as ReadableStream<Uint8Array>);
return new Response(body, { status: range ? 206 : 200, headers });
}
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -50,10 +50,17 @@ export function ensureGameFileSchema(handle: Database.Database): void {
WHEN OLD.state = 'ready' BEGIN SELECT RAISE(ABORT, 'Published game-file versions are immutable'); END;
CREATE INDEX IF NOT EXISTS idx_host_library_caches_version ON host_library_caches(version_id);
`);
const versionColumns = handle.prepare('PRAGMA table_info(game_file_versions)').all() as { name: string }[];
if (!versionColumns.some(column => column.name === 'last_activity_at')) handle.exec('ALTER TABLE game_file_versions ADD COLUMN last_activity_at TEXT');
const cacheColumns = handle.prepare('PRAGMA table_info(host_library_caches)').all() as { name: string }[];
if (!cacheColumns.some(column => column.name === 'bytes_verified')) handle.exec('ALTER TABLE host_library_caches ADD COLUMN bytes_verified INTEGER');
if (!cacheColumns.some(column => column.name === 'transfer_progress')) handle.exec('ALTER TABLE host_library_caches ADD COLUMN transfer_progress TEXT');
const versionColumns = handle.prepare('PRAGMA table_info(game_file_versions)').all() as {
name: string;
}[];
if (!versionColumns.some((column) => column.name === 'last_activity_at'))
handle.exec('ALTER TABLE game_file_versions ADD COLUMN last_activity_at TEXT');
const cacheColumns = handle.prepare('PRAGMA table_info(host_library_caches)').all() as {
name: string;
}[];
if (!cacheColumns.some((column) => column.name === 'bytes_verified'))
handle.exec('ALTER TABLE host_library_caches ADD COLUMN bytes_verified INTEGER');
if (!cacheColumns.some((column) => column.name === 'transfer_progress'))
handle.exec('ALTER TABLE host_library_caches ADD COLUMN transfer_progress TEXT');
initialized.add(handle);
}
+104 -35
View File
@@ -8,39 +8,62 @@ 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))) {
if (
typeof input !== 'string' ||
!input ||
input.length > 1024 ||
// eslint-disable-next-line no-control-regex -- Reject control characters in game file paths.
/[\\\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.');
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);
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.');
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 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);
@@ -50,21 +73,41 @@ export function gameFilePath(root: string, path: string): string {
}
/** Refuse symlinks at every existing component, including the managed root. */
export async function assertRegularPath(root: string, path: string, allowMissing = false): Promise<string> {
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))];
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.');
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> {
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}.`);
@@ -74,35 +117,61 @@ export async function hashGameFile(root: string, file: Pick<GameFileEntry, 'path
}
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}.`);
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> {
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);
} });
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 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 }); }
} finally {
await rm(temp, { force: true });
}
}
export function parseByteRange(header: string | null, size: number): { start: number; end: number } | null {
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.');
const start = match[1] ? Number(match[1]) : Math.max(0, size - Number(match[2]));
const 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) };
}
+122 -47
View File
@@ -32,28 +32,47 @@ export class UploadOffsetError extends Error {
/** Apply the same path, case, size and file/directory collision rules as published manifests. */
export function validateUploadPlan(input: unknown): UploadFilePlan[] {
if (!Array.isArray(input) || !input.length || input.length > 200_000) throw new Error('Provide a folder containing 1–200000 files.');
if (!Array.isArray(input) || !input.length || input.length > 200_000)
throw new Error('Provide a folder containing 1–200000 files.');
const modified = new Map<string, number>();
const manifest = validateManifest(input.map(raw => {
if (!raw || typeof raw !== 'object') throw new Error('Invalid upload file.');
const lastModified = raw.lastModified === undefined ? 0 : raw.lastModified;
if (!Number.isSafeInteger(lastModified) || lastModified < 0) throw new Error('Invalid file modification time.');
modified.set(raw.path, lastModified);
return { path: raw.path, size: raw.size, sha256: '0'.repeat(64) };
const manifest = validateManifest(
input.map((raw) => {
if (!raw || typeof raw !== 'object') throw new Error('Invalid upload file.');
const lastModified = raw.lastModified === undefined ? 0 : raw.lastModified;
if (!Number.isSafeInteger(lastModified) || lastModified < 0)
throw new Error('Invalid file modification time.');
modified.set(raw.path, lastModified);
return { path: raw.path, size: raw.size, sha256: '0'.repeat(64) };
})
);
return manifest.map((file) => ({
path: file.path,
size: file.size,
lastModified: modified.get(file.path)!
}));
return manifest.map(file => ({ path: file.path, size: file.size, lastModified: modified.get(file.path)! }));
}
interface UploadLocks { barrier: Promise<void>; writers: number; files: Map<string, Promise<void>>; active: Set<Promise<void>> }
interface UploadLocks {
barrier: Promise<void>;
writers: number;
files: Map<string, Promise<void>>;
active: Set<Promise<void>>;
}
const uploadLocks = new Map<string, UploadLocks>();
export function uploadIsActive(root: string): boolean { return uploadLocks.has(resolve(root)); }
export function uploadIsActive(root: string): boolean {
return uploadLocks.has(resolve(root));
}
function locksFor(key: string): UploadLocks {
let locks = uploadLocks.get(key);
if (!locks) { locks = { barrier: Promise.resolve(), writers: 0, files: new Map(), active: new Set() }; uploadLocks.set(key, locks); }
if (!locks) {
locks = { barrier: Promise.resolve(), writers: 0, files: new Map(), active: new Set() };
uploadLocks.set(key, locks);
}
return locks;
}
function releaseLocks(key: string, locks: UploadLocks) {
if (!locks.writers && !locks.active.size && uploadLocks.get(key) === locks) uploadLocks.delete(key);
if (!locks.writers && !locks.active.size && uploadLocks.get(key) === locks)
uploadLocks.delete(key);
}
/** Serialize transfers and final publication for one import; a rejected operation never poisons the lock. */
@@ -64,12 +83,15 @@ export async function withUploadLock<T>(root: string, callback: () => Promise<T>
// from starting until publication finishes (including failed publication).
const previous = Promise.all([locks.barrier, ...locks.active]);
let release!: () => void;
const current = new Promise<void>(done => { release = done; });
const current = new Promise<void>((done) => {
release = done;
});
locks.barrier = current;
locks.writers++;
await previous;
try { return await callback(); }
finally {
try {
return await callback();
} finally {
release();
locks.writers--;
releaseLocks(key, locks);
@@ -77,31 +99,47 @@ export async function withUploadLock<T>(root: string, callback: () => Promise<T>
}
/** Different files may transfer together; offsets for the same file remain serial. */
export async function withUploadFileLock<T>(root: string, file: string, callback: () => Promise<T>): Promise<T> {
const key = resolve(root), locks = locksFor(key);
export async function withUploadFileLock<T>(
root: string,
file: string,
callback: () => Promise<T>
): Promise<T> {
const key = resolve(root),
locks = locksFor(key);
const previous = Promise.all([locks.barrier, locks.files.get(file)]);
let release!: () => void;
const current = new Promise<void>(done => { release = done; });
locks.files.set(file, current); locks.active.add(current);
const current = new Promise<void>((done) => {
release = done;
});
locks.files.set(file, current);
locks.active.add(current);
await previous;
try { return await callback(); }
finally {
release(); locks.active.delete(current);
try {
return await callback();
} finally {
release();
locks.active.delete(current);
if (locks.files.get(file) === current) locks.files.delete(file);
releaseLocks(key, locks);
}
}
/** File size is the resume checkpoint, including after a controller restart during an append. */
export async function getUploadFileProgress(root: string, file: Pick<UploadFilePlan, 'path' | 'size'>): Promise<UploadFileProgress> {
export async function getUploadFileProgress(
root: string,
file: Pick<UploadFilePlan, 'path' | 'size'>
): Promise<UploadFileProgress> {
const path = await assertRegularPath(root, file.path, true);
try {
const info = await lstat(path);
if (!info.isFile() || info.isSymbolicLink()) throw new Error('Game storage contains a non-regular path.');
if (info.size > file.size) throw new Error(`Stored upload exceeds the declared size of ${file.path}.`);
if (!info.isFile() || info.isSymbolicLink())
throw new Error('Game storage contains a non-regular path.');
if (info.size > file.size)
throw new Error(`Stored upload exceeds the declared size of ${file.path}.`);
return { receivedBytes: info.size, complete: info.size === file.size };
} catch (cause) {
if ((cause as NodeJS.ErrnoException).code === 'ENOENT') return { receivedBytes: 0, complete: false };
if ((cause as NodeJS.ErrnoException).code === 'ENOENT')
return { receivedBytes: 0, complete: false };
throw cause;
}
}
@@ -118,10 +156,17 @@ export async function receiveUploadChunk(
body: ReadableStream<Uint8Array> | null,
beforeWrite?: () => Promise<void> | void
): Promise<UploadFileProgress> {
if (!Number.isSafeInteger(chunk.offset) || chunk.offset < 0 || !Number.isSafeInteger(chunk.size)
|| chunk.size < 0 || chunk.size > MAX_UPLOAD_CHUNK_BYTES || chunk.offset + chunk.size > file.size
|| !Number.isSafeInteger(chunk.offset + chunk.size) || !/^[a-f0-9]{64}$/.test(chunk.sha256)
|| (chunk.size === 0 && file.size !== 0)) {
if (
!Number.isSafeInteger(chunk.offset) ||
chunk.offset < 0 ||
!Number.isSafeInteger(chunk.size) ||
chunk.size < 0 ||
chunk.size > MAX_UPLOAD_CHUNK_BYTES ||
chunk.offset + chunk.size > file.size ||
!Number.isSafeInteger(chunk.offset + chunk.size) ||
!/^[a-f0-9]{64}$/.test(chunk.sha256) ||
(chunk.size === 0 && file.size !== 0)
) {
throw new Error('Invalid upload chunk size, offset or SHA256.');
}
if (!body && chunk.size !== 0) throw new Error('A file chunk body is required.');
@@ -129,7 +174,8 @@ export async function receiveUploadChunk(
return withUploadFileLock(root, file.path, async () => {
await beforeWrite?.();
const progress = await getUploadFileProgress(root, file);
if (chunk.offset !== progress.receivedBytes) throw new UploadOffsetError(progress.receivedBytes);
if (chunk.offset !== progress.receivedBytes)
throw new UploadOffsetError(progress.receivedBytes);
const destination = await assertRegularPath(root, file.path, true);
// Keep transient chunks outside the tree that will become the immutable library.
const chunkRoot = `${resolve(root)}.chunks`;
@@ -137,19 +183,31 @@ export async function receiveUploadChunk(
await mkdir(dirname(temporary), { recursive: true });
let received = 0;
const hash = createHash('sha256');
const verifier = new Transform({ transform(data, _encoding, done) {
received += data.length;
if (received > chunk.size) return done(new Error('Upload exceeds the declared chunk size.'));
hash.update(data); done(null, data);
} });
const verifier = new Transform({
transform(data, _encoding, done) {
received += data.length;
if (received > chunk.size)
return done(new Error('Upload exceeds the declared chunk size.'));
hash.update(data);
done(null, data);
}
});
try {
await pipeline(body ? Readable.fromWeb(body as never) : Readable.from([]), verifier,
createWriteStream(temporary, { flags: 'wx', mode: 0o600, flush: true }));
if (received !== chunk.size || hash.digest('hex') !== chunk.sha256) throw new Error('Upload chunk checksum or size mismatch.');
await pipeline(
body ? Readable.fromWeb(body as never) : Readable.from([]),
verifier,
createWriteStream(temporary, { flags: 'wx', mode: 0o600, flush: true })
);
if (received !== chunk.size || hash.digest('hex') !== chunk.sha256)
throw new Error('Upload chunk checksum or size mismatch.');
await mkdir(dirname(destination), { recursive: true });
await assertRegularPath(root, file.path, true);
const target = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
const target = await open(
destination,
constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW,
0o600
);
try {
const info = await target.stat();
if (!info.isFile()) throw new Error('Game storage contains a non-regular path.');
@@ -160,8 +218,14 @@ export async function receiveUploadChunk(
const bytes = data as Buffer;
let written = 0;
while (written < bytes.length) {
const result = await target.write(bytes, written, bytes.length - written, position);
if (result.bytesWritten === 0) throw new Error('Unable to append the upload chunk.');
const result = await target.write(
bytes,
written,
bytes.length - written,
position
);
if (result.bytesWritten === 0)
throw new Error('Unable to append the upload chunk.');
written += result.bytesWritten;
position += result.bytesWritten;
}
@@ -172,15 +236,26 @@ export async function receiveUploadChunk(
await target.sync();
throw cause;
}
} finally { await target.close(); }
return { receivedBytes: chunk.offset + chunk.size, complete: chunk.offset + chunk.size === file.size };
} finally { await rm(temporary, { force: true }); }
} finally {
await target.close();
}
return {
receivedBytes: chunk.offset + chunk.size,
complete: chunk.offset + chunk.size === file.size
};
} finally {
await rm(temporary, { force: true });
}
});
}
/** Caller holds withUploadLock through hashing and publication to prevent concurrent writes. */
export async function hashUploadedFiles(root: string, files: ReadonlyArray<Pick<UploadFilePlan, 'path' | 'size'>>): Promise<GameFileEntry[]> {
export async function hashUploadedFiles(
root: string,
files: ReadonlyArray<Pick<UploadFilePlan, 'path' | 'size'>>
): Promise<GameFileEntry[]> {
const manifest: GameFileEntry[] = [];
for (const file of files) manifest.push({ path: file.path, size: file.size, sha256: await hashGameFile(root, file) });
for (const file of files)
manifest.push({ path: file.path, size: file.size, sha256: await hashGameFile(root, file) });
return validateManifest(manifest);
}