diff --git a/__tests__/cli-ui-command.test.ts b/__tests__/cli-ui-command.test.ts new file mode 100644 index 0000000..7d1f36e --- /dev/null +++ b/__tests__/cli-ui-command.test.ts @@ -0,0 +1,358 @@ +/** + * `codegraph ui` — the CLI face of the viewer server (CG-41). + * + * Exercised end-to-end against the built binary, because the things worth + * pinning here are the ones that only exist once commander, the project + * resolver and the server are wired together: the help text, the friendly + * "not indexed" guidance, the sensitive-directory refusal, and whether + * `--no-open` actually stops a browser from being launched. + * + * The browser check works by pointing `CODEGRAPH_BROWSER` at a script that + * touches a marker file — so "did it try to open a browser" becomes an + * observable fact rather than a promise. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFileSync, spawn, type ChildProcess } from 'child_process'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { DEFAULT_UI_PORT as DEFAULT_PORT } from '../src/ui-server/constants'; + +const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js'); + +const BASE_ENV = { + ...process.env, + CODEGRAPH_NO_DAEMON: '1', + CODEGRAPH_WASM_RELAUNCHED: '1', + NO_COLOR: '1', +}; + +/** Run the CLI to completion, capturing stdout+stderr and the exit code. */ +function runCli(args: string[], env: Record = {}): { code: number; output: string } { + try { + const output = execFileSync(process.execPath, [BIN, ...args], { + encoding: 'utf-8', + env: { ...BASE_ENV, ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, output }; + } catch (err) { + const e = err as { status?: number; stdout?: string; stderr?: string }; + return { code: e.status ?? 1, output: `${e.stdout ?? ''}${e.stderr ?? ''}` }; + } +} + +/** GET a path from a running viewer, with a valid loopback Host. */ +function get(port: number, requestPath: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request( + { host: '127.0.0.1', port, path: requestPath, method: 'GET' }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => + resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') }) + ); + } + ); + req.on('error', reject); + req.end(); + }); +} + +/** + * Start `codegraph ui` and wait for the URL it prints. + * + * The banner IS the readiness signal: the server is bound before the URL is + * printed, so anything the test does after this line is talking to a live + * socket. + */ +function startViewer( + args: string[], + env: Record +): Promise<{ child: ChildProcess; port: number; output: () => string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [BIN, 'ui', ...args], { + env: { ...BASE_ENV, ...env }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`codegraph ui never printed a URL. Output:\n${output}`)); + }, 30_000); + + const onChunk = (chunk: Buffer): void => { + output += chunk.toString('utf-8'); + const match = output.match(/http:\/\/127\.0\.0\.1:(\d+)/); + if (match?.[1]) { + clearTimeout(timer); + resolve({ child, port: Number(match[1]), output: () => output }); + } + }; + child.stdout?.on('data', onChunk); + child.stderr?.on('data', onChunk); + child.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + child.on('exit', (code) => { + clearTimeout(timer); + reject(new Error(`codegraph ui exited with ${code} before serving. Output:\n${output}`)); + }); + }); +} + +async function stopViewer(child: ChildProcess): Promise { + if (child.exitCode !== null) return; + await new Promise((resolve) => { + child.once('exit', () => resolve()); + child.kill('SIGTERM'); + // A viewer that ignores SIGTERM must not hang the suite. + setTimeout(() => { + child.kill('SIGKILL'); + resolve(); + }, 5_000).unref(); + }); +} + +describe('codegraph ui — help', () => { + it('reads well and documents the flags', () => { + const { code, output } = runCli(['ui', '--help']); + expect(code).toBe(0); + expect(output).toContain('--port'); + expect(output).toContain('--no-open'); + expect(output).toContain('4747'); + expect(output).toContain('127.0.0.1'); + expect(output).toContain('read-only'); + expect(output).toContain('Examples:'); + expect(output).toContain('CODEGRAPH_BROWSER'); + }); + + it('works through `codegraph help ui`', () => { + const viaHelpCommand = runCli(['help', 'ui']); + const viaFlag = runCli(['ui', '--help']); + expect(viaHelpCommand.code).toBe(0); + expect(viaHelpCommand.output).toBe(viaFlag.output); + }); + + it('is listed in the top-level help, and `web` is an alias', () => { + const top = runCli(['--help']); + expect(top.output).toContain('ui|web [options] [path]'); + const viaAlias = runCli(['help', 'web']); + expect(viaAlias.code).toBe(0); + expect(viaAlias.output).toContain('--no-open'); + }); + + it('rejects a nonsense --port with a plain message, not a stack trace', () => { + const { code, output } = runCli(['ui', '--port', 'banana']); + expect(code).toBe(1); + expect(output).toContain('--port must be a whole number'); + expect(output).not.toContain('at Object.'); + }); +}); + +describe('codegraph ui — refusals', () => { + let unindexed: string; + + beforeAll(() => { + unindexed = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-unindexed-')); + fs.writeFileSync(path.join(unindexed, 'a.ts'), 'export const a = 1;\n'); + }); + + afterAll(() => { + fs.rmSync(unindexed, { recursive: true, force: true }); + }); + + it('gives friendly guidance — never a stack trace — when there is no index', () => { + const { code, output } = runCli(['ui', unindexed]); + expect(code).toBe(1); + expect(output).toContain('No CodeGraph index found'); + expect(output).toContain('codegraph init'); + expect(output).not.toContain('at Object.'); + expect(output).not.toContain('Error:'); + }); + + // `/etc` is only sensitive on POSIX; on Windows it resolves to a + // non-existent `C:\etc` and the "no index" path handles it instead. + it.runIf(process.platform !== 'win32')('refuses a sensitive system directory', () => { + const { code, output } = runCli(['ui', '/etc']); + expect(code).toBe(1); + expect(output).toContain('Refusing to operate on sensitive'); + }); +}); + +describe('codegraph ui — serving', () => { + let projectDir: string; + let markerDir: string; + let opener: string; + + beforeAll(async () => { + projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-cli-')); + fs.mkdirSync(path.join(projectDir, 'src')); + fs.writeFileSync( + path.join(projectDir, 'src', 'auth.ts'), + 'export function parseToken(t: string){ return t.trim(); }\n' + ); + const cg = CodeGraph.initSync(projectDir); + await cg.indexAll(); + cg.close(); + + // A stand-in browser: records that it was launched, and with what. + markerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-open-')); + const markerFile = path.join(markerDir, 'opened.txt'); + if (process.platform === 'win32') { + opener = path.join(markerDir, 'open.cmd'); + fs.writeFileSync(opener, `@echo %1 > "${markerFile}"\r\n`); + } else { + opener = path.join(markerDir, 'open.sh'); + fs.writeFileSync(opener, `#!/bin/sh\nprintf '%s' "$1" > "${markerFile}"\n`); + fs.chmodSync(opener, 0o755); + } + }, 120_000); + + afterAll(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + fs.rmSync(markerDir, { recursive: true, force: true }); + }); + + const markerFile = (): string => path.join(markerDir, 'opened.txt'); + + /** The opener is async (detached); give it a moment before concluding. */ + async function waitForMarker(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (fs.existsSync(markerFile())) return fs.readFileSync(markerFile(), 'utf-8'); + if (Date.now() > deadline) return null; + await new Promise((r) => setTimeout(r, 50)); + } + } + + it('serves the viewer and prints where it is', async () => { + const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {}); + try { + const res = await get(viewer.port, '/'); + expect(res.status).toBe(200); + expect(res.body).toContain('
'); + + const banner = viewer.output(); + expect(banner).toContain('CodeGraph viewer'); + expect(banner).toContain(projectDir); + expect(banner).toContain('this machine only'); + } finally { + await stopViewer(viewer.child); + } + }, 60_000); + + it('honours --no-open: no browser is launched', async () => { + fs.rmSync(markerFile(), { force: true }); + const viewer = await startViewer(['--no-open', '--port', '0', projectDir], { + CODEGRAPH_BROWSER: opener, + }); + try { + // Confirm the server is genuinely up before concluding "nothing opened" — + // otherwise this passes for the wrong reason. + expect((await get(viewer.port, '/')).status).toBe(200); + expect(await waitForMarker(1_500)).toBeNull(); + expect(viewer.output()).toContain('Open that URL in a browser'); + expect(viewer.output()).not.toContain('Opening your browser'); + } finally { + await stopViewer(viewer.child); + } + }, 60_000); + + it('opens the browser at the served URL when --no-open is absent', async () => { + fs.rmSync(markerFile(), { force: true }); + const viewer = await startViewer(['--port', '0', projectDir], { CODEGRAPH_BROWSER: opener }); + try { + const opened = await waitForMarker(10_000); + expect(opened).not.toBeNull(); + expect(opened?.trim()).toContain(`http://127.0.0.1:${viewer.port}`); + expect(viewer.output()).toContain('Opening your browser'); + } finally { + await stopViewer(viewer.child); + } + }, 60_000); + + it('CODEGRAPH_BROWSER=none suppresses the launch like --no-open', async () => { + fs.rmSync(markerFile(), { force: true }); + const viewer = await startViewer(['--port', '0', projectDir], { CODEGRAPH_BROWSER: 'none' }); + try { + expect((await get(viewer.port, '/')).status).toBe(200); + expect(await waitForMarker(1_000)).toBeNull(); + } finally { + await stopViewer(viewer.child); + } + }, 60_000); + + it('moves off the default port when it is busy', async () => { + // Occupy 4747 so the fallback has something to fall back FROM. If a + // developer's own viewer already holds it, the bind fails and the + // assertion below is still exactly the right one: the new viewer must not + // be on 4747 either way. + const blocker = http.createServer(() => {}); + const bound = await new Promise((resolve) => { + blocker.once('error', () => resolve(false)); + blocker.listen(DEFAULT_PORT, '127.0.0.1', () => resolve(true)); + }); + + try { + const viewer = await startViewer(['--no-open', projectDir], {}); + try { + expect(viewer.port).not.toBe(DEFAULT_PORT); + expect((await get(viewer.port, '/')).status).toBe(200); + } finally { + await stopViewer(viewer.child); + } + } finally { + if (bound) await new Promise((resolve) => blocker.close(() => resolve())); + } + }, 60_000); + + it('refuses to move off a port the user pinned with --port', async () => { + const blocker = http.createServer(() => {}); + await new Promise((resolve) => blocker.listen(0, '127.0.0.1', resolve)); + const taken = (blocker.address() as { port: number }).port; + try { + const { code, output } = runCli(['ui', '--no-open', '--port', String(taken), projectDir]); + expect(code).toBe(1); + expect(output).toContain('already in use'); + expect(output).not.toContain('at Object.'); + } finally { + await new Promise((resolve) => blocker.close(() => resolve())); + } + }, 60_000); + + it('refuses a foreign Host end-to-end', async () => { + const viewer = await startViewer(['--no-open', '--port', '0', projectDir], {}); + try { + const res = await new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = http.request( + { + host: '127.0.0.1', + port: viewer.port, + path: '/', + headers: { Host: 'evil.example' }, + setHost: false, + }, + (r) => { + const chunks: Buffer[] = []; + r.on('data', (c: Buffer) => chunks.push(c)); + r.on('end', () => + resolve({ status: r.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') }) + ); + } + ); + req.on('error', reject); + req.end(); + }); + expect(res.status).toBe(403); + expect(res.body).not.toContain('
'); + } finally { + await stopViewer(viewer.child); + } + }, 60_000); +}); diff --git a/__tests__/ui-server.test.ts b/__tests__/ui-server.test.ts new file mode 100644 index 0000000..e48d6d4 --- /dev/null +++ b/__tests__/ui-server.test.ts @@ -0,0 +1,530 @@ +/** + * `codegraph ui` server — the loopback boundary (CG-41). + * + * This process serves the user's source code from a port on their machine, so + * the tests that matter are the refusals: a foreign `Host` (DNS rebinding is + * the only realistic attack on a loopback code viewer), a traversal out of the + * asset root, a write method, a cross-origin read. The happy path — index.html + * and hashed assets — is here mostly so a refusal that accidentally blocks + * everything can't pass. + * + * Requests go through `http.request`, not `fetch`: `Host` is a forbidden header + * name in undici, and forging it is the whole point of half these cases. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as http from 'http'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { + browserOpenCommand, + cacheControlFor, + contentTypeFor, + isAllowedHost, + isAllowedOrigin, + isSafeRequestPath, + PathRefusalError, + resolveProjectFile, + resolveStaticAsset, + startUiServer, + type UiServerHandle, +} from '../src/ui-server'; + +interface Response { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +/** + * One request with full control over the request line and headers. + * + * `setHost: false` stops node from adding its own `Host`, and `path` is sent + * verbatim — so a traversal case really does put `/../../x` on the wire. + */ +function request( + port: number, + requestPath: string, + options: { method?: string; headers?: Record } = {} +): Promise { + return new Promise((resolve, reject) => { + const headers: Record = { Host: `127.0.0.1:${port}`, ...options.headers }; + const req = http.request( + { host: '127.0.0.1', port, path: requestPath, method: options.method ?? 'GET', headers, setHost: false }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString('utf-8'), + }) + ); + } + ); + req.on('error', reject); + req.end(); + }); +} + +describe('codegraph ui server', () => { + let tempDir: string; + let viewerDir: string; + let projectRoot: string; + let server: UiServerHandle; + + beforeAll(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-server-')); + + // A stand-in for dist/viewer: same shape (index.html + hashed assets/), so + // the tests don't need the Svelte build to have run. + viewerDir = path.join(tempDir, 'viewer'); + fs.mkdirSync(path.join(viewerDir, 'assets'), { recursive: true }); + fs.writeFileSync( + path.join(viewerDir, 'index.html'), + '
' + + '' + ); + fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.js'), 'export const viewer = 1;\n'); + fs.writeFileSync(path.join(viewerDir, 'assets', 'index-abc123.css'), ':root{color:#16150f}\n'); + + projectRoot = path.join(tempDir, 'project'); + fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n'); + + // A file OUTSIDE both roots that a traversal would be trying to reach. + fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n'); + + server = await startUiServer({ projectRoot, viewerDir, port: 0 }); + }); + + afterAll(async () => { + await server?.close(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('serving the viewer', () => { + it('serves index.html at the root', async () => { + const res = await request(server.port, '/'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('text/html; charset=utf-8'); + expect(res.body).toContain('
'); + }); + + it('serves index.html directly too', async () => { + const res = await request(server.port, '/index.html'); + expect(res.status).toBe(200); + expect(res.body).toContain('
'); + }); + + it('serves hashed assets with their real content type', async () => { + const js = await request(server.port, '/assets/index-abc123.js'); + expect(js.status).toBe(200); + expect(js.headers['content-type']).toBe('text/javascript; charset=utf-8'); + expect(js.body).toContain('export const viewer'); + + const css = await request(server.port, '/assets/index-abc123.css'); + expect(css.status).toBe(200); + expect(css.headers['content-type']).toBe('text/css; charset=utf-8'); + }); + + it('caches hashed assets forever and index.html never', async () => { + const asset = await request(server.port, '/assets/index-abc123.js'); + expect(asset.headers['cache-control']).toBe('public, max-age=31536000, immutable'); + const index = await request(server.port, '/'); + expect(index.headers['cache-control']).toBe('no-store'); + }); + + it('falls back to index.html for an unknown route, but not for a missing asset', async () => { + // A hash-routed app only ever asks for `/`, but a hand-typed deep path + // should still open the app. + const route = await request(server.port, '/s/some-symbol-id'); + expect(route.status).toBe(200); + expect(route.body).toContain('
'); + + // A missing FILE must 404 — answering with HTML would hand the browser a + // script that isn't one, and hide a broken build. + const asset = await request(server.port, '/assets/index-doesnotexist.js'); + expect(asset.status).toBe(404); + }); + + it('answers HEAD with the same headers and no body', async () => { + const res = await request(server.port, '/', { method: 'HEAD' }); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('text/html; charset=utf-8'); + expect(res.headers['content-length']).toBeDefined(); + expect(res.body).toBe(''); + }); + }); + + describe('binding', () => { + it('listens on loopback only', () => { + const address = server.server.address(); + expect(address).not.toBeNull(); + expect(typeof address === 'object' ? address?.address : null).toBe('127.0.0.1'); + expect(server.url).toBe(`http://127.0.0.1:${server.port}`); + }); + + it('falls back to the next free port when the preferred one is taken', async () => { + const blocker = http.createServer(() => {}); + await new Promise((resolve) => blocker.listen(0, '127.0.0.1', resolve)); + const taken = (blocker.address() as { port: number }).port; + + const second = await startUiServer({ projectRoot, viewerDir, port: taken }); + try { + expect(second.port).not.toBe(taken); + expect(second.port).toBeGreaterThan(taken); + // …and it actually works on the port it landed on. + const res = await request(second.port, '/'); + expect(res.status).toBe(200); + } finally { + await second.close(); + await new Promise((resolve) => blocker.close(() => resolve())); + } + }); + + it('refuses to move off a port the caller pinned', async () => { + const blocker = http.createServer(() => {}); + await new Promise((resolve) => blocker.listen(0, '127.0.0.1', resolve)); + const taken = (blocker.address() as { port: number }).port; + + try { + await expect( + startUiServer({ projectRoot, viewerDir, port: taken, portFallback: false }) + ).rejects.toThrow(/already in use/i); + } finally { + await new Promise((resolve) => blocker.close(() => resolve())); + } + }); + }); + + describe('Host allowlist (DNS rebinding)', () => { + it('serves the loopback names', async () => { + for (const host of ['127.0.0.1', 'localhost', '[::1]', `localhost:${server.port}`, `[::1]:${server.port}`]) { + const res = await request(server.port, '/', { headers: { Host: host } }); + expect(res.status, `Host: ${host}`).toBe(200); + } + }); + + it('refuses a foreign Host', async () => { + for (const host of ['evil.example', `evil.example:${server.port}`, 'attacker.localhost.evil.com']) { + const res = await request(server.port, '/', { headers: { Host: host } }); + expect(res.status, `Host: ${host}`).toBe(403); + expect(res.body).not.toContain('
'); + } + }); + + it('refuses a loopback Host carrying someone else\u2019s port', async () => { + const res = await request(server.port, '/', { headers: { Host: '127.0.0.1:9' } }); + expect(res.status).toBe(403); + }); + + it('refuses a malformed or missing Host', async () => { + const malformed = await request(server.port, '/', { headers: { Host: '127.0.0.1:notaport' } }); + expect(malformed.status).toBe(403); + // Node's client insists on sending something for Host, so the empty-value + // case is covered by the unit assertions on isAllowedHost below. + }); + + it('refuses before touching the filesystem — even for an asset', async () => { + const res = await request(server.port, '/assets/index-abc123.js', { + headers: { Host: 'evil.example' }, + }); + expect(res.status).toBe(403); + expect(res.body).not.toContain('export const viewer'); + }); + }); + + describe('cross-origin', () => { + it('never sends CORS headers', async () => { + const res = await request(server.port, '/'); + expect(res.headers['access-control-allow-origin']).toBeUndefined(); + expect(res.headers['access-control-allow-credentials']).toBeUndefined(); + expect(res.headers['access-control-allow-methods']).toBeUndefined(); + }); + + it('refuses a request carrying a foreign Origin', async () => { + const res = await request(server.port, '/', { headers: { Origin: 'https://evil.example' } }); + expect(res.status).toBe(403); + }); + + it('allows the viewer\u2019s own origin', async () => { + const res = await request(server.port, '/', { + headers: { Origin: `http://127.0.0.1:${server.port}` }, + }); + expect(res.status).toBe(200); + }); + + it('sends the hardening headers on every response', async () => { + const res = await request(server.port, '/'); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + expect(res.headers['x-frame-options']).toBe('DENY'); + expect(res.headers['content-security-policy']).toContain("frame-ancestors 'none'"); + expect(res.headers['content-security-policy']).toContain("connect-src 'self'"); + }); + }); + + describe('read-only', () => { + it('refuses every method that is not GET or HEAD', async () => { + for (const method of ['POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']) { + const res = await request(server.port, '/', { method }); + expect(res.status, method).toBe(405); + expect(res.headers['allow']).toBe('GET, HEAD'); + } + }); + }); + + describe('paths outside the asset root', () => { + const traversals = [ + '/../secret.txt', + '/../../secret.txt', + '/assets/../../secret.txt', + '/..%2fsecret.txt', + '/%2e%2e/secret.txt', + '/%2e%2e%2fsecret.txt', + '/....//secret.txt', + ]; + + it('never serves a file outside the viewer directory', async () => { + for (const traversal of traversals) { + const res = await request(server.port, traversal); + expect(res.body, traversal).not.toContain('SUPER-SECRET-VALUE'); + expect(res.status, traversal).not.toBe(200); + } + }); + + it('404s an absolute system path rather than reading it', async () => { + const res = await request(server.port, '/etc/passwd'); + expect(res.body).not.toContain('root:'); + // No such file under the viewer root; an extension-less path is a route. + expect(res.status).toBe(200); + expect(res.body).toContain('
'); + + const shadow = await request(server.port, '/etc/hosts.txt'); + expect(shadow.status).toBe(404); + }); + + it('404s a NUL-truncation attempt', async () => { + const res = await request(server.port, '/index.html%00.png'); + expect(res.status).toBe(404); + }); + }); + + describe('/api is reserved', () => { + it('404s as JSON, never as the app shell', async () => { + const res = await request(server.port, '/api/nodes'); + expect(res.status).toBe(404); + expect(res.headers['content-type']).toBe('application/json; charset=utf-8'); + expect(JSON.parse(res.body)).toHaveProperty('error'); + expect(res.body).not.toContain('
'); + }); + + it('hands requests to a mounted handler with a decoded path and query', async () => { + const seen: Array<{ pathname: string; symbol: string | null; root: string }> = []; + const withApi = await startUiServer({ + projectRoot, + viewerDir, + port: 0, + api: (_req, res, ctx) => { + seen.push({ + pathname: ctx.pathname, + symbol: ctx.query.get('symbol'), + root: ctx.projectRoot, + }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"ok":true}'); + return true; + }, + }); + try { + const res = await request(withApi.port, '/api/node?symbol=parse%20Token'); + expect(res.status).toBe(200); + expect(JSON.parse(res.body)).toEqual({ ok: true }); + expect(seen).toEqual([{ pathname: '/api/node', symbol: 'parse Token', root: projectRoot }]); + } finally { + await withApi.close(); + } + }); + + it('turns a throwing handler into a JSON 500, not a crashed server', async () => { + const withApi = await startUiServer({ + projectRoot, + viewerDir, + port: 0, + api: () => { + throw new Error('handler blew up'); + }, + }); + try { + const res = await request(withApi.port, '/api/boom'); + expect(res.status).toBe(500); + expect(JSON.parse(res.body).error).toContain('handler blew up'); + // Still alive afterwards. + expect((await request(withApi.port, '/')).status).toBe(200); + } finally { + await withApi.close(); + } + }); + }); +}); + +describe('resolveProjectFile — the source read chokepoint', () => { + let tempDir: string; + let projectRoot: string; + + beforeAll(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-paths-')); + projectRoot = path.join(tempDir, 'project'); + fs.mkdirSync(path.join(projectRoot, 'src'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'src', 'auth.ts'), 'export const token = 1;\n'); + fs.writeFileSync(path.join(tempDir, 'secret.txt'), 'SUPER-SECRET-VALUE\n'); + }); + + afterAll(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('resolves a file inside the project', () => { + expect(resolveProjectFile(projectRoot, 'src/auth.ts')).toBe( + fs.realpathSync(path.join(projectRoot, 'src', 'auth.ts')) + ); + }); + + it('refuses traversal out of the project', () => { + for (const escape of ['../secret.txt', 'src/../../secret.txt', '..%2fsecret.txt']) { + expect(() => resolveProjectFile(projectRoot, escape), escape).toThrow(PathRefusalError); + } + }); + + it('refuses an absolute path', () => { + expect(() => resolveProjectFile(projectRoot, path.join(tempDir, 'secret.txt'))).toThrow( + PathRefusalError + ); + }); + + it('refuses an empty path', () => { + expect(() => resolveProjectFile(projectRoot, '')).toThrow(PathRefusalError); + expect(() => resolveProjectFile(projectRoot, ' ')).toThrow(PathRefusalError); + }); + + it('refuses a NUL byte', () => { + expect(() => resolveProjectFile(projectRoot, 'src/auth.ts%00.png')).toThrow(PathRefusalError); + }); + + // `/etc` resolves to a non-existent `C:\etc` on Windows, so the sensitive-path + // list only means anything on POSIX. + it.runIf(process.platform !== 'win32')('refuses a sensitive system directory as the root', () => { + expect(() => resolveProjectFile('/etc', 'passwd')).toThrow(PathRefusalError); + expect(() => resolveProjectFile('/', 'etc/passwd')).toThrow(PathRefusalError); + }); + + it.runIf(process.platform !== 'win32')('refuses a symlink pointing out of the project (#527)', () => { + const link = path.join(projectRoot, 'src', 'escape.ts'); + fs.symlinkSync(path.join(tempDir, 'secret.txt'), link); + try { + expect(() => resolveProjectFile(projectRoot, 'src/escape.ts')).toThrow(PathRefusalError); + } finally { + fs.unlinkSync(link); + } + }); +}); + +describe('security helpers', () => { + it('isAllowedHost accepts only loopback names on our port', () => { + expect(isAllowedHost('127.0.0.1', 4747)).toBe(true); + expect(isAllowedHost('127.0.0.1:4747', 4747)).toBe(true); + expect(isAllowedHost('localhost:4747', 4747)).toBe(true); + expect(isAllowedHost('LOCALHOST', 4747)).toBe(true); + expect(isAllowedHost('[::1]:4747', 4747)).toBe(true); + + expect(isAllowedHost(undefined, 4747)).toBe(false); + expect(isAllowedHost('', 4747)).toBe(false); + expect(isAllowedHost('evil.example', 4747)).toBe(false); + expect(isAllowedHost('127.0.0.1:4748', 4747)).toBe(false); + expect(isAllowedHost('127.0.0.1.evil.example', 4747)).toBe(false); + expect(isAllowedHost('localhost.evil.example:4747', 4747)).toBe(false); + expect(isAllowedHost('127.0.0.1:4747:4747', 4747)).toBe(false); + // Unbracketed IPv6 is malformed per RFC 7230 — rejected, not guessed at. + expect(isAllowedHost('::1', 4747)).toBe(false); + // A non-loopback address that merely resolves here still fails the check. + expect(isAllowedHost('192.168.1.5:4747', 4747)).toBe(false); + }); + + it('isAllowedOrigin allows absent and same-origin, refuses everything else', () => { + expect(isAllowedOrigin(undefined, 4747)).toBe(true); + expect(isAllowedOrigin('http://127.0.0.1:4747', 4747)).toBe(true); + expect(isAllowedOrigin('http://localhost:4747', 4747)).toBe(true); + expect(isAllowedOrigin('http://[::1]:4747', 4747)).toBe(true); + + expect(isAllowedOrigin('null', 4747)).toBe(false); + expect(isAllowedOrigin('https://evil.example', 4747)).toBe(false); + expect(isAllowedOrigin('http://127.0.0.1:4748', 4747)).toBe(false); + expect(isAllowedOrigin('file://', 4747)).toBe(false); + expect(isAllowedOrigin('not a url', 4747)).toBe(false); + }); + + it('isSafeRequestPath rejects a `..` segment however it is spelled', () => { + expect(isSafeRequestPath('/')).toBe(true); + expect(isSafeRequestPath('/assets/index-abc123.js')).toBe(true); + expect(isSafeRequestPath('/s/Some.Symbol')).toBe(true); + + expect(isSafeRequestPath('/../secret')).toBe(false); + expect(isSafeRequestPath('/a/../../secret')).toBe(false); + expect(isSafeRequestPath('/%2e%2e/secret')).toBe(false); + expect(isSafeRequestPath('/..%2Fsecret')).toBe(false); + expect(isSafeRequestPath('/a%00b')).toBe(false); + expect(isSafeRequestPath('/a\\b')).toBe(false); + expect(isSafeRequestPath('/%zz')).toBe(false); + }); + + it('resolveStaticAsset returns null for anything that is not a file in the root', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-ui-static-')); + try { + fs.mkdirSync(path.join(dir, 'assets')); + fs.writeFileSync(path.join(dir, 'index.html'), 'x'); + expect(resolveStaticAsset(dir, '/index.html')).toBe( + fs.realpathSync(path.join(dir, 'index.html')) + ); + expect(resolveStaticAsset(dir, '/assets')).toBeNull(); // a directory + expect(resolveStaticAsset(dir, '/missing.js')).toBeNull(); + expect(resolveStaticAsset(dir, '/../etc/passwd')).toBeNull(); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('contentTypeFor covers the viewer bundle and defaults safely', () => { + expect(contentTypeFor('a/index.html')).toBe('text/html; charset=utf-8'); + expect(contentTypeFor('a/index-abc.js')).toBe('text/javascript; charset=utf-8'); + expect(contentTypeFor('a/archivo.woff2')).toBe('font/woff2'); + expect(contentTypeFor('a/thing.unknownext')).toBe('application/octet-stream'); + }); + + it('cacheControlFor pins hashed assets and never index.html', () => { + expect(cacheControlFor(path.join('assets', 'index-abc.js'))).toContain('immutable'); + expect(cacheControlFor('index.html')).toBe('no-store'); + }); +}); + +describe('browserOpenCommand', () => { + it('uses the platform opener', () => { + expect(browserOpenCommand('http://x', 'darwin')).toEqual({ command: 'open', args: ['http://x'] }); + expect(browserOpenCommand('http://x', 'linux')).toEqual({ command: 'xdg-open', args: ['http://x'] }); + expect(browserOpenCommand('http://x', 'win32')).toEqual({ + command: 'cmd', + args: ['/c', 'start', '', 'http://x'], + }); + }); + + it('honours the CODEGRAPH_BROWSER override', () => { + expect(browserOpenCommand('http://x', 'darwin', 'firefox')).toEqual({ + command: 'firefox', + args: ['http://x'], + }); + for (const off of ['none', 'NONE', '0', 'false', 'off', '', ' ']) { + expect(browserOpenCommand('http://x', 'darwin', off), off).toBeNull(); + } + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 19038df..19cd271 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -20,6 +20,7 @@ * codegraph callees Find what a function/method calls * codegraph impact Analyze what code is affected by changing a symbol * codegraph affected [files] Find test files affected by changes + * codegraph ui [path] Open the browser viewer for an indexed project * codegraph upgrade [version] Update CodeGraph to the latest release */ @@ -53,6 +54,11 @@ import { relaunchWithWasmRuntimeFlagsIfNeeded } from '../extraction/wasm-runtime import { installCommandSupervision } from './command-supervision'; import { EXTRACTION_VERSION } from '../extraction/extraction-version'; import { getTelemetry, TELEMETRY_DOCS, recordIndexEvent } from '../telemetry'; +// Value import, but dependency-free by design so `--help` text can name the +// default port without dragging node:http into every other subcommand; the +// server itself is loaded lazily inside the `ui` action. See ui-server/constants. +import { BROWSER_ENV, DEFAULT_UI_PORT } from '../ui-server/constants'; +import type { UiServerHandle } from '../ui-server'; // Decided once, before `--color`/`--no-color` are stripped from argv below // (#1281). Piped/redirected stdout, NO_COLOR, or --no-color -> plain output. @@ -1822,6 +1828,136 @@ program }); }); +/** + * Print the "no index here" guidance. + * + * The viewer READS an index; it never builds one — indexing stays the user's + * decision, exactly as it is for the MCP tools. So a missing index is normal + * input, not a failure to apologize for: say what is missing, say the one + * command that fixes it, and never print a stack trace. + */ +function printNoIndexGuidance(projectPath: string): void { + error(`No CodeGraph index found for ${projectPath}`); + console.error(''); + console.error(' The viewer reads an index that already exists — it never creates one.'); + console.error(' To index this project:'); + console.error(''); + console.error(` ${chalk.cyan('codegraph init')}`); + console.error(''); + console.error(' Already indexed somewhere else? Point the viewer at it:'); + console.error(''); + console.error(` ${chalk.cyan('codegraph ui /path/to/indexed/project')}`); + console.error(''); +} + +/** + * codegraph ui [path] (alias: web) + * + * The browser reader: serves the built viewer (`dist/viewer/`) over loopback + * and opens it. Read-only in every sense — it answers GET, it opens the index + * for reading, and it never writes to the project or the graph. + * + * Deliberately absent from TELEMETRY_FLUSH_COMMANDS above: the command's own + * banner tells the user nothing leaves their machine, so it must not be the + * thing that triggers a telemetry send. The usage count still buffers locally + * like every other quick command. + */ +program + .command('ui [path]') + .alias('web') + .description('Open the CodeGraph viewer in your browser — read your indexed project as a graph') + .option('--port ', `Port to listen on (default: ${DEFAULT_UI_PORT}, or the next free one)`) + .option('--no-open', 'Print the URL instead of opening a browser') + .addHelpText( + 'after', + ` +Examples: + $ codegraph ui Read the project you're standing in + $ codegraph ui ~/code/my-app Read a specific indexed project + $ codegraph ui --port 8080 Use one specific port (fails if it's taken) + $ codegraph ui --no-open Just print the URL (headless boxes, SSH) + +The viewer listens on 127.0.0.1 only, so nothing on your network can reach it, +and it is read-only: it opens an index that already exists and never changes +your project or your graph. Requests from any other host are refused. + +Without --port it takes ${DEFAULT_UI_PORT}, or the next free port if that one is busy. + +Set ${BROWSER_ENV}= to choose which browser opens, or +${BROWSER_ENV}=none to never open one. +` + ) + .action(async (pathArg: string | undefined, options: { port?: string; open?: boolean }) => { + // An explicit --port stays explicit: a scripted `--port 8080` that quietly + // lands on 8081 is worse than one that says the port is busy. The default + // port is the only one we're free to walk away from. + let requestedPort: number | undefined; + if (options.port !== undefined) { + requestedPort = Number(options.port); + if (!Number.isInteger(requestedPort) || requestedPort < 0 || requestedPort > 65535) { + error(`--port must be a whole number between 0 and 65535 (got "${options.port}").`); + process.exit(1); + } + } + + const projectPath = resolveProjectPath(pathArg); + + // Sensitive-directory refusal before anything opens: the same guard the MCP + // entry points use, so `codegraph ui /etc` is turned away here rather than + // becoming a browsable view of the system. + const { validateProjectPath } = await import('../utils'); + const rootError = validateProjectPath(projectPath); + if (rootError) { + error(rootError); + process.exit(1); + } + + if (!isInitialized(projectPath)) { + printNoIndexGuidance(projectPath); + process.exit(1); + } + + const { startUiServer, openBrowser, ViewerMissingError } = await import('../ui-server'); + + let handle: UiServerHandle; + try { + handle = await startUiServer({ + projectRoot: projectPath, + port: requestedPort, + portFallback: requestedPort === undefined, + }); + } catch (err) { + // Both failure modes here (viewer assets missing, no port available) carry + // their own remediation — print it plainly, never a stack trace. + error(err instanceof ViewerMissingError || err instanceof Error ? err.message : String(err)); + process.exit(1); + } + + console.log(''); + console.log(chalk.bold('CodeGraph viewer')); + console.log(''); + console.log(` ${chalk.dim('Reading')} ${projectPath}`); + console.log(` ${chalk.dim('URL')} ${chalk.cyan(handle.url)}`); + console.log(` ${chalk.dim('Access')} this machine only ${getGlyphs().dash} read-only, nothing leaves your computer`); + console.log(''); + + const opened = options.open === false ? false : openBrowser(handle.url); + console.log( + opened + ? chalk.dim(' Opening your browser… press Ctrl+C to stop.') + : chalk.dim(' Open that URL in a browser. Press Ctrl+C to stop.') + ); + console.log(''); + + // The http server keeps the event loop alive on its own; these just make + // Ctrl-C hang up live sockets instead of waiting on browser keep-alives. + const shutdown = (): void => { + void handle.close().then(() => process.exit(0)); + }; + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + }); + /** * codegraph serve */ diff --git a/src/errors.ts b/src/errors.ts index acd440c..cb8fe7a 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -161,6 +161,20 @@ export class ConfigError extends CodeGraphError { } } +/** + * A refused path — the caller asked for something outside the project root, or + * for a sensitive system directory. Deliberately a plain `Error` and NOT a + * {@link CodeGraphError}: it is a security marker every read sink tests with + * `instanceof`, not a categorized operational failure, and the MCP layer treats + * it as one of the only two "stop trying" conditions (see `mcp/tools.ts`). + * + * It lives here — in the dependency-free error module — rather than next to its + * first caller so that a consumer can enforce the refusal WITHOUT importing the + * MCP tool graph. `mcp/tools.ts` re-exports it, so the class identity stays + * single and every existing `instanceof` check keeps working. + */ +export class PathRefusalError extends Error {} + /** * Simple logger for CodeGraph operations * diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 5c23f67..9e83013 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -80,8 +80,13 @@ export class NotIndexedError extends Error {} /** * A security refusal (sensitive system path). Stays `isError: true` WITHOUT * retry guidance — abandoning this path is the desired agent reaction. + * + * Defined in `../errors` so non-MCP read sinks (the `codegraph ui` server) can + * enforce the same refusal without importing this module; re-exported here + * because this is where every existing caller imports it from. */ -export class PathRefusalError extends Error {} +export { PathRefusalError } from '../errors'; +import { PathRefusalError } from '../errors'; import { resolve as resolvePath, relative as relativePath } from 'path'; /** Maximum output length to prevent context bloat (characters) */ diff --git a/src/ui-server/assets.ts b/src/ui-server/assets.ts new file mode 100644 index 0000000..9532bd7 --- /dev/null +++ b/src/ui-server/assets.ts @@ -0,0 +1,76 @@ +/** + * Locating the built browser viewer on disk. + * + * The viewer is a static Vite build that ships inside the package, exactly like + * `schema.sql` and the tree-sitter grammars: emitted into `dist/viewer/`, + * copied wholesale by `scripts/build-bundle.sh`, packed by + * `scripts/pack-npm.sh`. So it is found the same way `db/index.ts` finds + * `schema.sql` — relative to `__dirname`, never to `process.cwd()`, which is + * whatever directory the user happened to be standing in. + * + * `dist/viewer`, NOT `dist/ui`: `src/ui/` is the engine's TERMINAL ui and tsc + * already compiles it to `dist/ui/`. See `ui/vite.config.ts`. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { VIEWER_PATH_ENV } from './constants'; + +export { VIEWER_PATH_ENV }; + +/** + * The viewer build is missing — the package was assembled without it, or the + * repo was built with `tsc` alone. Carries user-facing remediation rather than + * a stack trace, because the CLI prints `.message` verbatim. + */ +export class ViewerMissingError extends Error { + constructor(searched: readonly string[]) { + super( + 'The CodeGraph viewer assets are missing from this installation.\n' + + 'Looked in:\n' + + searched.map((p) => ` ${p}`).join('\n') + + '\n\nIf you installed CodeGraph normally, reinstall it — the release bundle ' + + 'ships the viewer.\nIf you are working from a source checkout, run: npm run build' + ); + this.name = 'ViewerMissingError'; + } +} + +/** + * Candidate locations for the viewer, most-specific first. + * + * 1. The `CODEGRAPH_VIEWER_PATH` override. + * 2. `<__dirname>/../viewer` — the shipped layout (`dist/ui-server/` → + * `dist/viewer/`). + * 3. `<__dirname>/../../dist/viewer` — running the TypeScript straight out of + * `src/` (vitest, tsx), where `__dirname` is `src/ui-server/`. + */ +export function viewerDirCandidates(): string[] { + const override = process.env[VIEWER_PATH_ENV]?.trim(); + const candidates = [ + path.join(__dirname, '..', 'viewer'), + path.join(__dirname, '..', '..', 'dist', 'viewer'), + ]; + return override ? [path.resolve(override), ...candidates] : candidates; +} + +/** + * Resolve the directory holding the built viewer. + * + * @throws {ViewerMissingError} when no candidate contains an `index.html`. + */ +export function resolveViewerDir(): string { + const candidates = viewerDirCandidates(); + for (const dir of candidates) { + try { + if (fs.statSync(path.join(dir, 'index.html')).isFile()) { + // realpath so the containment checks in `security.ts` compare like for + // like when the install lives behind a symlink (Homebrew, nvm, pnpm). + return fs.realpathSync(dir); + } + } catch { + // Not here — try the next candidate. + } + } + throw new ViewerMissingError(candidates); +} diff --git a/src/ui-server/constants.ts b/src/ui-server/constants.ts new file mode 100644 index 0000000..3c3cb34 --- /dev/null +++ b/src/ui-server/constants.ts @@ -0,0 +1,36 @@ +/** + * User-facing constants for the `codegraph ui` server. + * + * Deliberately dependency-free so the CLI can import them for `--help` text + * without pulling `node:http` (and the rest of the server) into every + * invocation of every other subcommand. `ui-server/index.ts` re-exports them, + * so consumers have one import to reach for. + */ + +/** The port `codegraph ui` asks for first. */ +export const DEFAULT_UI_PORT = 4747; + +/** How many consecutive ports to try before giving up. */ +export const DEFAULT_PORT_ATTEMPTS = 20; + +/** + * The only interface the server ever binds. Not configurable, on purpose: this + * process serves the user's source code, and a `--host` flag is one typo away + * from publishing it to the local network. + */ +export const LOOPBACK_ADDRESS = '127.0.0.1'; + +/** + * Overrides which browser (if any) `codegraph ui` launches. `none` — or `0`, + * `false`, `off`, or an empty value — suppresses the launch entirely, the same + * as `--no-open`. Any other value is run as a command with the URL as its + * single argument. + */ +export const BROWSER_ENV = 'CODEGRAPH_BROWSER'; + +/** + * Development/test override for the directory served as the viewer. Point it at + * a directory containing an `index.html` to serve something other than the + * shipped build. + */ +export const VIEWER_PATH_ENV = 'CODEGRAPH_VIEWER_PATH'; diff --git a/src/ui-server/index.ts b/src/ui-server/index.ts new file mode 100644 index 0000000..a453826 --- /dev/null +++ b/src/ui-server/index.ts @@ -0,0 +1,426 @@ +/** + * The `codegraph ui` server. + * + * A loopback-only, read-only `node:http` server that hands the browser the + * built viewer (`dist/viewer/`) and — once the JSON API lands on the `api` seam + * below — a read-only view of one indexed project. No framework, no new + * dependency: it answers GET, serves files, and refuses everything else. + * + * The interesting part is not the routing, it is the boundary in `security.ts`. + * Read that first. + */ + +import * as fs from 'fs'; +import * as http from 'http'; +import * as path from 'path'; +import { resolveViewerDir } from './assets'; +import { + ALLOWED_METHODS, + isAllowedHost, + isAllowedOrigin, + isSafeRequestPath, + resolveStaticAsset, +} from './security'; +import { sendFile, sendJson, sendText, shouldFallBackToIndex } from './static'; +import { DEFAULT_PORT_ATTEMPTS, DEFAULT_UI_PORT, LOOPBACK_ADDRESS } from './constants'; + +export { ViewerMissingError } from './assets'; +export { + BROWSER_ENV, + DEFAULT_PORT_ATTEMPTS, + DEFAULT_UI_PORT, + LOOPBACK_ADDRESS, + VIEWER_PATH_ENV, +} from './constants'; +export { + ALLOWED_METHODS, + PathRefusalError, + isAllowedHost, + isAllowedOrigin, + isSafeRequestPath, + resolveProjectFile, + resolveStaticAsset, +} from './security'; +export { browserOpenCommand, openBrowser } from './open-browser'; +export { contentTypeFor, cacheControlFor } from './static'; + + +/** + * Everything a request handler needs, already validated. + */ +export interface UiRequestContext { + /** Percent-decoded path portion of the request URL, always starting with `/`. */ + pathname: string; + /** Parsed query string. */ + query: URLSearchParams; + /** Absolute path of the indexed project this server is reading. */ + projectRoot: string; + /** The request method — `GET` or `HEAD`; nothing else reaches a handler. */ + method: string; +} + +/** + * A handler mounted under `/api/`. Returns `true` when it answered the request + * (i.e. wrote a response), `false` to fall through to a 404. + * + * This is the seam the read-only JSON API plugs into. Everything it serves out + * of the user's repository must go through `resolveProjectFile` — see + * `security.ts`. + */ +export type UiApiHandler = ( + req: http.IncomingMessage, + res: http.ServerResponse, + ctx: UiRequestContext +) => boolean | Promise; + +export interface UiServerOptions { + /** Absolute path of the indexed project to read. */ + projectRoot: string; + /** + * Port to bind. `0` lets the OS choose. Defaults to {@link DEFAULT_UI_PORT}. + */ + port?: number; + /** + * Try the next port when the requested one is taken (default `true`). + * + * The CLI turns this OFF for an explicit `--port`: a scripted invocation that + * silently lands somewhere else is worse than one that says the port is busy. + */ + portFallback?: boolean; + /** How many ports to try in total. Defaults to {@link DEFAULT_PORT_ATTEMPTS}. */ + maxPortAttempts?: number; + /** Directory of built viewer assets. Defaults to the shipped `dist/viewer/`. */ + viewerDir?: string; + /** Optional read-only JSON API mounted under `/api/`. */ + api?: UiApiHandler; +} + +export interface UiServerHandle { + /** The port actually bound (may differ from the requested one — see fallback). */ + port: number; + /** The URL to open. */ + url: string; + /** Directory being served as the viewer. */ + viewerDir: string; + /** The underlying server, for tests and for callers that want raw events. */ + server: http.Server; + /** Stop listening and drop live connections. Idempotent. */ + close(): Promise; +} + +/** + * Response headers sent on EVERY response. + * + * `frame-ancestors`/`X-Frame-Options` stop another page from framing the viewer + * and reading it by overlay; `nosniff` stops an asset with a surprising + * extension from being executed as script; the CSP pins every resource to this + * origin, so a future viewer change cannot start phoning out with what it read. + * `style-src` keeps `'unsafe-inline'` because the syntax highlighter emits + * inline `style=` attributes on code spans. + * + * Note what is NOT here: any `Access-Control-*` header. Their absence is what + * makes a cross-origin read of a response body impossible even if a request + * somehow gets past the `Host` check. + */ +const SECURITY_HEADERS: Readonly> = { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'no-referrer', + 'Content-Security-Policy': [ + "default-src 'none'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self'", + "base-uri 'none'", + "form-action 'none'", + "frame-ancestors 'none'", + ].join('; '), +}; + +/** + * Start the viewer server. + * + * Resolves once the socket is bound, so the caller can print a URL that is + * already answering. + */ +export async function startUiServer(options: UiServerOptions): Promise { + // realpath, not just resolve: `resolveStaticAsset` hands back realpaths (the + // symlink check in `validatePathWithinRoot` resolves them), so a viewerDir + // that still holds a symlink — every macOS `/var/folders` temp dir, plenty of + // package managers — would make `path.relative` between the two nonsense, and + // the cache policy that keys off it silently wrong. + const viewerDir = options.viewerDir ? realpath(options.viewerDir) : resolveViewerDir(); + const projectRoot = path.resolve(options.projectRoot); + const indexHtml = path.join(viewerDir, 'index.html'); + + // The bound port is needed by the Host check, but is only known after listen. + // Captured by reference so the handler always sees the real value. + let boundPort = 0; + + const server = http.createServer((req, res) => { + handleRequest(req, res, { + viewerDir, + indexHtml, + projectRoot, + api: options.api, + port: () => boundPort, + }).catch(() => { + // handleRequest already answers every error it can; reaching here means + // the socket itself is gone. Never let it become an unhandled rejection, + // which the CLI's fatal handlers would turn into a process exit. + if (!res.writableEnded) res.destroy(); + }); + }); + + // A browser holds keep-alive sockets open; without this, `close()` would wait + // for them and Ctrl-C would appear to hang. + server.keepAliveTimeout = 5_000; + + boundPort = await listenWithFallback(server, { + port: options.port ?? DEFAULT_UI_PORT, + fallback: options.portFallback ?? true, + attempts: options.maxPortAttempts ?? DEFAULT_PORT_ATTEMPTS, + }); + + let closed = false; + return { + port: boundPort, + url: `http://${LOOPBACK_ADDRESS}:${boundPort}`, + viewerDir, + server, + close(): Promise { + if (closed) return Promise.resolve(); + closed = true; + return new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + }); + }, + }; +} + +interface HandlerDeps { + viewerDir: string; + indexHtml: string; + projectRoot: string; + api: UiApiHandler | undefined; + port: () => number; +} + +/** + * One request, start to finish. Order matters: the cheap refusals (method, + * `Host`, `Origin`) run before anything touches the filesystem. + */ +async function handleRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + deps: HandlerDeps +): Promise { + const method = req.method ?? 'GET'; + for (const [name, value] of Object.entries(SECURITY_HEADERS)) { + res.setHeader(name, value); + } + + if (!ALLOWED_METHODS.includes(method)) { + res.setHeader('Allow', ALLOWED_METHODS.join(', ')); + sendText(res, 405, `codegraph ui is read-only — ${method} is not allowed.`, method); + return; + } + + const port = deps.port(); + if (!isAllowedHost(req.headers.host, port)) { + // The DNS-rebinding refusal. Say why, since a human hitting this through a + // proxy or a container hostname needs to know what to change. + sendText( + res, + 403, + 'Refused: codegraph ui only answers requests addressed to this machine ' + + `(localhost, 127.0.0.1 or [::1] on port ${port}).\n` + + `This request said Host: ${forEcho(req.headers.host)}`, + method + ); + return; + } + + if (!isAllowedOrigin(readHeader(req, 'origin'), port)) { + sendText(res, 403, 'Refused: cross-origin requests are not served.', method); + return; + } + + // Checked on the RAW url, before WHATWG parsing folds `..` segments away. + const rawPath = (req.url ?? '/').split(/[?#]/)[0] ?? '/'; + if (!isSafeRequestPath(rawPath)) { + sendText(res, 404, 'Not found', method); + return; + } + + let url: URL; + try { + url = new URL(req.url ?? '/', `http://${LOOPBACK_ADDRESS}:${port}`); + } catch { + sendText(res, 400, 'Bad request URL.', method); + return; + } + + // `/api/` is reserved — it must 404 as JSON rather than fall through to the + // SPA, or a typo'd endpoint returns 200 + HTML and the viewer parses the app + // shell as a payload. + if (url.pathname === '/api' || url.pathname.startsWith('/api/')) { + const ctx: UiRequestContext = { + pathname: safeDecode(url.pathname), + query: url.searchParams, + projectRoot: deps.projectRoot, + method, + }; + if (deps.api) { + try { + if (await deps.api(req, res, ctx)) return; + } catch (err) { + if (!res.headersSent) { + sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }, method); + } else { + res.destroy(); + } + return; + } + } + if (!res.headersSent) sendJson(res, 404, { error: `No such endpoint: ${url.pathname}` }, method); + return; + } + + const requested = url.pathname === '/' ? '/index.html' : url.pathname; + const file = resolveStaticAsset(deps.viewerDir, requested); + if (file) { + sendFile(res, file, { rootDir: deps.viewerDir, method }); + return; + } + + if (shouldFallBackToIndex(url.pathname)) { + sendFile(res, deps.indexHtml, { rootDir: deps.viewerDir, method }); + return; + } + + sendText(res, 404, 'Not found', method); +} + +/** `path.resolve` + symlink resolution, falling back when the path is missing. */ +function realpath(dir: string): string { + const resolved = path.resolve(dir); + try { + return fs.realpathSync(resolved); + } catch { + return resolved; + } +} + +/** + * Bound and de-fang an attacker-supplied header before echoing it back. + * + * The refused `Host` is worth showing — a human hitting this through a proxy or + * a container hostname needs to know what was actually sent. But it is + * attacker-chosen text, so it goes out truncated and stripped of control bytes. + * (The response is `text/plain` + `nosniff`, so there is nothing to inject + * into; this is belt and braces.) + */ +function forEcho(value: string | undefined): string { + if (!value) return '(none)'; + // eslint-disable-next-line no-control-regex -- stripping raw control bytes IS the point + const clean = value.replace(/[\x00-\x1f\x7f]/g, '?'); + return clean.length > 100 ? `${clean.slice(0, 100)}…` : clean; +} + +/** Read a header as a single string (node gives arrays for some headers). */ +function readHeader(req: http.IncomingMessage, name: string): string | undefined { + const value = req.headers[name]; + if (value === undefined) return undefined; + return Array.isArray(value) ? value[0] : value; +} + +/** Percent-decode for display; the raw value is used for anything security-relevant. */ +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +/** + * Bind the first free port at or after `port`, on loopback only. + * + * Only `EADDRINUSE` advances to the next port — a permission failure or a bad + * address will not get better one port over, and retrying twenty times would + * only bury the real error. + */ +async function listenWithFallback( + server: http.Server, + opts: { port: number; fallback: boolean; attempts: number } +): Promise { + // Port 0 means "any free port", so there is nothing to fall back from. + const attempts = opts.port === 0 || !opts.fallback ? 1 : Math.max(1, opts.attempts); + + for (let i = 0; i < attempts; i++) { + const candidate = opts.port === 0 ? 0 : opts.port + i; + try { + await listenOnce(server, candidate); + const address = server.address(); + if (address === null || typeof address === 'string') { + throw new Error('The UI server bound to an unexpected address.'); + } + return address.port; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'EADDRINUSE' || i === attempts - 1) { + throw describeBindFailure(err, candidate, opts); + } + } + } + /* istanbul ignore next — the loop either returns or throws */ + throw new Error('The UI server could not bind a port.'); +} + +/** + * One `listen()` attempt, with both outcomes as a promise. + * + * The same `http.Server` is reused across attempts: a `listen()` that failed + * with EADDRINUSE never took a handle, so it can be listened on again directly + * (verified on Node 20 and 22 — `server.listening` is still `false` afterwards, + * and `close()` on a never-listening server would itself throw). + */ +function listenOnce(server: http.Server, port: number): Promise { + return new Promise((resolve, reject) => { + const onError = (err: Error): void => { + server.removeListener('listening', onListening); + reject(err); + }; + const onListening = (): void => { + server.removeListener('error', onError); + resolve(); + }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(port, LOOPBACK_ADDRESS); + }); +} + +/** Turn a bind failure into something a user can act on. */ +function describeBindFailure( + err: unknown, + port: number, + opts: { port: number; fallback: boolean; attempts: number } +): Error { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'EADDRINUSE') { + return opts.fallback + ? new Error( + `Ports ${opts.port}–${port} are all in use. Free one, or pick another with --port.` + ) + : new Error(`Port ${port} is already in use. Pick another with --port, or omit --port to let CodeGraph find a free one.`); + } + if (code === 'EACCES') { + return new Error(`Not allowed to listen on port ${port}. Ports below 1024 usually need elevated privileges — pick a higher one with --port.`); + } + return err instanceof Error ? err : new Error(String(err)); +} diff --git a/src/ui-server/open-browser.ts b/src/ui-server/open-browser.ts new file mode 100644 index 0000000..b8be7f5 --- /dev/null +++ b/src/ui-server/open-browser.ts @@ -0,0 +1,74 @@ +/** + * Opening the user's browser at the viewer URL. + * + * No dependency: the three platform openers are one-liners, and pulling in a + * package to shell out to `open` would be the only runtime dependency the + * viewer adds to a CLI that currently has ten. + */ + +import { spawn } from 'child_process'; +import { BROWSER_ENV } from './constants'; + +export { BROWSER_ENV }; + +const SUPPRESS_VALUES: ReadonlySet = new Set(['', 'none', '0', 'false', 'off']); + +export interface OpenCommand { + command: string; + args: string[]; +} + +/** + * The command that would open `url`, or `null` when opening is suppressed. + * + * Split out from {@link openBrowser} so the platform mapping is testable + * without launching anything. + */ +export function browserOpenCommand( + url: string, + platform: NodeJS.Platform, + override?: string +): OpenCommand | null { + if (override !== undefined) { + const trimmed = override.trim(); + if (SUPPRESS_VALUES.has(trimmed.toLowerCase())) return null; + return { command: trimmed, args: [url] }; + } + if (platform === 'darwin') return { command: 'open', args: [url] }; + if (platform === 'win32') { + // `start` is a cmd builtin, not an executable. The empty string is the + // window title — without it `start` treats a quoted URL as the title and + // opens a blank console instead. + return { command: 'cmd', args: ['/c', 'start', '', url] }; + } + return { command: 'xdg-open', args: [url] }; +} + +/** + * Open `url` in the user's default browser, best effort. + * + * Never throws and never keeps the CLI alive: the child is detached and + * unref'd, and a missing opener (a headless Linux box with no `xdg-open`) is + * swallowed — the URL is already printed, which is the part that matters. + * + * @returns `true` if a launch was attempted. + */ +export function openBrowser(url: string, platform: NodeJS.Platform = process.platform): boolean { + const open = browserOpenCommand(url, platform, process.env[BROWSER_ENV]); + if (!open) return false; + try { + const child = spawn(open.command, open.args, { + detached: true, + stdio: 'ignore', + // `start` is a shell builtin reached through `cmd /c`, so no shell here. + shell: false, + }); + child.on('error', () => { + /* no opener installed — the printed URL is the fallback */ + }); + child.unref(); + return true; + } catch { + return false; + } +} diff --git a/src/ui-server/security.ts b/src/ui-server/security.ts new file mode 100644 index 0000000..b7a1cce --- /dev/null +++ b/src/ui-server/security.ts @@ -0,0 +1,238 @@ +/** + * The `codegraph ui` server's security boundary. + * + * Threat model, stated plainly: this process serves a browser-readable view of + * the user's SOURCE CODE from a port on their machine. It binds loopback, so + * nothing on the network can reach it. That leaves one realistic attack — + * **DNS rebinding**: any page the user visits can point `evil.example` at + * `127.0.0.1` and then have the browser issue same-origin requests to us. The + * browser will happily connect; the only thing that distinguishes the attacker's + * request from the viewer's own is the `Host` header, which the browser fills in + * from the URL and script cannot forge. + * + * So the rules are: + * + * - **`Host` must be a loopback name** (`localhost`, `127.0.0.1`, `[::1]`) and, + * if it carries a port, that port must be ours. Anything else is 403. + * - **`Origin`, when present, must be loopback too.** Belt and braces: absent on + * the viewer's own same-origin GETs, and present-and-foreign only on a + * cross-site request we want nothing to do with. + * - **No CORS headers, ever.** Not adding `Access-Control-Allow-Origin` is what + * keeps a cross-origin reader from seeing a response body even if it does + * reach us. There is deliberately no way to turn this on. + * - **GET/HEAD only.** The viewer is a reader; nothing it serves has a side + * effect, so there is no state for a forged request to change. + * - **Every path resolves through {@link validatePathWithinRoot}** — the same + * chokepoint the MCP read sinks use, which catches `../` traversal AND + * in-tree symlinks pointing out of the root (#527). + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { PathRefusalError } from '../errors'; +import { validatePathWithinRoot, validateProjectPath } from '../utils'; + +export { PathRefusalError }; + +/** + * Host names that mean "this machine". A browser only ever sends the bracketed + * form for IPv6, but the raw form is accepted after brackets are stripped. + */ +const LOOPBACK_HOSTNAMES: ReadonlySet = new Set(['localhost', '127.0.0.1', '::1']); + +/** HTTP methods the viewer server answers. Everything else is 405. */ +export const ALLOWED_METHODS: readonly string[] = ['GET', 'HEAD']; + +interface HostParts { + hostname: string; + /** `undefined` when the header carried no `:port` suffix. */ + port: number | undefined; +} + +/** + * Split a `Host` header into hostname and port, or `null` if it is malformed. + * + * An unbracketed IPv6 literal (`::1`) is malformed per RFC 7230 and is rejected + * rather than guessed at — no browser produces one, so accepting it would only + * widen the parser for an attacker's benefit. + */ +function splitHostPort(host: string): HostParts | null { + const trimmed = host.trim(); + if (!trimmed) return null; + + if (trimmed.startsWith('[')) { + const end = trimmed.indexOf(']'); + if (end < 0) return null; + const port = parsePortSuffix(trimmed.slice(end + 1)); + if (port === null) return null; + return { hostname: trimmed.slice(1, end), port }; + } + + const colon = trimmed.indexOf(':'); + if (colon === -1) return { hostname: trimmed, port: undefined }; + // A second colon without brackets is a bare IPv6 literal or junk. + if (trimmed.indexOf(':', colon + 1) !== -1) return null; + const port = parsePortSuffix(trimmed.slice(colon)); + if (port === null) return null; + return { hostname: trimmed.slice(0, colon), port }; +} + +/** + * Parse the `:1234` tail of a `Host` header. + * + * @returns the port, `undefined` for an empty suffix, or `null` when the suffix + * is present but not a plain port number. + */ +function parsePortSuffix(suffix: string): number | undefined | null { + if (suffix === '') return undefined; + if (!suffix.startsWith(':')) return null; + const digits = suffix.slice(1); + if (!/^\d{1,5}$/.test(digits)) return null; + const port = Number(digits); + return port >= 0 && port <= 65535 ? port : null; +} + +/** + * Whether a request's `Host` header names this loopback server. + * + * A missing `Host` is rejected: HTTP/1.1 requires it, and the one client that + * may legally omit it (HTTP/1.0) is not a browser we need to serve. + */ +export function isAllowedHost(host: string | undefined, port: number): boolean { + if (typeof host !== 'string') return false; + const parts = splitHostPort(host); + if (!parts) return false; + if (!LOOPBACK_HOSTNAMES.has(parts.hostname.toLowerCase())) return false; + return parts.port === undefined || parts.port === port; +} + +/** + * Whether a request's `Origin` header is acceptable. + * + * An ABSENT `Origin` is allowed — browsers omit it on same-origin GETs, which + * is every request the viewer makes. A present one must be loopback-on-our-port; + * the literal `null` origin (sandboxed iframe, `file://` page) is refused. + */ +export function isAllowedOrigin(origin: string | undefined, port: number): boolean { + if (origin === undefined) return true; + const trimmed = origin.trim(); + if (trimmed === '') return true; + if (trimmed === 'null') return false; + + let url: URL; + try { + url = new URL(trimmed); + } catch { + return false; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; + // WHATWG keeps IPv6 hostnames bracketed; the allowlist stores them bare. + const hostname = url.hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase(); + if (!LOOPBACK_HOSTNAMES.has(hostname)) return false; + return url.port === '' || Number(url.port) === port; +} + +/** + * Whether a raw request path is worth resolving at all. + * + * Rejects any `..` segment outright rather than letting containment sort it + * out later. Containment WOULD catch it — but the SPA fallback sits behind + * containment, so `GET /../../etc/passwd` would otherwise be answered with the + * app shell (a 200) instead of the 404 a traversal attempt deserves. Nothing + * outside the root leaks either way; this just stops the server from + * pretending a hostile path was an ordinary route. + * + * Takes the RAW path from `req.url`, before WHATWG URL parsing folds `..` + * segments away — that folding is what would hide the attempt. + */ +export function isSafeRequestPath(rawPath: string): boolean { + const decoded = decodePath(rawPath); + if (decoded === null) return false; + return !decoded.split('/').includes('..'); +} + +/** + * Resolve a request path to a file inside the static asset root. + * + * Returns the absolute path, or `null` for anything that is not a readable file + * inside `rootDir` — a traversal attempt, a symlink escape, a directory, a + * missing file. Callers turn `null` into a 404 (never a 403): telling a prober + * which of those it hit is free information. + * + * Percent-decoding happens HERE, before containment is checked, so an encoded + * `..%2f` is caught by the same guard as a literal `../`. + */ +export function resolveStaticAsset(rootDir: string, urlPath: string): string | null { + const decoded = decodePath(urlPath); + if (decoded === null) return null; + + const relative = decoded.replace(/^\/+/, ''); + const absolute = validatePathWithinRoot(rootDir, relative); + if (!absolute) return null; + + try { + return fs.statSync(absolute).isFile() ? absolute : null; + } catch { + return null; + } +} + +/** + * Percent-decode a URL path and reject the encodings that only ever show up in + * an attack: NUL (truncates a path in some syscalls), other C0 control bytes, + * and backslashes (a separator on Windows, a legal filename character on POSIX + * — treating it as a separator everywhere is the safe direction, and no built + * asset name contains one). + * + * @returns the decoded path, or `null` if it is unusable. + */ +function decodePath(urlPath: string): string | null { + let decoded: string; + try { + decoded = decodeURIComponent(urlPath); + } catch { + return null; // malformed percent-encoding + } + // eslint-disable-next-line no-control-regex -- rejecting raw control bytes IS the point + if (/[\x00-\x1f\x7f\\]/.test(decoded)) return null; + return decoded; +} + +/** + * Resolve a project-relative source path to an absolute path that is safe to + * read and hand to the browser. + * + * This is the single read chokepoint for anything served OUT OF THE USER'S + * REPOSITORY (as opposed to the viewer's own bundled assets). The JSON API + * built on top of this server must route every file read through it — that is + * what keeps `/api/source?path=../../.ssh/id_rsa` from being a credential leak + * over a port the user opened to read their own code. + * + * @throws {PathRefusalError} when the root is a sensitive system directory, or + * the path escapes the root by traversal or symlink. + */ +export function resolveProjectFile(projectRoot: string, relativePath: string): string { + if (typeof relativePath !== 'string' || relativePath.trim() === '') { + throw new PathRefusalError('No file path was given.'); + } + const decoded = decodePath(relativePath); + if (decoded === null) { + throw new PathRefusalError(`Refusing to read an unusable path: ${relativePath}`); + } + + // Sensitive-directory refusal, same list the MCP entry points use. Checked on + // the ROOT rather than the leaf: a root of `/etc` makes every path under it + // sensitive, and a leaf check would have to enumerate the world. + const rootError = validateProjectPath(projectRoot); + if (rootError) throw new PathRefusalError(rootError); + + if (path.isAbsolute(decoded)) { + throw new PathRefusalError(`Refusing to read an absolute path: ${decoded}`); + } + + const absolute = validatePathWithinRoot(projectRoot, decoded); + if (!absolute) { + throw new PathRefusalError(`Refusing to read a path outside the project: ${decoded}`); + } + return absolute; +} diff --git a/src/ui-server/static.ts b/src/ui-server/static.ts new file mode 100644 index 0000000..77d12d8 --- /dev/null +++ b/src/ui-server/static.ts @@ -0,0 +1,138 @@ +/** + * Static file serving for the viewer's own bundle (`dist/viewer/`). + * + * Deliberately small: a MIME table, a stream, and the SPA fallback. Everything + * that decides WHETHER a path may be read lives in `security.ts`. + */ + +import * as fs from 'fs'; +import type { ServerResponse } from 'http'; +import * as path from 'path'; + +/** + * Content types for everything the Vite build emits, plus the handful of things + * a future viewer asset might be. Unknown extensions fall back to + * `application/octet-stream`, which — with `X-Content-Type-Options: nosniff` — + * a browser will download rather than execute. + */ +const CONTENT_TYPES: Readonly> = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.txt': 'text/plain; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + '.ico': 'image/x-icon', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.ttf': 'font/ttf', + '.otf': 'font/otf', + '.wasm': 'application/wasm', +}; + +/** The content type to send for a file, by extension. */ +export function contentTypeFor(filePath: string): string { + return CONTENT_TYPES[path.extname(filePath).toLowerCase()] ?? 'application/octet-stream'; +} + +/** + * Cache policy. + * + * Vite content-hashes everything under `assets/`, so those are immutable for a + * year — a reload of the viewer refetches nothing, and an upgraded CodeGraph + * changes the hash and therefore the URL. `index.html` names those hashes, so + * it must never be cached. + */ +export function cacheControlFor(relativePath: string): string { + const normalized = relativePath.split(path.sep).join('/'); + return normalized.startsWith('assets/') + ? 'public, max-age=31536000, immutable' + : 'no-store'; +} + +/** + * Stream a file as the response body. + * + * `HEAD` gets identical headers and no body — it is a GET whose body the client + * asked us to skip, which keeps it read-only by construction. + */ +export function sendFile( + res: ServerResponse, + absolutePath: string, + options: { rootDir: string; method: string; extraHeaders?: Record } +): void { + let stats: fs.Stats; + try { + stats = fs.statSync(absolutePath); + } catch { + sendText(res, 404, 'Not found', options.method); + return; + } + + const relative = path.relative(options.rootDir, absolutePath); + res.writeHead(200, { + 'Content-Type': contentTypeFor(absolutePath), + 'Content-Length': String(stats.size), + 'Cache-Control': cacheControlFor(relative), + 'Last-Modified': stats.mtime.toUTCString(), + ...options.extraHeaders, + }); + + if (options.method === 'HEAD') { + res.end(); + return; + } + + const stream = fs.createReadStream(absolutePath); + stream.on('error', () => { + // Headers are already out, so there is no status left to change: drop the + // connection so the client sees a truncated body rather than a silent lie. + res.destroy(); + }); + res.on('close', () => stream.destroy()); + stream.pipe(res); +} + +/** Send a plain-text status response (the error path for a browser or curl). */ +export function sendText(res: ServerResponse, status: number, message: string, method: string): void { + const body = Buffer.from(message.endsWith('\n') ? message : `${message}\n`, 'utf-8'); + res.writeHead(status, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Length': String(body.byteLength), + 'Cache-Control': 'no-store', + }); + res.end(method === 'HEAD' ? undefined : body); +} + +/** Send a JSON response. Used for `/api/*`, which must never get HTML back. */ +export function sendJson(res: ServerResponse, status: number, payload: unknown, method: string): void { + const body = Buffer.from(JSON.stringify(payload), 'utf-8'); + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': String(body.byteLength), + 'Cache-Control': 'no-store', + }); + res.end(method === 'HEAD' ? undefined : body); +} + +/** + * Whether a request path should fall back to `index.html` when no file matches. + * + * The viewer is hash-routed (`/#/s/`), so in practice only `/` is ever + * requested — but a bookmarked or hand-typed `/anything` should still open the + * app rather than a 404 page. A path that names a FILE (has an extension) never + * falls back: answering `/assets/index-abc123.js` with HTML would hand the + * browser a script that is not a script, and hide a genuinely missing asset + * behind a page that looks like it loaded. + */ +export function shouldFallBackToIndex(pathname: string): boolean { + return path.extname(pathname) === ''; +}