diff --git a/README.md b/README.md index 08139c2..adac1e2 100644 --- a/README.md +++ b/README.md @@ -29,11 +29,13 @@ The default Compose data mount preserves the previous `EC_DATA_DIR` to your preferred persistent directory. Back up that directory, including SQLite metadata and published game-file storage. -1. **Hosts → Add Host**: configure a Debian 12/13 amd64 machine and run its +1. **Library → Game Files**: select T5 or T6 and upload the complete local game + folder, including its DLC files. Name the library and version, then publish it. + Uploads work before any host is enrolled. +2. **Hosts → Add Host**: configure a Debian 12/13 amd64 machine and run its generated enrollment command. -2. Prepare a Plutainer runtime from the host page. -3. **Library → Game Files**: import existing T5/T6 files once, then cache a - published version on the destination host. +3. Prepare a Plutainer runtime from the host page and download the published + game-file version to that host from **Game Files**. 4. Add the exact Plutonium server keys in **Keys**, or supply missing keys when launching. 5. Launch a server or collection with an explicit host, game-file version and @@ -69,9 +71,12 @@ server ID and retained after removal. ## Libraries and campaigns -Game-file versions include game, version, DLC metadata, manifests, checksums and -size. Actual game files use dedicated filesystem storage, never SQLite blobs. -Downloads resume, report progress and verify checksums before becoming usable. +Game-file versions contain the uploaded T5/T6 folder, with its directory layout +and included DLC preserved. There is no separate DLC declaration. Versions record +the game, label, file manifest, checksums and size. Uploads use bounded chunks, +support pause/resume and verify checksums before publication. Actual game files +use dedicated filesystem storage, never SQLite blobs. Host downloads resume, +report progress and verify checksums before becoming usable. Multiple instances on one host share read-only base files. Scripts, plugins, configuration baselines and overlays retain their versioning, diff --git a/docs/authentication.md b/docs/authentication.md index 8f73d6b..d1fca9d 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -31,7 +31,7 @@ guessable token. An unset setup token holds setup closed. | Raw configs, file diffs, logs and telemetry diagnostics | No | Yes | Yes | | Deploy/start/stop/restart/rebuild/remove servers | No | Yes | Yes | | Scripts, plugins, configs, campaigns, betas and collections | Read summaries | Manage | Manage | -| Host setup, enrollment, credentials, runtime preparation and game-file import/cache | Read status | Read status | Manage | +| Host setup, enrollment, credentials, runtime preparation and game-file upload/cache | Read status | Read status | Manage | | Server keys and global settings/maintenance policies | No | No | Manage | | Create users, change roles, disable accounts and reset passwords | No | No | Yes | | Own password and sign out | Yes | Yes | Yes | diff --git a/docs/deployment-blueprints.md b/docs/deployment-blueprints.md index e8da3f8..3954c43 100644 --- a/docs/deployment-blueprints.md +++ b/docs/deployment-blueprints.md @@ -131,7 +131,7 @@ the typed key so it cannot accidentally be saved against a different lobby. Komodo connection settings, target host, RCON configuration, config filename, lobby availability, and port availability are checked locally. These checks do -not probe the host or verify game/DLC files, images, Docker volumes, firewall +not probe the host or verify game files, images, Docker volumes, firewall rules, or credentials. Container and RCON verification happen after provisioning. T6 variants retain their exact catalog slug and launch ruleset; RCON health checks verify the engine map, not the variant's start location. diff --git a/docs/native-hosts.md b/docs/native-hosts.md index 6ba7d41..bc4e74c 100644 --- a/docs/native-hosts.md +++ b/docs/native-hosts.md @@ -17,9 +17,10 @@ the agent owns local execution and its durable operation journal. 3. The installer validates the platform, installs Python 3 and an absent Docker installation, verifies the downloaded agent SHA-256 over HTTPS, enrolls it and enables `electric-cherry-agent.service`. It needs no Git, Node.js or local build. -4. In **Hosts**, prepare a Plutainer runtime image. In **Game Files**, import your - existing `t5_game_files` or `t6_game_files` Docker volume on an enrolled source - host. Name the library and version and record its DLC metadata. +4. In **Hosts**, prepare a Plutainer runtime image. In **Game Files**, select T5 or + T6 and upload your local game folder. Name the library and version and publish + it after the upload finishes. Include the DLC files in that folder; there is + no separate DLC declaration. Uploading also works before enrolling any host. 5. Cache the published version on each destination host. Wait for verification. Hosts report connection and readiness separately; deployment also checks the particular library, image, capacity and ports you selected. @@ -73,8 +74,15 @@ API outline: - `POST /api/agent/heartbeat`: authenticated inventory, current operation and caches. - `GET /api/agent/operations`: deliver/reconcile durable operation IDs. - `POST /api/agent/operations/:id`: authenticated acceptance, progress or result. -- `/api/agent/imports/...` and `/api/agent/libraries/...`: host-scoped uploads and - authenticated game-file downloads. +- `POST /api/game-files`: administrator creates a folder upload with game, name, + version, optional description and `files: [{path, size, lastModified}]`. +- `GET /api/game-files/:id/upload`: durable per-file offsets and publication status. +- `PUT /api/game-files/:id/upload/files/:path`: administrator uploads a raw chunk + with `X-Upload-Offset`, `X-Upload-Length` and `X-Content-SHA256` headers. +- `POST /api/game-files/:id/upload/complete`: administrator starts asynchronous + verification/publication; poll the upload endpoint for completion. +- `/api/agent/libraries/...`: authenticated game-file downloads. Legacy + `/api/agent/imports/...` endpoints remain compatible with already queued imports. The operation envelope is independent of polling and can be carried over a persistent transport later. No unrestricted shell operation is available. @@ -117,15 +125,37 @@ communication loss stops unrelated running containers. ## Libraries and ports -Imports scan the source volume and generate per-file SHA-256 manifests. Uploads -stream to temporary controller storage, are bounded by their manifest sizes and -verified before atomic publication. Published versions cannot be updated; +Choose the actual game folder, not its parent, a ZIP archive or the Plutonium +launcher data folder. The selected folder's name is removed once; nested paths +such as `zone/english/...` and `main/...` stay intact. Plutonium's +[T5 server guide](https://plutonium.pw/docs/server/t5/setting-up-a-server/) and +[T6 server guide](https://plutonium.pw/docs/server/t6/setting-up-a-server/) use a +copy of the game directory, separately from the launcher/runtime. The uploader +preserves the supplied files rather than attempting to infer installed maps from +a manually maintained DLC list. + +Folder uploads use chunks of at most 8 MiB, each checked against its SHA-256 before +being appended to temporary controller storage. Memory usage does not grow with +the size of an individual game file. Pause/retry uses the controller's saved file +offsets. After a page reload, choose **Resume upload** and reselect the original, +unchanged folder; relative paths, sizes and modification times must match. Keep +the page open while transferring. Once publication starts, the controller hashes +every complete file and publishes the directory atomically; closing the browser +does not stop that verification. An interrupted controller can retry publication. +Published versions cannot be updated; changing files requires a new version. Hosts download with HTTP Range resumption, report progress, verify all checksums and atomically promote the complete cache. Corrupt or incomplete content never becomes Ready. Cache inventory is reconciled on heartbeats, including vanished caches. Older immutable versions remain usable when their exact manifest is verified. +The controller and reverse proxy must accept 8 MiB upload requests and folder +manifests up to 32 MiB. Compose sets `BODY_SIZE_LIMIT=Infinity`; application routes +still enforce these limits, declared file sizes and checksums. Set a proxy body +limit of at least 32 MiB (for example `client_max_body_size 32m` in nginx). +Existing published versions and cached host libraries remain usable after the +upload workflow replaces source-host imports. + Reservations include host ID, local bind address, protocol and port. Wildcard binds conflict with concrete addresses on the same host. UDP and TCP reservations for one launch commit together; the same number can be used independently on diff --git a/src/lib/game-files.ts b/src/lib/game-files.ts index 27782db..6067ff3 100644 --- a/src/lib/game-files.ts +++ b/src/lib/game-files.ts @@ -10,10 +10,10 @@ export interface HostLibraryCache { } export interface GameLibraryVersion { id: string; libraryId: string; game: GameLibraryGame; version: string; - description: string; dlc: string[]; state: GameLibraryState; sizeBytes: number; + description: string; state: GameLibraryState; sizeBytes: number; fileCount: number; manifestSha256: string | null; files: GameFileEntry[]; createdAt: string; publishedAt: string | null; storageLocation: string; - sourceHostId: string; sourceVolume: string; error: string | null; operationId: string | null; + sourceHostId: string | null; sourceVolume: string | null; error: string | null; operationId: string | null; caches: HostLibraryCache[]; } export interface GameLibrary { diff --git a/src/lib/server/game-files/http.ts b/src/lib/server/game-files/http.ts index f8f828c..a7e40bc 100644 --- a/src/lib/server/game-files/http.ts +++ b/src/lib/server/game-files/http.ts @@ -7,7 +7,8 @@ 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; - return json({ error: cause instanceof Error ? cause.message : 'Game file request failed.' }, { status }); + 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> { if (!request.body) throw new Error('A JSON request body is required.'); diff --git a/src/lib/server/game-files/index.ts b/src/lib/server/game-files/index.ts index 21f15b7..fca90cf 100644 --- a/src/lib/server/game-files/index.ts +++ b/src/lib/server/game-files/index.ts @@ -6,6 +6,7 @@ import { config } from '../config.js'; import { getHost, getHostOperation, listHosts, queueHostOperation } from '../hosts/index.js'; import { ensureGameFileSchema } from './schema.js'; import { manifestChecksum, receiveGameFile, validateGamePath, validateManifest, verifyGameFile } from './storage.js'; +import { MAX_UPLOAD_CHUNK_BYTES, validateUploadPlan, getUploadFileProgress, receiveUploadChunk, hashUploadedFiles, withUploadLock } from './uploads.js'; import type { GameFileEntry, GameLibrary, GameLibraryGame, GameLibraryVersion, HostLibraryCache, LibraryCacheState } from '../../game-files.js'; function database() { const handle = db(); ensureGameFileSchema(handle); return handle; } @@ -17,10 +18,10 @@ const cacheFromRow = (row: CacheRow): HostLibraryCache => ({ hostId: row.host_id function versionFromRow(row: VersionRow): GameLibraryVersion { const files = JSON.parse(row.manifest) as GameFileEntry[]; return { id: row.id, libraryId: row.library_id, game: row.game, version: row.version, - description: row.description, dlc: JSON.parse(row.dlc), state: row.state, sizeBytes: row.size_bytes, - files, fileCount: files.length, manifestSha256: row.manifest_sha256, createdAt: row.created_at, - publishedAt: row.published_at, storageLocation: row.storage_location, sourceHostId: row.source_host_id, - sourceVolume: row.source_volume, error: row.error, operationId: row.operation_id, + description: row.description, state: row.state, sizeBytes: row.size_bytes, + files, fileCount: files.length || (database().prepare('SELECT COUNT(*) AS count FROM game_file_upload_entries WHERE version_id=?').get(row.id) as { count: number }).count, manifestSha256: row.manifest_sha256, createdAt: row.created_at, + publishedAt: row.published_at, storageLocation: row.storage_location, sourceHostId: row.source_host_id || null, + sourceVolume: row.source_volume || null, error: row.error, operationId: row.operation_id, caches: (database().prepare('SELECT * FROM host_library_caches WHERE version_id = ?').all(row.id) as CacheRow[]).map(cacheFromRow) }; } export function getLibraryVersion(id: string): GameLibraryVersion | null { @@ -60,6 +61,112 @@ function textField(input: unknown, label: string, max: number, required = true): if (typeof input !== 'string' || input.length > max || (required && !input.trim())) throw new Error(`Provide a valid ${label}.`); return input.trim(); } + +/** Register a folder on the controller; no host or Docker volume is needed. */ +export function createLibraryUpload(input: Record): GameLibraryVersion { + const game = input.game; + if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.'); + const name = textField(input.name, 'library name', 160); + const description = textField(input.description ?? '', 'description', 4000, false); + const version = textField(input.version, 'version label', 120); + const files = validateUploadPlan(input.files); + const handle = database(); + const id = randomUUID(); const now = new Date().toISOString(); + handle.transaction(() => { + let library = handle.prepare('SELECT id,game FROM game_file_libraries WHERE name=?').get(name) as { id: string; game: string } | undefined; + if (library && library.game !== game) throw new Error('This library name is already used for another game.'); + if (!library) { + library = { id: randomUUID(), game }; + handle.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)').run(library.id, name, game, description, now); + } + if (handle.prepare('SELECT 1 FROM game_file_versions WHERE library_id=? AND version=?').get(library.id, version)) throw new Error('This version label already exists. Choose a new version.'); + // Keep legacy non-null columns for existing databases and published versions. + handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,size_bytes,created_at,storage_location,source_host_id,source_volume) + VALUES(?,?,?,?,'[]','importing',?,?,?,'','')`).run(id, library.id, version, description, files.reduce((sum, file) => sum + file.size, 0), now, join(gameFilesRoot(), game, id)); + const insert = handle.prepare('INSERT INTO game_file_upload_entries(version_id,path,size,last_modified) VALUES(?,?,?,?)'); + for (const file of files) insert.run(id, file.path, file.size, file.lastModified); + })(); + return getLibraryVersion(id)!; +} + +type UploadEntry = { path: string; size: number; lastModified: number }; +function requireBrowserUpload(id: string): GameLibraryVersion { + const version = getLibraryVersion(id); + if (!version || version.sourceHostId !== null || !database().prepare('SELECT 1 FROM game_file_upload_entries WHERE version_id=? LIMIT 1').get(id)) { + throw Object.assign(new Error('Folder upload not found.'), { status: 404 }); + } + return version; +} +function uploadEntries(id: string): UploadEntry[] { + return database().prepare('SELECT path,size,last_modified AS lastModified FROM game_file_upload_entries WHERE version_id=? ORDER BY path').all(id) as UploadEntry[]; +} +async function uploadStorageRoot(version: GameLibraryVersion): Promise { + // Recover publication interrupted after the atomic rename but before its DB commit. + try { if ((await stat(version.storageLocation)).isDirectory()) return version.storageLocation; } + catch (cause) { if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause; } + return stagingRoot(version.id); +} +export async function browserUploadStatus(id: string) { + const version = requireBrowserUpload(id); + const status = version.state === 'ready' ? 'published' as const : publishing.has(id) ? 'verifying' as const : version.state === 'failed' ? 'failed' as const : 'pending' as const; + const root = await uploadStorageRoot(version); + const entries = uploadEntries(id); + const files = []; + // Avoid opening thousands of files concurrently for a large folder. + for (const file of entries) files.push({ ...file, ...(status === 'published' ? { receivedBytes: file.size, complete: true } : await getUploadFileProgress(root, file)) }); + const { files: _manifest, ...summary } = version; + return { status, chunkSize: MAX_UPLOAD_CHUNK_BYTES, files, version: summary, error: version.error }; +} +export async function uploadBrowserFile(id: string, path: string, chunk: { offset: number; size: number; sha256: string }, body: ReadableStream | null) { + validateGamePath(path); + const checkWritable = () => { + const version = requireBrowserUpload(id); + if (version.state === 'ready' || version.manifestSha256 || publishing.has(id)) throw Object.assign(new Error('This upload is no longer writable.'), { status: 409 }); + }; + checkWritable(); + const file = database().prepare('SELECT path,size,last_modified AS lastModified FROM game_file_upload_entries WHERE version_id=? AND path=?').get(id, path) as UploadEntry | undefined; + if (!file) throw new Error('File is not listed in this upload.'); + const progress = await receiveUploadChunk(stagingRoot(id), file, chunk, body, checkWritable); + database().prepare("UPDATE game_file_versions SET state='importing',error=NULL WHERE id=? AND state IN ('importing','failed')").run(id); + return progress; +} +export async function publishBrowserUpload(id: string): Promise { + const version = requireBrowserUpload(id); + if (version.state === 'ready') return version; + const existing = publishing.get(id); if (existing) return existing; + const task = withUploadLock(stagingRoot(id), async () => { + const root = await uploadStorageRoot(version); + const files = await hashUploadedFiles(root, uploadEntries(id)); + const checksum = manifestChecksum(files); + if (version.manifestSha256 && checksum !== version.manifestSha256) throw new Error('Stored files no longer match the verified manifest.'); + database().transaction(() => { + database().prepare("UPDATE game_file_versions SET state='importing',manifest=?,manifest_sha256=?,error=NULL WHERE id=? AND state IN ('importing','failed')").run(JSON.stringify(files), checksum, id); + database().prepare('DELETE FROM game_file_entries WHERE version_id=?').run(id); + const insert = database().prepare('INSERT INTO game_file_entries(version_id,path,size,sha256) VALUES(?,?,?,?)'); + for (const file of files) insert.run(id, file.path, file.size, file.sha256); + })(); + if (root !== version.storageLocation) { + await mkdir(dirname(version.storageLocation), { recursive: true }); + await rename(root, version.storageLocation); + } + database().prepare("UPDATE game_file_versions SET state='ready',published_at=?,error=NULL WHERE id=? AND state='importing'").run(new Date().toISOString(), id); + return getLibraryVersion(id)!; + }); + publishing.set(id, task); + try { return await task; } finally { publishing.delete(id); } +} +export async function requestBrowserPublication(id: string) { + const version = requireBrowserUpload(id); + if (version.state !== 'ready' && !publishing.has(id)) { + database().prepare("UPDATE game_file_versions SET state='importing',error=NULL WHERE id=? AND state IN ('importing','failed')").run(id); + void publishBrowserUpload(id).catch(cause => { + database().prepare("UPDATE game_file_versions SET state='failed',error=? WHERE id=? AND state='importing'") + .run((cause instanceof Error ? cause.message : 'Folder publication failed.').slice(0, 2000), id); + }); + } + return browserUploadStatus(id); +} + export function createLibraryImport(input: Record): GameLibraryVersion { const game = input.game; if (game !== 't5' && game !== 't6') throw new Error('Choose T5 or T6 game files.'); @@ -69,9 +176,6 @@ export function createLibraryImport(input: Record): GameLibrary const name = textField(input.name, 'library name', 160); const description = textField(input.description ?? '', 'description', 4000, false); const version = textField(input.version, 'version label', 120); - const dlcInput = input.dlc ?? []; - if (!Array.isArray(dlcInput) || dlcInput.length > 64) throw new Error('DLC must be a list of content names.'); - const dlc = dlcInput.map(value => textField(value, 'DLC name', 120)); const sourceVolume = game === 't5' ? 't5_game_files' : 't6_game_files'; const handle = database(); let library = handle.prepare('SELECT id,game FROM game_file_libraries WHERE name=?').get(name) as { id: string; game: string } | undefined; @@ -84,7 +188,7 @@ export function createLibraryImport(input: Record): GameLibrary } if (handle.prepare('SELECT 1 FROM game_file_versions WHERE library_id=? AND version=?').get(library.id, version)) throw new Error('This version label already exists. Choose a new version.'); handle.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,storage_location,source_host_id,source_volume) - VALUES(?,?,?,?,?,'importing',?,?,?,?)`).run(id, library.id, version, description, JSON.stringify(dlc), now, join(gameFilesRoot(), game, id), hostId, sourceVolume); + VALUES(?,?,?,?,?,'importing',?,?,?,?)`).run(id, library.id, version, description, '[]', now, join(gameFilesRoot(), game, id), hostId, sourceVolume); })(); try { const operation = queueHostOperation(hostId, 'library.import', { diff --git a/src/lib/server/game-files/schema.ts b/src/lib/server/game-files/schema.ts index fcf7f03..ae62c16 100644 --- a/src/lib/server/game-files/schema.ts +++ b/src/lib/server/game-files/schema.ts @@ -29,6 +29,11 @@ export function ensureGameFileSchema(handle: Database.Database): void { version_id TEXT NOT NULL REFERENCES game_file_versions(id), path TEXT NOT NULL, size INTEGER NOT NULL, sha256 TEXT NOT NULL, PRIMARY KEY(version_id,path) ); + CREATE TABLE IF NOT EXISTS game_file_upload_entries ( + version_id TEXT NOT NULL REFERENCES game_file_versions(id), path TEXT NOT NULL, + size INTEGER NOT NULL, last_modified INTEGER NOT NULL, + PRIMARY KEY(version_id,path) + ); INSERT OR IGNORE INTO game_file_entries(version_id,path,size,sha256) SELECT v.id,json_extract(f.value,'$.path'),json_extract(f.value,'$.size'),json_extract(f.value,'$.sha256') FROM game_file_versions v,json_each(v.manifest) f; diff --git a/src/lib/server/game-files/storage.ts b/src/lib/server/game-files/storage.ts index 135b24c..524ad03 100644 --- a/src/lib/server/game-files/storage.ts +++ b/src/lib/server/game-files/storage.ts @@ -63,13 +63,18 @@ export async function assertRegularPath(root: string, path: string, allowMissing return result; } -export async function verifyGameFile(root: string, file: GameFileEntry): Promise { +/** 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); - if (hash.digest('hex') !== file.sha256) throw new Error(`Checksum verification failed for ${file.path}.`); + 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. */ diff --git a/src/lib/server/game-files/uploads.ts b/src/lib/server/game-files/uploads.ts new file mode 100644 index 0000000..fcbf595 --- /dev/null +++ b/src/lib/server/game-files/uploads.ts @@ -0,0 +1,155 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { constants, createReadStream, createWriteStream } from 'node:fs'; +import { lstat, mkdir, open, rm } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import type { GameFileEntry } from '../../game-files.js'; +import { assertRegularPath, hashGameFile, validateManifest } from './storage.js'; + +export const MAX_UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024; + +export interface UploadFilePlan { + path: string; + size: number; + lastModified: number; +} + +export interface UploadFileProgress { + receivedBytes: number; + complete: boolean; +} + +export class UploadOffsetError extends Error { + readonly status = 409; + readonly receivedBytes: number; + constructor(receivedBytes: number) { + super(`Upload offset does not match the stored file. Resume at byte ${receivedBytes}.`); + this.name = 'UploadOffsetError'; + this.receivedBytes = receivedBytes; + } +} + +/** 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.'); + const modified = new Map(); + 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)! })); +} + +const uploadLocks = new Map>(); + +/** Serialize transfers and final publication for one import; a rejected operation never poisons the lock. */ +export async function withUploadLock(root: string, callback: () => Promise | T): Promise { + const key = resolve(root); + const previous = uploadLocks.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise(done => { release = done; }); + uploadLocks.set(key, current); + await previous; + try { return await callback(); } + finally { + release(); + if (uploadLocks.get(key) === current) uploadLocks.delete(key); + } +} + +/** File size is the resume checkpoint, including after a controller restart during an append. */ +export async function getUploadFileProgress(root: string, file: Pick): Promise { + 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}.`); + return { receivedBytes: info.size, complete: info.size === file.size }; + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code === 'ENOENT') return { receivedBytes: 0, complete: false }; + throw cause; + } +} + +/** + * Verify one bounded request before touching the destination. Only verified bytes may be + * appended, so a process interruption during the append can resume at the actual file size. + * beforeWrite runs under the lock: callers must recheck that the import is still writable. + */ +export async function receiveUploadChunk( + root: string, + file: Pick, + chunk: { offset: number; size: number; sha256: string }, + body: ReadableStream | null, + beforeWrite?: () => Promise | void +): Promise { + 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.'); + + return withUploadLock(root, async () => { + await beforeWrite?.(); + const progress = await getUploadFileProgress(root, file); + 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`; + const temporary = await assertRegularPath(chunkRoot, randomUUID(), true); + 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); + } }); + 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 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); + try { + const info = await target.stat(); + if (!info.isFile()) throw new Error('Game storage contains a non-regular path.'); + if (info.size !== chunk.offset) throw new UploadOffsetError(info.size); + let position = chunk.offset; + try { + for await (const data of createReadStream(temporary)) { + 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.'); + written += result.bytesWritten; + position += result.bytesWritten; + } + } + await target.sync(); + } catch (cause) { + await target.truncate(chunk.offset); + 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 }); } + }); +} + +/** Caller holds withUploadLock through hashing and publication to prevent concurrent writes. */ +export async function hashUploadedFiles(root: string, files: ReadonlyArray>): Promise { + const manifest: GameFileEntry[] = []; + for (const file of files) manifest.push({ path: file.path, size: file.size, sha256: await hashGameFile(root, file) }); + return validateManifest(manifest); +} diff --git a/src/lib/server/provisioning/instances.ts b/src/lib/server/provisioning/instances.ts index 548b43e..56d03d0 100644 --- a/src/lib/server/provisioning/instances.ts +++ b/src/lib/server/provisioning/instances.ts @@ -204,7 +204,7 @@ export function previewCreation(input: { game?: unknown; mapSlug?: unknown; blue { label: 'Lobby', state: name ? 'pass' : 'blocked', detail: name ?? 'All 20 lobbies for this map are occupied or reserved.' }, { label: 'Game port', state: port === null ? 'blocked' : 'pass', detail: port === null ? 'No free game port. Free capacity before launching.' : `${port} / UDP and TCP. Availability is checked again at launch.` }, { label: 'Promoted configuration', state: 'pass', detail: recipe.configSource }, - { label: 'Game files and host readiness', state: 'info', detail: 'The host must have the selected game and DLC files. This preview does not probe volumes, images, credentials, or network access.' } + { label: 'Game files and host readiness', state: 'info', detail: 'The host must have the selected game files. This preview does not probe volumes, images, credentials, or network access.' } ]; const id = randomUUID(); const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString(); diff --git a/src/lib/ui/components/NativeHostSelection.svelte b/src/lib/ui/components/NativeHostSelection.svelte index 2c1d0e5..2252433 100644 --- a/src/lib/ui/components/NativeHostSelection.svelte +++ b/src/lib/ui/components/NativeHostSelection.svelte @@ -30,7 +30,7 @@ {#if !loading && !hosts.some(candidate => candidate.readiness === 'ready')}

