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(); }); });