100 lines
4.1 KiB
TypeScript
100 lines
4.1 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import { mkdtemp, mkdir, readFile, readdir, rm, symlink } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
import {
|
|
assertRegularPath,
|
|
manifestChecksum,
|
|
parseByteRange,
|
|
receiveGameFile,
|
|
validateManifest,
|
|
validateGamePath,
|
|
verifyGameFile
|
|
} from '../../src/lib/server/game-files/storage.js';
|
|
|
|
const sha = (data: string) => createHash('sha256').update(data).digest('hex');
|
|
const entry = (path = 'zone/file.ff', data = 'game data') => ({
|
|
path,
|
|
size: Buffer.byteLength(data),
|
|
sha256: sha(data)
|
|
});
|
|
const stream = (value: string) => new Response(value).body!;
|
|
let directory: string;
|
|
afterEach(async () => {
|
|
if (directory) await rm(directory, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('game-file manifests', () => {
|
|
it.each([
|
|
'../escape',
|
|
'/absolute',
|
|
'a/../b',
|
|
'a//b',
|
|
'a\\b',
|
|
'C:/file',
|
|
'./file',
|
|
'a\0b',
|
|
'a/file.',
|
|
'a/file '
|
|
])('rejects unsafe or ambiguous path %s', (path) => {
|
|
expect(() => validateGamePath(path)).toThrow();
|
|
});
|
|
it('canonicalizes file order, rejects duplicates, and hashes the canonical manifest', () => {
|
|
const files = validateManifest([entry('z.ff'), entry('a.ff')]);
|
|
expect(files.map((file) => file.path)).toEqual(['a.ff', 'z.ff']);
|
|
expect(manifestChecksum(files)).toBe(sha(JSON.stringify(files)));
|
|
expect(() => validateManifest([entry('A.ff'), entry('a.ff')])).toThrow(/Duplicate/);
|
|
expect(() => validateManifest([entry('zone'), entry('zone/file.ff')])).toThrow(
|
|
/file and a directory/
|
|
);
|
|
expect(() => validateManifest([{ ...entry(), size: -1 }])).toThrow();
|
|
expect(() => validateManifest([{ ...entry(), sha256: 'bad' }])).toThrow();
|
|
});
|
|
});
|
|
describe('streaming storage integrity', () => {
|
|
it('atomically accepts only complete verified streams and cleans partial uploads', async () => {
|
|
directory = await mkdtemp(join(tmpdir(), 'ec-game-storage-'));
|
|
const file = entry();
|
|
await expect(receiveGameFile(directory, file, stream('bad'))).rejects.toThrow(/mismatch/);
|
|
expect(await readdir(join(directory, 'zone'))).toEqual([]);
|
|
await expect(
|
|
receiveGameFile(directory, file, stream('game data too long'))
|
|
).rejects.toThrow(/exceeds/);
|
|
await receiveGameFile(directory, file, stream('game data'));
|
|
expect(await readFile(join(directory, file.path), 'utf8')).toBe('game data');
|
|
await verifyGameFile(directory, file);
|
|
await expect(
|
|
verifyGameFile(directory, { ...file, sha256: sha('something') })
|
|
).rejects.toThrow(/Checksum/);
|
|
});
|
|
it('refuses symlinks before writing or reading shared files', async () => {
|
|
directory = await mkdtemp(join(tmpdir(), 'ec-game-symlink-'));
|
|
const root = join(directory, 'root');
|
|
await mkdir(root);
|
|
const outside = join(directory, 'outside');
|
|
await mkdir(outside);
|
|
await symlink(outside, join(root, 'zone'));
|
|
await expect(receiveGameFile(root, entry(), stream('game data'))).rejects.toThrow(
|
|
/non-regular/
|
|
);
|
|
await expect(assertRegularPath(root, 'zone/file.ff', true)).rejects.toThrow(/non-regular/);
|
|
expect(await readdir(outside)).toEqual([]);
|
|
});
|
|
});
|
|
describe('HTTP download resume ranges', () => {
|
|
it('supports open, bounded, suffix, and clipped ranges', () => {
|
|
expect(parseByteRange(null, 10)).toBeNull();
|
|
expect(parseByteRange('bytes=3-', 10)).toEqual({ start: 3, end: 9 });
|
|
expect(parseByteRange('bytes=3-5', 10)).toEqual({ start: 3, end: 5 });
|
|
expect(parseByteRange('bytes=-3', 10)).toEqual({ start: 7, end: 9 });
|
|
expect(parseByteRange('bytes=3-99', 10)).toEqual({ start: 3, end: 9 });
|
|
});
|
|
it.each(['bytes=10-', 'bytes=-0', 'bytes=5-3', 'bytes=0-1,3-4', 'bytes=-', 'not-a-range'])(
|
|
'rejects unsatisfiable %s',
|
|
(range) => {
|
|
expect(() => parseByteRange(range, 10)).toThrow();
|
|
}
|
|
);
|
|
});
|