No deployment-ready hosts. Set up a host, then refresh.

{/if} {#if host}

{host.hostname} · ports {host.portMin}–{host.portMax} · {host.publicAddress || host.bindAddress}

{/if} {#each games as game}{/each} -

Import or download game files to a host, then refresh. Each launch stays pinned to its selected library version.

+

Upload a game folder or download a version to a host, then refresh. Each launch stays pinned to its selected library version.

{#if host && !imageReady}

Prepare this runtime image from host setup before launch.

{/if} {#if network}
Network overrides

Public mapping does not change the local Docker port reservation.

{/if} diff --git a/src/lib/ui/game-folder.ts b/src/lib/ui/game-folder.ts new file mode 100644 index 0000000..7173896 --- /dev/null +++ b/src/lib/ui/game-folder.ts @@ -0,0 +1,38 @@ +export interface SelectedGameFile { path: string; file: File } +export interface SelectedGameFolder { name: string; files: SelectedGameFile[]; size: number } + +/** Directory inputs include the selected folder in every relative path. Remove + * that one component and retain the complete game directory below it. */ +export function selectGameFolder(selection: Iterable): SelectedGameFolder { + const files: SelectedGameFile[] = []; + const paths = new Set(); + let name = ''; + let size = 0; + for (const file of selection) { + const [root, ...parts] = file.webkitRelativePath.split('/'); + if (!root || root === '.' || root === '..' || !parts.length || name && root !== name) throw new Error('Choose one game folder with the folder picker.'); + name = root; + const path = parts.join('/'); + if (path.length > 1024 || /[\\\x00-\x1f\x7f:]/.test(path) || parts.some(part => !part || part === '.' || part === '..' || /[. ]$/.test(part))) { + throw new Error(`This file has an unsupported game path: ${file.webkitRelativePath}`); + } + const key = path.toLowerCase(); + if (paths.has(key)) throw new Error(`This folder has duplicate game paths: ${path}`); + paths.add(key); + size += file.size; + if (!Number.isSafeInteger(size)) throw new Error('This folder exceeds the supported upload size.'); + files.push({ path, file }); + if (files.length > 200_000) throw new Error('Choose a folder with no more than 200,000 files.'); + } + if (!files.length) throw new Error('The selected folder contains no files.'); + for (const { path } of files) { + const parents = path.toLowerCase().split('/'); + parents.pop(); + while (parents.length) { + if (paths.has(parents.join('/'))) throw new Error(`This folder uses the same game path for a file and a directory: ${path}`); + parents.pop(); + } + } + files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0); + return { name, files, size }; +} diff --git a/src/routes/api/game-files/+server.ts b/src/routes/api/game-files/+server.ts index 72a634b..f60f07a 100644 --- a/src/routes/api/game-files/+server.ts +++ b/src/routes/api/game-files/+server.ts @@ -1,10 +1,10 @@ import { json } from '@sveltejs/kit'; -import { createLibraryImport, libraryOverview } from '$lib/server/game-files/index.js'; +import { createLibraryUpload, libraryOverview } from '$lib/server/game-files/index.js'; import { gameFileError, readGameJson } from '$lib/server/game-files/http.js'; import { requireManagementOrigin } from '$lib/server/hosts/http.js'; export function GET() { return json(libraryOverview()); } export async function POST({ request }: { request: Request }) { - try { requireManagementOrigin(request); return json({ version: createLibraryImport(await readGameJson(request)) }, { status: 202 }); } + try { requireManagementOrigin(request); return json({ version: createLibraryUpload(await readGameJson(request, 32 * 1024 * 1024)) }, { status: 202 }); } catch (cause) { return gameFileError(cause); } } diff --git a/src/routes/api/game-files/[id]/upload/+server.ts b/src/routes/api/game-files/[id]/upload/+server.ts new file mode 100644 index 0000000..091f837 --- /dev/null +++ b/src/routes/api/game-files/[id]/upload/+server.ts @@ -0,0 +1,8 @@ +import { json } from '@sveltejs/kit'; +import { browserUploadStatus } from '$lib/server/game-files/index.js'; +import { gameFileError } from '$lib/server/game-files/http.js'; + +export async function GET({ params }: { params: { id: string } }) { + try { return json(await browserUploadStatus(params.id), { headers: { 'Cache-Control': 'no-store' } }); } + catch (cause) { return gameFileError(cause); } +} diff --git a/src/routes/api/game-files/[id]/upload/complete/+server.ts b/src/routes/api/game-files/[id]/upload/complete/+server.ts new file mode 100644 index 0000000..d4eefb8 --- /dev/null +++ b/src/routes/api/game-files/[id]/upload/complete/+server.ts @@ -0,0 +1,11 @@ +import { json } from '@sveltejs/kit'; +import { requestBrowserPublication } from '$lib/server/game-files/index.js'; +import { gameFileError } from '$lib/server/game-files/http.js'; +import { requireManagementOrigin } from '$lib/server/hosts/http.js'; + +export async function POST({ request, params }: { request: Request; params: { id: string } }) { + try { + requireManagementOrigin(request); + return json(await requestBrowserPublication(params.id), { status: 202 }); + } catch (cause) { return gameFileError(cause); } +} diff --git a/src/routes/api/game-files/[id]/upload/files/[...path]/+server.ts b/src/routes/api/game-files/[id]/upload/files/[...path]/+server.ts new file mode 100644 index 0000000..b6ae774 --- /dev/null +++ b/src/routes/api/game-files/[id]/upload/files/[...path]/+server.ts @@ -0,0 +1,17 @@ +import { json } from '@sveltejs/kit'; +import { uploadBrowserFile } from '$lib/server/game-files/index.js'; +import { gameFileError } from '$lib/server/game-files/http.js'; +import { requireManagementOrigin } from '$lib/server/hosts/http.js'; + +function integerHeader(request: Request, name: string): number { + const value = request.headers.get(name); + if (value === null || !/^\d+$/.test(value) || !Number.isSafeInteger(Number(value))) throw new Error(`Provide a valid ${name} header.`); + return Number(value); +} +export async function PUT({ request, params }: { request: Request; params: { id: string; path: string } }) { + try { + requireManagementOrigin(request); + const chunk = { offset: integerHeader(request, 'X-Upload-Offset'), size: integerHeader(request, 'X-Upload-Length'), sha256: request.headers.get('X-Content-SHA256') ?? '' }; + return json(await uploadBrowserFile(params.id, params.path, chunk, request.body)); + } catch (cause) { return gameFileError(cause); } +} diff --git a/src/routes/game-files/+page.svelte b/src/routes/game-files/+page.svelte index 49c267c..72a648d 100644 --- a/src/routes/game-files/+page.svelte +++ b/src/routes/game-files/+page.svelte @@ -1,9 +1,11 @@ Game Files | Electric Cherry
- - {#snippet actions()}{#if canManage}{/if}{/snippet} + + {#snippet actions()}{#if canManage}{/if}{/snippet} {#if error}{/if} {#if notice}

{notice}

{/if} - {#if !canManage}

Administrators import game files and distribute verified copies to hosts.

{/if} - {#if canManage && showImport} -
-

Import an existing game volume

-

The selected host scans its {game}_game_files Docker volume, calculates SHA256 checksums, and streams a copy into controller storage. Publication requires every file to pass verification.

-
-
+ {#if !canManage}

Administrators upload game files and distribute verified copies to hosts.

{/if} + {#if canManage && showUpload} +
+

{uploadId ? phase === 'published' ? 'Version published' : 'Continue folder upload' : 'Upload a game folder'}

+

Select your complete T5 or T6 game folder, including its installed DLC. The files upload directly to this controller; a game host is only needed when you are ready to run servers.

+ {#if uploadError}{/if} + +
- - - - - + + + +
+ {#if phase !== 'verifying' && phase !== 'published'} + +

{uploadId ? 'To resume, file names, sizes, and modification dates must match the original upload.' : 'Choose the game directory itself. Its top-level folder name is removed once; all files and subfolders inside it keep their paths.'}

+ {/if} + {#if folder}
{folder.name}{folder.files.length.toLocaleString()} files · {formatGameBytes(folder.size)}
Preview folder paths
    {#each folder.files.slice(0, 8) as file}
  • {file.path}
  • {/each}
{#if folder.files.length > 8}

And {(folder.files.length - 8).toLocaleString()} more files.

{/if}
{/if} + {#if phase !== 'idle'}
+
{phase === 'preparing' ? 'Preparing upload…' : phase === 'uploading' ? 'Uploading game files' : phase === 'verifying' ? 'Verifying and publishing…' : phase === 'published' ? 'Published and ready to distribute' : 'Upload paused'}{completedFiles.toLocaleString()} / {totalFiles.toLocaleString()} files
+ +

{formatGameBytes(progressBytes)} / {formatGameBytes(totalBytes)} saved or transferring

+ {#if currentPath}

{currentPath}

{/if} + {#if phase === 'verifying'}

The controller checks every file and publishes this version when verification finishes. Verification continues if you leave this page.

{:else if phase === 'paused'}

Saved chunks are kept on the controller. Resume this upload to check the saved progress and retry.

{/if} +
{/if} +
+ {#if transferring} + {:else if phase !== 'verifying' && phase !== 'published'}{/if} + {#if phase === 'idle' || phase === 'uploading'}Keep this page open while files transfer. Interrupted uploads can be resumed below.{/if}
- - {#if !onlineHosts.length}

Enroll an online source host in Hosts before importing.

{/if}
{/if} -
{libraries.length} libraries · Base-game files and runtime images are versioned separately.
+
{libraries.length} libraries · Game folders and runtime images are versioned separately.
{#each matching as library (library.id)}
-

{library.name}

{library.game.toUpperCase()} · {library.description}

{#if canManage}{/if}
+

{library.name}

{library.game.toUpperCase()} · {library.description}

{#if canManage}{/if}
{#each library.versions as item (item.id)} + {@const browserUpload = !item.sourceHostId}
-

{item.version}

{item.state === 'ready' ? 'Published' : item.state === 'importing' ? 'Importing' : 'Failed'}
+

{item.version}

{item.state === 'ready' ? 'Published' : item.state === 'importing' ? browserUpload ? item.id === uploadId && phase === 'verifying' ? 'Verifying' : 'Uploading' : 'Importing' : 'Failed'}

{formatGameBytes(item.sizeBytes)} · {item.fileCount.toLocaleString()} files · Created {new Date(item.createdAt).toLocaleString()}

- {#if item.dlc.length}

Content: {item.dlc.join(', ')}

{/if} {#if item.error}

{item.error}

{/if} -
Version details
Immutable version ID
{item.id}
Manifest SHA256
{item.manifestSha256 ?? 'Waiting for source scan'}
Controller storage
{item.storageLocation}
Imported from
{hostName(item.sourceHostId)} / {item.sourceVolume}
View complete file manifest
+
Version details
Immutable version ID
{item.id}
Manifest SHA256
{item.manifestSha256 ?? 'Available after verification'}
Uploaded from
{browserUpload ? 'Game folder in browser' : `${hostName(item.sourceHostId)} / ${item.sourceVolume}`}
{#if item.state === 'ready'}View complete file manifest{/if}
{#if item.state === 'ready'} - {#if canManage}
{/if} + {#if canManage}
{#if !onlineHosts.length}

This version is stored and ready. Connect a host when you want to deploy game servers.

{/if}{/if}
- {#each item.caches as cache}{:else}{/each} + {#each item.caches as cache}{:else}{/each}
HostCache stateProgressDetail
{hostName(cache.hostId)}{cache.status}{!hosts.find(host => host.id === cache.hostId)?.connected ? ' · offline' : ''}{#if cache.status === 'downloading' || cache.status === 'verifying'}{formatGameBytes(cache.bytesDownloaded)} / {formatGameBytes(cache.bytesTotal)}{:else if cache.status === 'ready' || cache.status === 'outdated'}Verified{cache.status === 'outdated' ? ' · older version' : ''}{:else}—{/if}{cache.error ?? ''}
Enroll a host to cache this version.
{hostName(cache.hostId)}{cache.status}{!hosts.find(host => host.id === cache.hostId)?.connected ? ' · offline' : ''}{#if cache.status === 'downloading' || cache.status === 'verifying'}{formatGameBytes(cache.bytesDownloaded)} / {formatGameBytes(cache.bytesTotal)}{:else if cache.status === 'ready' || cache.status === 'outdated'}Verified{cache.status === 'outdated' ? ' · older version' : ''}{:else}—{/if}{cache.error ?? ''}
No host copies yet.
- {:else if item.state === 'importing'}

The agent operation continues independently of this page. An interrupted import is reconciled when the host reconnects.

{/if} + {:else if browserUpload}

Resume with the original unchanged folder. Published versions can be downloaded to any enrolled host.

{#if canManage}
{/if} + {:else if item.state === 'importing'}

The existing host import continues independently. It is reconciled when the host reconnects.

{/if}
{/each}
- {:else}{#if loaded}{:else}

Loading game libraries…

{/if}{/each} + {:else}{#if loaded}{:else}

Loading game libraries…

{/if}{/each}
diff --git a/tests/unit/auth-access.test.ts b/tests/unit/auth-access.test.ts index efb8625..db502aa 100644 --- a/tests/unit/auth-access.test.ts +++ b/tests/unit/auth-access.test.ts @@ -49,7 +49,8 @@ describe('management route permissions', () => { ['/api/hosts', 'POST'], ['/api/hosts/[id]', 'PATCH'], ['/api/hosts/[id]/enrollment', 'POST'], ['/api/hosts/[id]/credentials', 'DELETE'], ['/api/hosts/[id]/credentials', 'POST'], ['/api/hosts/[id]/operations', 'POST'], ['/api/settings', 'PUT'], ['/api/game-files', 'POST'], - ['/api/game-files/[id]/download', 'POST'], ['/api/maintenance', 'POST'], ['/api/watchdog', 'POST'], ['/api/plutonium', 'POST'] + ['/api/game-files/[id]/download', 'POST'], ['/api/game-files/[id]/upload/files/[...path]', 'PUT'], + ['/api/game-files/[id]/upload/complete', 'POST'], ['/api/maintenance', 'POST'], ['/api/watchdog', 'POST'], ['/api/plutonium', 'POST'] ])('protects host, game-file, and global policy mutations at %s', (route, method) => { expect(routePermission(route, method)).toBe('admin'); expect(allows('operator', route, method)).toBe(false); diff --git a/tests/unit/game-file-browser-upload.test.ts b/tests/unit/game-file-browser-upload.test.ts new file mode 100644 index 0000000..9bcc2bf --- /dev/null +++ b/tests/unit/game-file-browser-upload.test.ts @@ -0,0 +1,227 @@ +import Database from 'better-sqlite3'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const state = vi.hoisted(() => ({ handle: null as Database.Database | null })); +vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle })); +import { + browserUploadStatus, createLibraryImport, createLibraryUpload, getHostLibraryCache, + getLibraryVersion, listLibraries, publishBrowserUpload, queueLibraryDownload, + requestBrowserPublication, requireReadyLibrary, updateHostLibraryCaches, uploadBrowserFile +} from '../../src/lib/server/game-files/index.js'; +import { createEnrollment, createHost, enrollHost, listHosts } from '../../src/lib/server/hosts/index.js'; +import { manifestChecksum } from '../../src/lib/server/game-files/storage.js'; +import { serveGameFile } from '../../src/lib/server/game-files/http.js'; +import { GET as manifestRoute } from '../../src/routes/api/agent/libraries/[id]/manifest/+server.js'; + +let directory: string; +const modified = 1_700_000_000_000; +const bytes = Buffer.from('verified Plutonium game content'); +const sha = (value: Uint8Array) => createHash('sha256').update(value).digest('hex'); +const file = (path = 'zone/base.ff', content = bytes) => ({ path, size: content.length, lastModified: modified }); +const upload = (id: string, path: string, content: Buffer, offset = 0, checksum = sha(content)) => + uploadBrowserFile(id, path, { offset, size: content.length, sha256: checksum }, new Response(new Uint8Array(content)).body); +const begin = (game: 't5' | 't6' = 't6', files = [file()], version = '1') => + createLibraryUpload({ game, name: `Plutonium ${game.toUpperCase()}`, version, description: 'Uploaded game folder', files }); + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'ec-browser-library-')); + state.handle = new Database(join(directory, 'controller.sqlite')); + state.handle.pragma('foreign_keys=ON'); + vi.stubEnv('GAME_FILES_ROOT', join(directory, 'libraries')); +}); +afterEach(async () => { + state.handle?.close(); state.handle = null; + await rm(directory, { recursive: true, force: true }); + vi.unstubAllEnvs(); +}); + +function host() { + const created = createHost({ name: 'Fresh download target' }); + const { credential } = enrollHost(createEnrollment(created.id).token, { + protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 100 * 1024 ** 3 + }); + return { id: created.id, credential }; +} + +describe('browser folder uploads', () => { + it.each(['t5', 't6'] as const)('publishes %s from a browser without an enrolled host or DLC declarations', async game => { + expect(listHosts()).toEqual([]); + const initial = begin(game); + expect(initial).toMatchObject({ state: 'importing', operationId: null, sourceHostId: null, sourceVolume: null }); + expect(initial).not.toHaveProperty('dlc'); + expect(await browserUploadStatus(initial.id)).toMatchObject({ + status: 'pending', files: [{ ...file(), receivedBytes: 0, complete: false }] + }); + await upload(initial.id, file().path, bytes); + const accepted = await requestBrowserPublication(initial.id); + expect(['verifying', 'published']).toContain(accepted.status); + const ready = await publishBrowserUpload(initial.id); + expect(ready).toMatchObject({ state: 'ready', sizeBytes: bytes.length, fileCount: 1 }); + expect(ready.files).toEqual([{ path: file().path, size: bytes.length, sha256: sha(bytes) }]); + expect(ready.manifestSha256).toBe(manifestChecksum(ready.files)); + expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes); + expect(await browserUploadStatus(initial.id)).toMatchObject({ status: 'published' }); + expect(listLibraries()[0].versions[0]).not.toHaveProperty('dlc'); + expect(listHosts()).toEqual([]); + }); + + it('preserves nested relative contents and derives a canonical manifest in path order', async () => { + const selected = [file('zone/english/a.ff'), file('main/iw_00.iwd'), file('empty.txt', Buffer.alloc(0))]; + const initial = begin('t6', selected); + for (const item of selected) await upload(initial.id, item.path, item.size ? bytes : Buffer.alloc(0)); + const ready = await publishBrowserUpload(initial.id); + expect(ready.files.map(item => item.path)).toEqual(['empty.txt', 'main/iw_00.iwd', 'zone/english/a.ff']); + expect(ready.files[0].sha256).toBe(sha(Buffer.alloc(0))); + expect(ready.manifestSha256).toBe(manifestChecksum(ready.files)); + expect(await readFile(join(ready.storageLocation, 'zone/english/a.ff'))).toEqual(bytes); + }); + + it('rejects incomplete publication and allows the remaining files to finish the same version', async () => { + const initial = begin('t5', [file('zone/first.ff'), file('zone/second.ff')]); + await upload(initial.id, 'zone/first.ff', bytes); + await expect(publishBrowserUpload(initial.id)).rejects.toThrow(); + expect(getLibraryVersion(initial.id)?.state).not.toBe('ready'); + const hidden = await serveGameFile(new Request('https://ec.test/files'), initial.id, 'zone/first.ff'); + expect(hidden.status).toBe(404); + await upload(initial.id, 'zone/second.ff', bytes); + const ready = await publishBrowserUpload(initial.id); + expect(ready.state).toBe('ready'); + expect(ready.id).toBe(initial.id); + }); + + it('does not acknowledge corrupt chunks and permits a verified retry', async () => { + const initial = begin(); + await expect(upload(initial.id, file().path, bytes, 0, '0'.repeat(64))).rejects.toThrow(); + expect((await browserUploadStatus(initial.id)).files[0]).toMatchObject({ receivedBytes: 0, complete: false }); + await expect(uploadBrowserFile(initial.id, file().path, { + offset: 0, size: bytes.length, sha256: sha(bytes) + }, new Response(bytes.subarray(0, -1)).body)).rejects.toThrow(); + expect((await browserUploadStatus(initial.id)).files[0].receivedBytes).toBe(0); + await upload(initial.id, file().path, bytes); + expect((await browserUploadStatus(initial.id)).files[0]).toMatchObject({ receivedBytes: bytes.length, complete: true }); + expect((await publishBrowserUpload(initial.id)).state).toBe('ready'); + }); + + it('surfaces asynchronous incomplete publication as retryable failure', async () => { + const initial = begin(); + await requestBrowserPublication(initial.id); + await vi.waitFor(async () => expect((await browserUploadStatus(initial.id)).status).toBe('failed')); + expect((await browserUploadStatus(initial.id)).error).toBeTruthy(); + await upload(initial.id, file().path, bytes); + expect(await browserUploadStatus(initial.id)).toMatchObject({ status: 'pending', error: null }); + await requestBrowserPublication(initial.id); + expect((await publishBrowserUpload(initial.id)).state).toBe('ready'); + }); + + it('resumes from the verified chunk boundary and safely retries a lost acknowledgement', async () => { + const sizeProbe = begin(); + const chunkSize = (await browserUploadStatus(sizeProbe.id)).chunkSize; + expect(chunkSize).toBeGreaterThan(0); + const large = Buffer.alloc(chunkSize + 17, 0x41); + const initial = begin('t6', [file('main/archive.iwd', large)], 'large'); + const first = large.subarray(0, chunkSize); + await upload(initial.id, 'main/archive.iwd', first); + expect((await browserUploadStatus(initial.id)).files[0]).toMatchObject({ receivedBytes: chunkSize, complete: false }); + state.handle!.close(); + state.handle = new Database(join(directory, 'controller.sqlite')); + state.handle.pragma('foreign_keys=ON'); + await expect(upload(initial.id, 'main/archive.iwd', first)).rejects.toMatchObject({ receivedBytes: chunkSize }); + expect((await browserUploadStatus(initial.id)).files[0].receivedBytes).toBe(chunkSize); + await upload(initial.id, 'main/archive.iwd', large.subarray(chunkSize), chunkSize); + const ready = await publishBrowserUpload(initial.id); + expect(ready.files[0].sha256).toBe(sha(large)); + expect((await readFile(join(ready.storageLocation, 'main/archive.iwd'))).equals(large)).toBe(true); + }); + + it('rejects unknown file paths, traversal, and chunks outside the declared file', async () => { + const initial = begin(); + await expect(upload(initial.id, 'zone/unselected.ff', bytes)).rejects.toThrow(); + await expect(upload(initial.id, '../escape.ff', bytes)).rejects.toThrow(); + await expect(upload(initial.id, file().path, bytes, bytes.length + 1)).rejects.toThrow(); + await expect(upload(initial.id, file().path, Buffer.concat([bytes, bytes]))).rejects.toThrow(); + expect((await browserUploadStatus(initial.id)).files[0].receivedBytes).toBe(0); + }); + + it('keeps ready bytes and version metadata immutable and requires a new version label', async () => { + const initial = begin(); await upload(initial.id, file().path, bytes); + const ready = await publishBrowserUpload(initial.id); + await expect(upload(initial.id, file().path, bytes)).rejects.toThrow(); + expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/); + expect(() => begin()).toThrow(/already exists/); + expect((await publishBrowserUpload(initial.id)).manifestSha256).toBe(ready.manifestSha256); + expect(begin('t6', [file()], '2').id).not.toBe(ready.id); + expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes); + }); + + it('recovers publication after the verified directory was renamed but the ready commit was interrupted', async () => { + const initial = begin(); await upload(initial.id, file().path, bytes); + const files = [{ path: file().path, size: bytes.length, sha256: sha(bytes) }]; + const checksum = manifestChecksum(files); + state.handle!.transaction(() => { + state.handle!.prepare('UPDATE game_file_versions SET manifest=?,manifest_sha256=? WHERE id=?') + .run(JSON.stringify(files), checksum, initial.id); + state.handle!.prepare('INSERT INTO game_file_entries(version_id,path,size,sha256) VALUES(?,?,?,?)') + .run(initial.id, file().path, bytes.length, sha(bytes)); + })(); + await mkdir(dirname(initial.storageLocation), { recursive: true }); + await rename(join(directory, 'libraries', '.imports', initial.id), initial.storageLocation); + state.handle!.close(); state.handle = new Database(join(directory, 'controller.sqlite')); + state.handle.pragma('foreign_keys=ON'); + expect(await browserUploadStatus(initial.id)).toMatchObject({ status: 'pending', files: [{ complete: true }] }); + const ready = await publishBrowserUpload(initial.id); + expect(ready).toMatchObject({ id: initial.id, state: 'ready', manifestSha256: checksum, files }); + expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes); + }); + + it('rejects chunks racing with final publication without recreating staging files', async () => { + const initial = begin(); await upload(initial.id, file().path, bytes); + const publishing = publishBrowserUpload(initial.id); + await expect(upload(initial.id, file().path, bytes)).rejects.toMatchObject({ status: 409 }); + const ready = await publishing; + expect(ready.state).toBe('ready'); + expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes); + await expect(stat(join(directory, 'libraries', '.imports', initial.id))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('rejects browser operations against an existing host-sourced import', async () => { + const source = host(); + const legacy = createLibraryImport({ hostId: source.id, game: 't6', name: 'Legacy import', version: '1' }); + expect(legacy.sourceHostId).toBe(source.id); + await expect(Promise.resolve().then(() => browserUploadStatus(legacy.id))).rejects.toThrow(); + await expect(upload(legacy.id, file().path, bytes)).rejects.toThrow(); + await expect(publishBrowserUpload(legacy.id)).rejects.toThrow(); + await expect(Promise.resolve().then(() => requestBrowserPublication(legacy.id))).rejects.toThrow(); + expect(getLibraryVersion(legacy.id)?.state).toBe('importing'); + }); + + it('distributes uploaded versions through the unchanged agent manifest, ranges, and verified cache contract', async () => { + const initial = begin('t5'); await upload(initial.id, file().path, bytes); + const ready = await publishBrowserUpload(initial.id); + const target = host(); + const operation = queueLibraryDownload(target.id, ready.id); + expect(operation).toMatchObject({ kind: 'library.download', payload: { + libraryVersionId: ready.id, game: 't5', manifestSha256: ready.manifestSha256, + manifestUrl: `/api/agent/libraries/${ready.id}/manifest`, filesBaseUrl: `/api/agent/libraries/${ready.id}/files` + } }); + const manifest = manifestRoute({ request: new Request(`https://ec.test/api/agent/libraries/${ready.id}/manifest`, { + headers: { Authorization: `Bearer ${target.credential}` } + }), params: { id: ready.id } }); + expect(manifest.status).toBe(200); + expect(await manifest.json()).toEqual({ id: ready.id, game: 't5', files: ready.files, sizeBytes: bytes.length, manifestSha256: ready.manifestSha256 }); + const range = await serveGameFile(new Request('https://ec.test/file', { headers: { Range: 'bytes=9-' } }), ready.id, file().path); + expect(range.status).toBe(206); + expect(Buffer.from(await range.arrayBuffer())).toEqual(bytes.subarray(9)); + expect(() => requireReadyLibrary(target.id, ready.id)).toThrow(/finish downloading/); + updateHostLibraryCaches(target.id, [{ libraryVersionId: ready.id, status: 'ready', manifestSha256: '0'.repeat(64) }]); + expect(getHostLibraryCache(target.id, ready.id)?.status).toBe('failed'); + updateHostLibraryCaches(target.id, [{ libraryVersionId: ready.id, status: 'ready', manifestSha256: ready.manifestSha256 }]); + expect(requireReadyLibrary(target.id, ready.id).id).toBe(ready.id); + const replacement = begin('t5', [file()], '2'); await upload(replacement.id, file().path, bytes); + await publishBrowserUpload(replacement.id); + expect(requireReadyLibrary(target.id, ready.id).manifestSha256).toBe(ready.manifestSha256); + }); +}); diff --git a/tests/unit/game-file-uploads.test.ts b/tests/unit/game-file-uploads.test.ts new file mode 100644 index 0000000..3a3e629 --- /dev/null +++ b/tests/unit/game-file-uploads.test.ts @@ -0,0 +1,180 @@ +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + getUploadFileProgress, hashUploadedFiles, MAX_UPLOAD_CHUNK_BYTES, + receiveUploadChunk, UploadOffsetError, validateUploadPlan, withUploadLock +} from '../../src/lib/server/game-files/uploads.js'; +import { manifestChecksum, verifyGameFile } from '../../src/lib/server/game-files/storage.js'; + +const sha = (value: string | Uint8Array) => createHash('sha256').update(value).digest('hex'); +const stream = (value: string) => new Response(value).body!; +const file = { path: 'zone/english/map.ff', size: 10, lastModified: 1234 }; +const chunk = (value: string, offset = 0) => ({ offset, size: Buffer.byteLength(value), sha256: sha(value) }); +let directory: string; +let root: string; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'ec-folder-upload-')); + root = join(directory, 'import'); +}); +afterEach(async () => { await rm(directory, { recursive: true, force: true }); }); + +describe('browser folder upload plans', () => { + it('preserves folder structure and source identity while sorting canonical paths', () => { + expect(validateUploadPlan([{ path: 'zone/map.ff', size: 10, lastModified: 100 }, { path: 'main/data.iwd', size: 0 }])).toEqual([ + { path: 'main/data.iwd', size: 0, lastModified: 0 }, + { path: 'zone/map.ff', size: 10, lastModified: 100 } + ]); + }); + + it.each([ + [], [{ path: '../zone.ff', size: 1 }], [{ path: 'zone\\map.ff', size: 1 }], + [{ path: 'Zone/a', size: 1 }, { path: 'zone/A', size: 1 }], + [{ path: 'ZONE', size: 1 }, { path: 'zone/a', size: 1 }], + [{ path: 'zone/a', size: -1 }], [{ path: 'zone/a', size: 1.5 }], + [{ path: 'zone/a', size: Number.MAX_SAFE_INTEGER }, { path: 'zone/b', size: 1 }], + [{ path: 'zone/a', size: 1, lastModified: -1 }], [{ path: 'zone/a', size: 1, lastModified: 1.5 }], + [{ path: 'zone/a', size: 1, lastModified: null }] + ].map(plan => ({ plan })))('rejects ambiguous or unsafe plan $plan', ({ plan }) => { + expect(() => validateUploadPlan(plan)).toThrow(); + }); +}); + +describe('bounded resumable file transfer', () => { + it('resumes arbitrary chunks by filesystem offset and produces a distribution-compatible manifest', async () => { + expect(await getUploadFileProgress(root, file)).toEqual({ receivedBytes: 0, complete: false }); + expect(await receiveUploadChunk(root, file, chunk('0123'), stream('0123'))).toEqual({ receivedBytes: 4, complete: false }); + // A new request discovers its checkpoint from disk; no process-local receipt is required. + expect(await getUploadFileProgress(root, file)).toEqual({ receivedBytes: 4, complete: false }); + expect(await receiveUploadChunk(root, file, chunk('456789', 4), stream('456789'))).toEqual({ receivedBytes: 10, complete: true }); + expect(await readFile(join(root, file.path), 'utf8')).toBe('0123456789'); + const manifest = await withUploadLock(root, () => hashUploadedFiles(root, [file])); + expect(manifest).toEqual([{ path: file.path, size: 10, sha256: sha('0123456789') }]); + expect(manifestChecksum(manifest)).toBe(sha(JSON.stringify(manifest))); + await verifyGameFile(root, manifest[0]); + expect(await readdir(root)).toEqual(['zone']); + expect(await readdir(`${root}.chunks`)).toEqual([]); + }); + + it('reports committed progress after reply loss instead of appending duplicate bytes', async () => { + await receiveUploadChunk(root, file, chunk('0123'), stream('0123')); + await expect(receiveUploadChunk(root, file, chunk('0123'), stream('0123'))).rejects.toMatchObject({ + name: 'UploadOffsetError', receivedBytes: 4, status: 409 + }); + await expect(receiveUploadChunk(root, file, chunk('89', 8), stream('89'))).rejects.toBeInstanceOf(UploadOffsetError); + expect(await readFile(join(root, file.path), 'utf8')).toBe('0123'); + await receiveUploadChunk(root, file, chunk('456789', 4), stream('456789')); + }); + + it('can resume a verified append interrupted by process termination at an unaligned byte', async () => { + await mkdir(join(root, 'zone/english'), { recursive: true }); + await writeFile(join(root, file.path), '0123456'); + expect(await getUploadFileProgress(root, file)).toEqual({ receivedBytes: 7, complete: false }); + await receiveUploadChunk(root, file, chunk('789', 7), stream('789')); + expect(await readFile(join(root, file.path), 'utf8')).toBe('0123456789'); + }); + + it('distinguishes an absent empty file and creates it using the empty digest', async () => { + const empty = { path: 'main/empty.dat', size: 0 }; + expect(await getUploadFileProgress(root, empty)).toEqual({ receivedBytes: 0, complete: false }); + await expect(hashUploadedFiles(root, [empty])).rejects.toThrow(); + expect(await receiveUploadChunk(root, empty, chunk(''), null)).toEqual({ receivedBytes: 0, complete: true }); + expect(await getUploadFileProgress(root, empty)).toEqual({ receivedBytes: 0, complete: true }); + expect(await hashUploadedFiles(root, [empty])).toEqual([{ ...empty, sha256: sha('') }]); + }); + + it('does not advance progress for wrong checksums, truncated requests, oversized streams or disconnects', async () => { + await receiveUploadChunk(root, file, chunk('0123'), stream('0123')); + await expect(receiveUploadChunk(root, file, chunk('456789', 4), stream('xxxxxx'))).rejects.toThrow(/checksum/); + await expect(receiveUploadChunk(root, file, chunk('456789', 4), stream('45'))).rejects.toThrow(/size mismatch/); + await expect(receiveUploadChunk(root, file, chunk('456789', 4), stream('4567890'))).rejects.toThrow(/exceeds/); + let reads = 0; + const disconnect = new ReadableStream({ pull(controller) { + if (reads++ === 0) controller.enqueue(new TextEncoder().encode('45')); + else controller.error(new Error('Browser connection ended.')); + } }); + await expect(receiveUploadChunk(root, file, chunk('456789', 4), disconnect)).rejects.toThrow(/connection ended/); + expect(await getUploadFileProgress(root, file)).toEqual({ receivedBytes: 4, complete: false }); + expect(await readFile(join(root, file.path), 'utf8')).toBe('0123'); + expect(await readdir(`${root}.chunks`)).toEqual([]); + await receiveUploadChunk(root, file, chunk('456789', 4), stream('456789')); + }); + + it('bounds request size before reading any body and rejects invalid metadata', async () => { + const tooLarge = MAX_UPLOAD_CHUNK_BYTES + 1; + await expect(receiveUploadChunk(root, { path: 'large.iwd', size: tooLarge }, { + offset: 0, size: tooLarge, sha256: sha('') + }, null)).rejects.toThrow(/Invalid upload chunk/); + for (const metadata of [ + { ...chunk('a'), offset: -1 }, { ...chunk('a'), offset: 0.5 }, { ...chunk('a'), size: 1.5 }, + { ...chunk('a'), sha256: 'wrong' }, chunk('', 0), chunk('01234567890', 0) + ]) await expect(receiveUploadChunk(root, file, metadata, stream('a'))).rejects.toThrow(/Invalid upload chunk/); + await expect(receiveUploadChunk(root, file, chunk('a'), null)).rejects.toThrow(/body is required/); + }); + + it('streams a maximum-size chunk supplied in small pieces', async () => { + const piece = new Uint8Array(64 * 1024).fill(97); + let remaining = MAX_UPLOAD_CHUNK_BYTES; + const digest = createHash('sha256'); + for (let i = 0; i < MAX_UPLOAD_CHUNK_BYTES / piece.length; i++) digest.update(piece); + const body = new ReadableStream({ pull(controller) { + if (remaining === 0) controller.close(); + else { remaining -= piece.length; controller.enqueue(piece); } + } }); + const large = { path: 'main/large.iwd', size: MAX_UPLOAD_CHUNK_BYTES }; + const sha256 = digest.digest('hex'); + await receiveUploadChunk(root, large, { offset: 0, size: large.size, sha256 }, body); + expect(await hashUploadedFiles(root, [large])).toEqual([{ ...large, sha256 }]); + }); + + it('rejects missing or incorrectly sized files before publication', async () => { + await receiveUploadChunk(root, file, chunk('0123'), stream('0123')); + await expect(hashUploadedFiles(root, [file])).rejects.toThrow(/Size verification/); + await writeFile(join(root, file.path), '01234567890'); + await expect(getUploadFileProgress(root, file)).rejects.toThrow(/exceeds/); + }); + + it('refuses traversal and symlinks in destination and temporary storage', async () => { + await mkdir(root); + const outside = join(directory, 'outside'); + await mkdir(outside); + await expect(receiveUploadChunk(root, { path: '../escape', size: 1 }, chunk('a'), stream('a'))).rejects.toThrow(/relative/); + await symlink(outside, join(root, 'zone')); + await expect(receiveUploadChunk(root, file, chunk('0123'), stream('0123'))).rejects.toThrow(/non-regular/); + await rm(join(root, 'zone')); + await symlink(outside, `${root}.chunks`); + await expect(receiveUploadChunk(root, file, chunk('0123'), stream('0123'))).rejects.toThrow(/non-regular/); + expect(await readdir(outside)).toEqual([]); + }); +}); + +describe('upload and publication coordination', () => { + it('serializes simultaneous writes with the same offset', async () => { + const responses = await Promise.allSettled([ + receiveUploadChunk(root, file, chunk('0123'), stream('0123')), + receiveUploadChunk(root, file, chunk('0123'), stream('0123')) + ]); + expect(responses.map(response => response.status)).toEqual(['fulfilled', 'rejected']); + expect((responses[1] as PromiseRejectedResult).reason).toMatchObject({ name: 'UploadOffsetError', receivedBytes: 4 }); + expect(await readFile(join(root, file.path), 'utf8')).toBe('0123'); + }); + + it('rechecks writable state inside the lock after queued publication and releases failed locks', async () => { + let finishPublication!: () => void; + const gate = new Promise(resolve => { finishPublication = resolve; }); + let published = false; + const publication = withUploadLock(root, async () => { await gate; published = true; }); + const transfer = receiveUploadChunk(root, file, chunk('0123'), stream('0123'), () => { + if (published) throw new Error('Library is immutable.'); + }); + finishPublication(); + await publication; + await expect(transfer).rejects.toThrow(/immutable/); + expect(await getUploadFileProgress(root, file)).toEqual({ receivedBytes: 0, complete: false }); + await expect(withUploadLock(root, () => { throw new Error('Failed work'); })).rejects.toThrow(/Failed work/); + expect(await withUploadLock(root, () => 'next operation')).toBe('next operation'); + }); +}); diff --git a/tests/unit/game-folder-selection.test.ts b/tests/unit/game-folder-selection.test.ts new file mode 100644 index 0000000..094d2c4 --- /dev/null +++ b/tests/unit/game-folder-selection.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest'; +import { selectGameFolder } from '../../src/lib/ui/game-folder.js'; + +function selectedFile(path: string, size = 10) { + const read = vi.fn(() => { throw new Error('Folder selection must not read file bodies.'); }); + const file = { + name: path.split('/').at(-1), webkitRelativePath: path, size, lastModified: 1_700_000_000_000, + arrayBuffer: read, text: read, stream: read, slice: read + } as unknown as File; + return { file, read }; +} + +describe('game folder selection', () => { + it('removes the selected root exactly once while preserving nested game paths', () => { + const first = selectedFile('T6/T6/zone/english/base.ff', 20); + const second = selectedFile('T6/main/iw_00.iwd', 30); + const result = selectGameFolder([first.file, second.file]); + expect(result.name).toBe('T6'); + expect(result.size).toBe(50); + expect(result.files).toEqual([ + { path: 'T6/zone/english/base.ff', file: first.file }, + { path: 'main/iw_00.iwd', file: second.file } + ]); + expect(first.read).not.toHaveBeenCalled(); + expect(second.read).not.toHaveBeenCalled(); + }); + + it('prepares multi-gigabyte and empty files using metadata alone', () => { + const large = selectedFile('Game/zone/base.ff', 7 * 1024 ** 3); + const empty = selectedFile('Game/empty.txt', 0); + const result = selectGameFolder([large.file, empty.file]); + expect(result.size).toBe(7 * 1024 ** 3); + expect(result.files.map(item => item.path)).toEqual(['empty.txt', 'zone/base.ff']); + expect(result.files[1].file).toBe(large.file); + expect(large.read).not.toHaveBeenCalled(); + expect(empty.read).not.toHaveBeenCalled(); + }); + + it('rejects case-insensitive duplicate file names', () => { + expect(() => selectGameFolder([ + selectedFile('Game/Zone/base.ff').file, + selectedFile('Game/zone/BASE.ff').file + ])).toThrow('duplicate game paths'); + }); + + it('rejects a path used as both a file and a directory regardless of case', () => { + expect(() => selectGameFolder([ + selectedFile('Game/zone/english/base.ff').file, + selectedFile('Game/ZONE').file + ])).toThrow('same game path for a file and a directory'); + }); + + it.each(['../base.ff', './base.ff', '/Game/base.ff', 'base.ff'])( + 'rejects an absent or parent root in %s', path => { + expect(() => selectGameFolder([selectedFile(path).file])).toThrow('one game folder'); + } + ); + + it.each(['Game/../base.ff', 'Game/zone/./base.ff', 'Game/zone//base.ff', 'Game/zone\\base.ff'])( + 'rejects traversal and non-normalized content paths in %s', path => { + expect(() => selectGameFolder([selectedFile(path).file])).toThrow('unsupported game path'); + } + ); + + it('rejects files from multiple selected roots', () => { + expect(() => selectGameFolder([ + selectedFile('T5/main/base.ff').file, + selectedFile('T6/main/base.ff').file + ])).toThrow('one game folder'); + }); + + it('rejects empty selections', () => { + expect(() => selectGameFolder([])).toThrow('contains no files'); + }); +});