diff --git a/app/build.bat b/app/build.bat index 4d80d3b0..46762498 100644 --- a/app/build.bat +++ b/app/build.bat @@ -610,6 +610,13 @@ if errorlevel 1 ( endlocal & exit /b 1 ) +rem ── Pre-build verification (typecheck + tests across packages) ── +call :run_pre_build_tests +if errorlevel 1 ( + for %%f in (!_all_expected!) do echo FAIL %%f>>"!BUILD_RESULTS_FILE!" + endlocal & exit /b 1 +) + rem ── Build frontend ── call :build_frontend if errorlevel 1 ( @@ -721,6 +728,40 @@ call :elapsed_since !_icons_start! _icons_elapsed echo === Icons done ^(!_icons_elapsed!^) === exit /b 0 +rem ─── run_pre_build_tests ───────────────────────────────────────────────── +rem Pre-build verification: ensure source quality across packages before producing artifacts. +rem Backend unit tests run inside backend\build.bat, here we cover the rest. +rem Skip with SKIP_TESTS=1 only in emergencies (e.g. broken upstream tooling); CI must never set it. + +:run_pre_build_tests +if "%SKIP_TESTS%"=="1" ( + echo === Pre-build tests skipped ^(SKIP_TESTS=1^) === + exit /b 0 +) +set "_test_start=0" +call :get_timestamp _test_start +echo === Running pre-build verification === +cd /d "!ROOT_DIR!\shared" +call bun install --frozen-lockfile +if errorlevel 1 exit /b 1 +call bun run typecheck +if errorlevel 1 exit /b 1 +cd /d "!ROOT_DIR!\cli" +call bun install --frozen-lockfile +if errorlevel 1 exit /b 1 +call bun run typecheck +if errorlevel 1 exit /b 1 +cd /d "!ROOT_DIR!\frontend" +call bun install --frozen-lockfile +if errorlevel 1 exit /b 1 +call bun run check +if errorlevel 1 exit /b 1 +cd /d "!SCRIPT_DIR!" +set "_test_elapsed=0" +call :elapsed_since !_test_start! _test_elapsed +echo === Pre-build verification done ^(!_test_elapsed!^) === +exit /b 0 + rem ─── build_frontend ────────────────────────────────────────────────────── :build_frontend @@ -732,8 +773,11 @@ set "_fe_start=0" call :get_timestamp _fe_start echo === Building frontend === cd /d "!ROOT_DIR!\frontend" -call build.bat +call bun i --frozen-lockfile +if errorlevel 1 exit /b 1 +call bun --bun run build if errorlevel 1 exit /b 1 +cd /d "!SCRIPT_DIR!" set "_fe_elapsed=0" call :elapsed_since !_fe_start! _fe_elapsed echo === Frontend done ^(!_fe_elapsed!^) === @@ -749,10 +793,13 @@ call :get_timestamp _be_start echo === Building backend ^(target: !BUN_TGT!^) === cd /d "!ROOT_DIR!\backend" if exist build rmdir /s /q build -bun i --frozen-lockfile +call bun i --frozen-lockfile if errorlevel 1 ( endlocal & exit /b 1 ) mkdir build -bun build --compile --target !BUN_TGT! src/app.ts --outfile build\lish-backend.exe +call bun build --compile --target !BUN_TGT! ./src/app.ts --outfile build\lish-backend.exe +if errorlevel 1 ( endlocal & exit /b 1 ) +mkdir build\lish +call bun build ./src/lish/checksum-worker.ts --target bun --outfile build\lish\checksum-worker.js if errorlevel 1 ( endlocal & exit /b 1 ) set "_be_elapsed=0" call :elapsed_since !_be_start! _be_elapsed @@ -764,19 +811,19 @@ rem ─── sync_product_info ──────────────── :sync_product_info set "PRODUCT_JSON=!ROOT_DIR!\shared\src\product.json" -for /f "tokens=*" %%v in ('bun -e "process.stdout.write(require(process.argv[1]).version)" "!PRODUCT_JSON!"') do set "PRODUCT_VERSION=%%v" -for /f "tokens=*" %%n in ('bun -e "process.stdout.write(require(process.argv[1]).name)" "!PRODUCT_JSON!"') do set "PRODUCT_NAME=%%n" -for /f "tokens=*" %%d in ('bun -e "process.stdout.write(require(process.argv[1]).identifier)" "!PRODUCT_JSON!"') do set "PRODUCT_IDENTIFIER=%%d" +for /f "tokens=*" %%v in ('call bun -e "process.stdout.write(require(process.argv[1]).version)" "!PRODUCT_JSON!"') do set "PRODUCT_VERSION=%%v" +for /f "tokens=*" %%n in ('call bun -e "process.stdout.write(require(process.argv[1]).name)" "!PRODUCT_JSON!"') do set "PRODUCT_NAME=%%n" +for /f "tokens=*" %%d in ('call bun -e "process.stdout.write(require(process.argv[1]).identifier)" "!PRODUCT_JSON!"') do set "PRODUCT_IDENTIFIER=%%d" echo Product: !PRODUCT_NAME! v!PRODUCT_VERSION! (!PRODUCT_IDENTIFIER!) rem Sync tauri.conf.json -bun -e "var f=require('fs'),p=require(process.argv[1]),t=process.argv[2],c=JSON.parse(f.readFileSync(t,'utf8'));c.productName=p.name;c.mainBinaryName=p.name;c.version=p.version;c.identifier=p.identifier;c.bundle.windows.nsis.startMenuFolder=p.name;f.writeFileSync(t,JSON.stringify(c,null,'\t')+'\n')" "!PRODUCT_JSON!" "!SCRIPT_DIR!tauri.conf.json" +call bun -e "var f=require('fs'),p=require(process.argv[1]),t=process.argv[2],c=JSON.parse(f.readFileSync(t,'utf8'));c.productName=p.name;c.mainBinaryName=p.name;c.version=p.version;c.identifier=p.identifier;c.bundle.windows.nsis.startMenuFolder=p.name;f.writeFileSync(t,JSON.stringify(c,null,'\t')+'\n')" "!PRODUCT_JSON!" "!SCRIPT_DIR!tauri.conf.json" rem Sync Cargo.toml version -bun -e "var f=require('fs'),v=process.argv[1],t=process.argv[2],s=f.readFileSync(t,'utf8').replace(/^version = \"[^\"]*\"/m,'version = \"'+v+'\"');f.writeFileSync(t,s)" "!PRODUCT_VERSION!" "!SCRIPT_DIR!Cargo.toml" +call bun -e "var f=require('fs'),v=process.argv[1],t=process.argv[2],s=f.readFileSync(t,'utf8').replace(/^version = \"[^\"]*\"/m,'version = \"'+v+'\"');f.writeFileSync(t,s)" "!PRODUCT_VERSION!" "!SCRIPT_DIR!Cargo.toml" rem Sync wix-fragment-debug.wxs -bun -e "var f=require('fs'),n=process.argv[1],s=f.readFileSync(process.argv[2],'utf8').replace(/\{\{product_name\}\}/g,n);f.writeFileSync(process.argv[2],s)" "!PRODUCT_NAME!" "!SCRIPT_DIR!wix-fragment-debug.wxs" +call bun -e "var f=require('fs'),n=process.argv[1],s=f.readFileSync(process.argv[2],'utf8').replace(/\{\{product_name\}\}/g,n);f.writeFileSync(process.argv[2],s)" "!PRODUCT_NAME!" "!SCRIPT_DIR!wix-fragment-debug.wxs" exit /b 0 rem ─── build_zip ──────────────────────────────────────────────────────────── @@ -788,6 +835,8 @@ if exist "!ZIP_STAGING!" rmdir /s /q "!ZIP_STAGING!" mkdir "!ZIP_STAGING!" copy /y "!BUILD_RELEASE_DIR!\!PRODUCT_NAME!.exe" "!ZIP_STAGING!\!PRODUCT_NAME!.exe" >nul copy /y "!ROOT_DIR!\backend\build\lish-backend.exe" "!ZIP_STAGING!\lish-backend.exe" >nul +mkdir "!ZIP_STAGING!\lish" +xcopy /e /i /y "!ROOT_DIR!\backend\build\lish" "!ZIP_STAGING!\lish" >nul rem Create Debug.bat from template powershell -Command "(Get-Content '!SCRIPT_DIR!bundle-scripts\Debug.bat' -Raw) -replace '\{\{product_name\}\}','!PRODUCT_NAME!' | Set-Content '!ZIP_STAGING!\Debug.bat' -NoNewline" powershell -Command "Compress-Archive -Path '!ZIP_STAGING!\*' -DestinationPath '!FINAL_DIR!\!PRODUCT_NAME!_!PRODUCT_VERSION!_windows_!_arch!.zip' -CompressionLevel !ZIP_PS_LEVEL! -Force" diff --git a/app/build.sh b/app/build.sh index b7640009..620ca197 100755 --- a/app/build.sh +++ b/app/build.sh @@ -334,6 +334,22 @@ build_frontend() { echo "=== Frontend done ($(elapsed_since $_t)) ===" } +# Pre-build verification: ensure source quality across packages before producing artifacts. +# Backend unit tests run inside backend/build.sh, here we cover the rest. +# Skip with SKIP_TESTS=1 only in emergencies (e.g. broken upstream tooling); CI must never set it. +run_pre_build_tests() { + if [ "${SKIP_TESTS:-0}" = "1" ]; then + echo "=== Pre-build tests skipped (SKIP_TESTS=1) ===" + return 0 + fi + _t=$(date +%s) + echo "=== Running pre-build verification ===" + (cd "$ROOT_DIR/shared" && bun install --frozen-lockfile && bun run typecheck) + (cd "$ROOT_DIR/cli" && bun install --frozen-lockfile && bun run typecheck) + (cd "$ROOT_DIR/frontend" && bun install --frozen-lockfile && bun run check) + echo "=== Pre-build verification done ($(elapsed_since $_t)) ===" +} + build_backend() { if [ "$BUILD_OS" = "macos" ] && [ "$BUILD_ARCH" = "universal" ]; then _t=$(date +%s) @@ -563,6 +579,7 @@ ${PRODUCT_NAME} - peer-to-peer file sharing application %files /usr/bin/${PRODUCT_NAME_LOWER} /usr/bin/lish-backend +/usr/bin/lish /usr/share/applications/${PRODUCT_NAME_LOWER}.desktop /usr/share/applications/${PRODUCT_NAME_LOWER}-debug.desktop /usr/share/icons/hicolor/256x256/apps/${PRODUCT_NAME_LOWER}.png @@ -646,6 +663,8 @@ APPRUN_EOF _stage_zip_linux() { cp "$BUILD_RELEASE_DIR/$PRODUCT_NAME_LOWER" "$ZIP_STAGING/" cp "$ROOT_DIR/backend/build/lish-backend" "$ZIP_STAGING/lish-backend" + mkdir -p "$ZIP_STAGING/lish" + cp -r "$ROOT_DIR/backend/build/lish/." "$ZIP_STAGING/lish/" _copy_debug_script chmod +x "$ZIP_STAGING/$PRODUCT_NAME_LOWER" "$ZIP_STAGING/lish-backend" } @@ -653,6 +672,8 @@ _stage_zip_linux() { _stage_zip_windows() { cp "$BUILD_RELEASE_DIR/${PRODUCT_NAME}.exe" "$ZIP_STAGING/" cp "$ROOT_DIR/backend/build/lish-backend.exe" "$ZIP_STAGING/lish-backend.exe" + mkdir -p "$ZIP_STAGING/lish" + cp -r "$ROOT_DIR/backend/build/lish/." "$ZIP_STAGING/lish/" sed "s/{{product_name}}/$PRODUCT_NAME/g" \ "$SCRIPT_DIR/bundle-scripts/Debug.bat" >"$ZIP_STAGING/Debug.bat" } @@ -694,6 +715,8 @@ build_linux_packages() { chmod +x "$PKG_STAGING/usr/bin/$PRODUCT_NAME_LOWER" cp "$ROOT_DIR/backend/build/lish-backend" "$PKG_STAGING/usr/bin/" chmod +x "$PKG_STAGING/usr/bin/lish-backend" + mkdir -p "$PKG_STAGING/usr/bin/lish" + cp -r "$ROOT_DIR/backend/build/lish/." "$PKG_STAGING/usr/bin/lish/" generate_desktop_entry "$PKG_STAGING/usr/share/applications/${PRODUCT_NAME_LOWER}.desktop" generate_desktop_entry "$PKG_STAGING/usr/share/applications/${PRODUCT_NAME_LOWER}-debug.desktop" --debug @@ -914,6 +937,7 @@ docker_inner_build() { _inner_fail=0 build_icons + run_pre_build_tests build_frontend build_backend sync_product_info diff --git a/app/tauri.linux.conf.json b/app/tauri.linux.conf.json index 24588083..cf02b277 100644 --- a/app/tauri.linux.conf.json +++ b/app/tauri.linux.conf.json @@ -3,7 +3,8 @@ "mainBinaryName": "libershare", "bundle": { "resources": { - "../backend/build/lish-backend": "./lish-backend" + "../backend/build/lish-backend": "./lish-backend", + "../backend/build/lish/checksum-worker.js": "./lish/checksum-worker.js" }, "linux": { "deb": { diff --git a/app/tauri.macos.conf.json b/app/tauri.macos.conf.json index 95037331..2909776c 100644 --- a/app/tauri.macos.conf.json +++ b/app/tauri.macos.conf.json @@ -1,7 +1,8 @@ { "bundle": { "resources": { - "../backend/build/lish-backend": "./lish-backend" + "../backend/build/lish-backend": "./lish-backend", + "../backend/build/lish/checksum-worker.js": "./lish/checksum-worker.js" } } } diff --git a/app/tauri.windows.conf.json b/app/tauri.windows.conf.json index b39b620d..eb66a49d 100644 --- a/app/tauri.windows.conf.json +++ b/app/tauri.windows.conf.json @@ -1,7 +1,8 @@ { "bundle": { "resources": { - "../backend/build/lish-backend.exe": "./lish-backend.exe" + "../backend/build/lish-backend.exe": "./lish-backend.exe", + "../backend/build/lish/checksum-worker.js": "./lish/checksum-worker.js" }, "windows": { "wix": { diff --git a/backend/build.bat b/backend/build.bat index 21eb300b..e1ed20cc 100644 --- a/backend/build.bat +++ b/backend/build.bat @@ -1,8 +1,24 @@ @echo off if exist build rmdir /s /q build -bun i --frozen-lockfile +call bun i --frozen-lockfile +if errorlevel 1 exit /b 1 + +rem Pre-build verification: typecheck + unit tests must pass before producing artifacts. +rem Skip with SKIP_TESTS=1 only in emergencies (e.g. broken upstream tooling); CI must never set it. +if not "%SKIP_TESTS%"=="1" ( + call bun run typecheck + if errorlevel 1 exit /b 1 + call bun run test + if errorlevel 1 exit /b 1 +) + mkdir build -bun build --compile src/app.ts --outfile build\lish-backend.exe +call bun build --compile ./src/app.ts --outfile build\lish-backend.exe +if errorlevel 1 exit /b 1 +mkdir build\lish +call bun build ./src/lish/checksum-worker.ts --target bun --outfile build\lish\checksum-worker.js +if errorlevel 1 exit /b 1 rem Patch PE subsystem from CONSOLE (3) to WINDOWS_GUI (2) to prevent console window powershell -Command "$f='%~dp0build\lish-backend.exe'; $b=[IO.File]::ReadAllBytes($f); $pe=[BitConverter]::ToInt32($b,0x3C); $b[$pe+0x5C]=2; $b[$pe+0x5D]=0; [IO.File]::WriteAllBytes($f,$b)" +if errorlevel 1 exit /b 1 diff --git a/backend/build.sh b/backend/build.sh index c4911a39..c35a8533 100755 --- a/backend/build.sh +++ b/backend/build.sh @@ -26,12 +26,22 @@ done mkdir -p build bun i --frozen-lockfile +# Pre-build verification: typecheck + unit tests must pass before producing artifacts. +# Skip with SKIP_TESTS=1 only in emergencies (e.g. broken upstream tooling); CI must never set it. +if [ "${SKIP_TESTS:-0}" != "1" ]; then + bun run typecheck + bun run test +fi + +mkdir -p build/lish + if [ -n "$BUN_TARGET" ]; then echo "Building backend for target: $BUN_TARGET" case "$BUN_TARGET" in - *windows*) bun build --compile --target "$BUN_TARGET" src/app.ts --outfile build/lish-backend.exe ;; - *) bun build --compile --target "$BUN_TARGET" src/app.ts --outfile build/lish-backend ;; + *windows*) bun build --compile --target "$BUN_TARGET" ./src/app.ts --outfile build/lish-backend.exe ;; + *) bun build --compile --target "$BUN_TARGET" ./src/app.ts --outfile build/lish-backend ;; esac else - bun build --compile src/app.ts --outfile build/lish-backend + bun build --compile ./src/app.ts --outfile build/lish-backend fi +bun build ./src/lish/checksum-worker.ts --target bun --outfile build/lish/checksum-worker.js diff --git a/backend/src/app.ts b/backend/src/app.ts index 869ee64d..c6e7384b 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,4 +1,5 @@ import { dirname, join } from 'path'; +import { pathToFileURL } from 'url'; import { productName, productVersion } from '@shared'; import { setupLogger, type LogLevel } from './logger.ts'; import { Networks } from './lishnet/lishnets.ts'; @@ -15,11 +16,7 @@ const args = process.argv.slice(2); // Default dataDir: next to binary if compiled, otherwise ./data (relative to CWD) const isCompiledBinary = process.execPath !== Bun.which('bun'); let dataDir = isCompiledBinary ? join(dirname(process.execPath), 'data') : './data'; - -// In compiled binaries, import.meta.url is always the binary path (/$bunfs/root/), -// so the worker is at ./lish/checksum-worker.js relative to it. -// In dev mode the default in lish.ts (./checksum-worker.ts relative to lish.ts) is correct. -if (isCompiledBinary) setWorkerUrl(new URL('./lish/checksum-worker.js', import.meta.url).href); +if (isCompiledBinary) setWorkerUrl(pathToFileURL(join(dirname(process.execPath), 'lish', 'checksum-worker.js')).href); let logLevel: LogLevel = isCompiledBinary ? 'info' : 'debug'; let apiHost = 'localhost'; diff --git a/backend/src/lish/lish.ts b/backend/src/lish/lish.ts index ff25f60b..a7ea6441 100644 --- a/backend/src/lish/lish.ts +++ b/backend/src/lish/lish.ts @@ -7,16 +7,23 @@ import { calculateChecksum } from './checksum.ts'; import { Utils } from '../utils.ts'; import { type DataServer } from './data-server.ts'; -// Worker URL for checksum-worker. Default works in dev mode (import.meta.url is the actual file URL). -// In compiled binaries, import.meta.url is always the binary path, so app.ts must call setWorkerUrl() -// with new URL('./lish/checksum-worker.js', import.meta.url).href before any LISH creation. -let _workerUrl: string = new URL('./checksum-worker.ts', import.meta.url).href; +// Cached at module load: Bun.which() walks PATH on every call, and this flag is consulted on +// every parallel-checksum invocation. Compiled binaries always have execPath !== Bun.which('bun'). +const _isCompiledBinary = process.execPath !== Bun.which('bun'); +const _canTerminateBusyWorkers = !(process.platform === 'win32' && _isCompiledBinary); -/** Override the checksum worker URL. Must be called from the main entrypoint (app.ts) in compiled mode. */ +let _workerUrl: string | null = null; + +/** Override the checksum worker URL for tests or external launchers. */ export function setWorkerUrl(url: string): void { _workerUrl = url; } +function createChecksumWorker(): Worker { + if (_workerUrl) return new Worker(_workerUrl); + return new Worker(new URL('./checksum-worker.ts', import.meta.url)); +} + // Helper to normalize paths to forward slashes function normalizePath(p: string): string { return p.replace(/\\/g, '/'); @@ -74,9 +81,15 @@ async function calculateChecksumsParallel(filePath: string, fileSize: number, ch const totalChunks = Math.ceil(fileSize / chunkSize); const cpuCount = maxWorkers > 0 ? maxWorkers : navigator.hardwareConcurrency || 1; const workerCount = Math.min(cpuCount, totalChunks); + const releaseWorkers = (): void => { + for (const worker of workers) { + if (_canTerminateBusyWorkers) worker.terminate(); + else (worker as Worker & { unref?: () => void }).unref?.(); + } + }; // Create workers const workers: Worker[] = []; - for (let i = 0; i < workerCount; i++) workers.push(new Worker(_workerUrl)); + for (let i = 0; i < workerCount; i++) workers.push(createChecksumWorker()); let completedChunks = 0; const results: string[] = new Array(totalChunks); let nextChunk = 0; @@ -87,14 +100,28 @@ async function calculateChecksumsParallel(filePath: string, fileSize: number, ch function abortHandler(): void { if (finished) return; finished = true; - workers.forEach(w => w.terminate()); + releaseWorkers(); rejectAll(new CodedError(ErrorCodes.LISH_CREATE_CANCELLED)); } + function failWorker(error: unknown): void { + if (finished) return; + finished = true; + signal?.removeEventListener('abort', abortHandler); + releaseWorkers(); + rejectAll(error instanceof Error ? error : new Error(String(error))); + } if (signal?.aborted) { abortHandler(); return; } signal?.addEventListener('abort', abortHandler, { once: true }); + for (const worker of workers) { + worker.addEventListener('error', event => { + const message = event instanceof ErrorEvent ? event.message : 'checksum worker failed'; + failWorker(new Error(message)); + }); + worker.addEventListener('messageerror', () => failWorker(new Error('checksum worker message could not be deserialized'))); + } function feedWorker(workerIndex: number): void { if (finished) return; if (nextChunk >= totalChunks) return; @@ -106,8 +133,7 @@ async function calculateChecksumsParallel(filePath: string, fileSize: number, ch worker.removeEventListener('message', handler); if (finished) return; if (event.data.error) { - finished = true; - rejectAll(new Error(event.data.error)); + failWorker(new Error(event.data.error)); return; } results[chunkIndex] = event.data.checksum; @@ -125,8 +151,8 @@ async function calculateChecksumsParallel(filePath: string, fileSize: number, ch // Start one chunk per worker for (let i = 0; i < workerCount; i++) feedWorker(i); }); - // Terminate workers - workers.forEach(w => w.terminate()); + // Release workers via the platform-aware helper so the success path matches abort/error cleanup. + releaseWorkers(); return results; } diff --git a/backend/tests/compiled-worker-smoke.test.ts b/backend/tests/compiled-worker-smoke.test.ts new file mode 100644 index 00000000..4a01ac2c --- /dev/null +++ b/backend/tests/compiled-worker-smoke.test.ts @@ -0,0 +1,278 @@ +import { expect, test } from 'bun:test'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawn, type Subprocess } from 'bun'; + +async function findFreePort(): Promise { + const server = Bun.serve({ + port: 0, + fetch: () => new Response('ok'), + }); + const port = server.port; + server.stop(true); + if (port === undefined) throw new Error('failed to allocate a free port'); + return port; +} + +async function waitForBackend(port: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const ws = new WebSocket(`ws://localhost:${port}/ws`); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ws.close(); + reject(new Error('websocket open timeout')); + }, 500); + ws.addEventListener('open', () => { + clearTimeout(timer); + ws.close(); + resolve(); + }); + ws.addEventListener('error', error => { + clearTimeout(timer); + reject(error); + }); + }); + return; + } catch (error) { + lastError = error; + await Bun.sleep(250); + } + } + throw new Error(`backend did not open ws://localhost:${port}/ws: ${String(lastError)}`); +} + +function rpc(ws: WebSocket, method: string, params: Record, timeoutMs = 15_000): Promise { + const id = crypto.randomUUID(); + ws.send(JSON.stringify({ id, method, params })); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ws.removeEventListener('message', onMessage); + reject(new Error(`RPC timeout: ${method}`)); + }, timeoutMs); + function onMessage(event: MessageEvent): void { + const msg = JSON.parse(event.data as string); + if (msg.id !== id) return; + clearTimeout(timer); + ws.removeEventListener('message', onMessage); + if (msg.error) reject(new Error(`${method}: ${msg.error}${msg.errorDetail ? `: ${msg.errorDetail}` : ''}`)); + else resolve(msg.result); + } + ws.addEventListener('message', onMessage); + }); +} + +async function openWs(port: number): Promise { + const ws = new WebSocket(`ws://localhost:${port}/ws`); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('websocket open timeout')), 5_000); + ws.addEventListener('open', () => { + clearTimeout(timer); + resolve(); + }); + ws.addEventListener('error', error => { + clearTimeout(timer); + reject(error); + }); + }); + return ws; +} + +async function stopProcess(proc: Subprocess<'ignore', 'pipe', 'pipe'>): Promise { + try { + proc.kill(); + await Promise.race([proc.exited, Bun.sleep(5_000)]); + } catch {} +} + +interface CompiledBackend { + tempRoot: string; + sourceDir: string; + outputDir: string; + exePath: string; + apiPort: number; + backend: Subprocess<'ignore', 'pipe', 'pipe'>; +} + +async function buildAndSpawnCompiledBackend(prefix: string): Promise { + const tempRoot = await mkdtemp(join(tmpdir(), `libershare-${prefix}-`)); + const sourceDir = join(tempRoot, 'source'); + const outputDir = join(tempRoot, 'out'); + const dataDir = join(tempRoot, 'data'); + const buildDir = join(tempRoot, 'build'); + const runDir = join(tempRoot, 'run'); + const exePath = join(buildDir, process.platform === 'win32' ? 'lish-backend.exe' : 'lish-backend'); + + await mkdir(sourceDir, { recursive: true }); + await mkdir(outputDir, { recursive: true }); + await mkdir(dataDir, { recursive: true }); + await mkdir(buildDir, { recursive: true }); + await mkdir(join(buildDir, 'lish'), { recursive: true }); + await mkdir(runDir, { recursive: true }); + await writeFile( + join(dataDir, 'settings.json'), + JSON.stringify({ + network: { + incomingPort: 0, + allowRelay: false, + maxRelayClients: 0, + mdnsEnabled: false, + }, + }) + ); + + const build = spawn({ + cmd: ['bun', 'build', '--compile', './src/app.ts', '--outfile', exePath], + cwd: import.meta.dir + '/..', + stdout: 'pipe', + stderr: 'pipe', + }); + const buildExit = await build.exited; + const buildOutput = `${await new Response(build.stdout).text()}${await new Response(build.stderr).text()}`; + expect(buildExit, buildOutput).toBe(0); + + const workerBuild = spawn({ + cmd: ['bun', 'build', './src/lish/checksum-worker.ts', '--target', 'bun', '--outfile', join(buildDir, 'lish', 'checksum-worker.js')], + cwd: import.meta.dir + '/..', + stdout: 'pipe', + stderr: 'pipe', + }); + const workerBuildExit = await workerBuild.exited; + const workerBuildOutput = `${await new Response(workerBuild.stdout).text()}${await new Response(workerBuild.stderr).text()}`; + expect(workerBuildExit, workerBuildOutput).toBe(0); + + const apiPort = await findFreePort(); + const backend = spawn({ + cmd: [exePath, '--datadir', dataDir, '--host', 'localhost', '--port', String(apiPort), '--loglevel', 'debug'], + cwd: runDir, + stdout: 'pipe', + stderr: 'pipe', + }); + await waitForBackend(apiPort, 20_000); + + return { tempRoot, sourceDir, outputDir, exePath, apiPort, backend }; +} + +async function teardownCompiledBackend(ctx: CompiledBackend | undefined): Promise { + if (!ctx) return; + await stopProcess(ctx.backend); + await rm(ctx.tempRoot, { recursive: true, force: true }); +} + +test( + 'compiled backend can create a LISH with the default checksum settings outside the source tree', + async () => { + let ctx: CompiledBackend | undefined; + try { + ctx = await buildAndSpawnCompiledBackend('compiled-worker-smoke'); + await writeFile(join(ctx.sourceDir, 'a.txt'), 'a'.repeat(10_000)); + await writeFile(join(ctx.sourceDir, 'b.txt'), 'b'.repeat(10_000)); + + const ws = await openWs(ctx.apiPort); + const progressEvents: string[] = []; + ws.addEventListener('message', event => { + const msg = JSON.parse(event.data as string); + if (msg.event === 'lishs.create:progress') progressEvents.push(msg.data.type); + }); + + await rpc(ws, 'events.subscribe', { events: ['lishs.create:progress'] }); + const result = await rpc( + ws, + 'lishs.create', + { + dataPath: ctx.sourceDir, + lishFile: join(ctx.outputDir, 'compiled-worker-smoke.lish'), + addToSharing: false, + addToDownloading: false, + name: 'Compiled worker smoke', + chunkSize: 1024, + minifyJSON: false, + compress: false, + }, + 30_000 + ); + ws.close(); + + expect(result).toHaveProperty('lishID'); + expect(progressEvents).toContain('chunk'); + expect(progressEvents.filter(type => type === 'file')).toHaveLength(2); + } finally { + await teardownCompiledBackend(ctx); + } + }, + 120_000 +); + +// Regression guard for the Windows compiled `worker.terminate()` segfault path. The fix in +// lish.ts swaps terminate() for unref() on busy workers when running as a compiled binary on +// Windows. We prove the abort cleanup no longer crashes by mid-flight cancelling a multi-file +// LISH creation and verifying the backend stays responsive afterwards. +test( + 'compiled backend handles stopCreate mid-flight without crashing', + async () => { + let ctx: CompiledBackend | undefined; + try { + ctx = await buildAndSpawnCompiledBackend('compiled-worker-stop'); + // 3 × 8 MB random files keep workers busy long enough to abort during chunk hashing + // without finishing the run instantly. + const fileBytes = 8 * 1024 * 1024; + for (const name of ['a.bin', 'b.bin', 'c.bin']) { + const buf = new Uint8Array(fileBytes); + crypto.getRandomValues(buf.subarray(0, 65_536)); + await writeFile(join(ctx.sourceDir, name), buf); + } + + const ws = await openWs(ctx.apiPort); + let progressCount = 0; + ws.addEventListener('message', event => { + const msg = JSON.parse(event.data as string); + if (msg.event === 'lishs.create:progress') progressCount++; + }); + await rpc(ws, 'events.subscribe', { events: ['lishs.create:progress'] }); + + const createPromise = rpc( + ws, + 'lishs.create', + { + dataPath: ctx.sourceDir, + lishFile: join(ctx.outputDir, 'stop-create.lish'), + addToSharing: false, + addToDownloading: false, + name: 'Stop create regression', + chunkSize: 65_536, + minifyJSON: false, + compress: false, + }, + 60_000 + ); + + // Wait until at least one chunk has progressed so we know workers are active. + const startWait = Date.now(); + while (progressCount === 0 && Date.now() - startWait < 30_000) await Bun.sleep(50); + expect(progressCount).toBeGreaterThan(0); + + await rpc(ws, 'lishs.stopCreate', {}); + + let createError: unknown; + try { + await createPromise; + } catch (e) { + createError = e; + } + expect(String(createError)).toContain('LISH_CREATE_CANCELLED'); + + // Backend must still be alive: a follow-up RPC should succeed. + expect(ctx.backend.exitCode).toBeNull(); + const list = await rpc(ws, 'lishs.list', {}); + expect(list).toBeDefined(); + + ws.close(); + } finally { + await teardownCompiledBackend(ctx); + } + }, + 180_000 +); diff --git a/backend/tests/unit/protocol/downloader.test.ts b/backend/tests/unit/protocol/downloader.test.ts index f3c2eaa5..222b6ba7 100644 --- a/backend/tests/unit/protocol/downloader.test.ts +++ b/backend/tests/unit/protocol/downloader.test.ts @@ -736,12 +736,16 @@ describe('Downloader – path traversal protection', () => { // --- Encoded/obfuscated traversal attempts (must block) --- - it('blocks backslash traversal on Windows', () => { + // Backslash is only a path separator on Windows. On POSIX it is a literal filename character, + // so safePath cannot — and should not — interpret it as traversal. + const itWindows = process.platform === 'win32' ? it : it.skip; + + itWindows('blocks backslash traversal on Windows', () => { const dl = makeDownloader(); expect(() => callSafePath(dl, '..\\evil.txt')).toThrow('Path traversal blocked'); }); - it('blocks mixed slash traversal', () => { + itWindows('blocks mixed slash traversal on Windows', () => { const dl = makeDownloader(); expect(() => callSafePath(dl, '..\\..\\evil.txt')).toThrow('Path traversal blocked'); });