Files
Colby Mchenry 69ee3b9a85 fix(ui): fall back past a port Windows refuses with EACCES, and say why (#2299) (#2353)
On Windows, a port another program holds exclusively (SO_EXCLUSIVEADDRUSE)
or one inside an excluded port range fails listen() with EACCES rather than
EADDRINUSE. The `codegraph ui` port fallback only moved on after EADDRINUSE,
so it stopped at the first such port. The error then blamed the POSIX
privileged-port rule ("Ports below 1024 usually need elevated privileges")
for ports like 4747 or 49912. Confirmed on Windows 11: a .NET listener on
0.0.0.0:P with ExclusiveAddressUse makes Node's listen(P, '127.0.0.1') fail
with EACCES.

Fix:
- shouldTryNextPort moves past EADDRINUSE on every platform. It moves past
  EACCES at any port on Windows, which has no privileged ports, and from
  1024 up elsewhere.
- Below 1024 off Windows, EACCES is the privileged-port rule, and the next
  port falls under it too. The walk stops there with the message it always
  gave, rather than trying 20 privileged ports.
- describeBindFailure takes the platform:
  - Windows names the exclusive hold or reserved range and how to list the
    ranges (netsh int ipv4 show excludedportrange protocol=tcp).
  - POSIX keeps the privileged-port text below 1024. Above it, it says the
    system refused permission.
  - A walk that ran out says "in use or reserved" (Windows) or "in use or
    not allowed" when any port was refused rather than in use.
- Both helpers take the platform as a parameter, like browserOpenCommand,
  so the tests check every platform's answer on any host.

This builds on PR #2319 by @sx4im, which advanced on EACCES, used the
Windows wording and added the issue's listen-spy test. That PR advanced on
EACCES on every platform and printed "Windows refused port N ... netsh" on
Linux and macOS too. On POSIX it would also walk 20 privileged ports and
end with "Ports 80-99 are all in use or reserved". This change restricts
both to where they are true. The changelog entry goes in the viewer-launch
file, because the viewer is not released yet.

Verification:
- __tests__/ui-server.test.ts gains three kinds of test:
  - The issue's end-to-end test: port N is taken and N+1 is refused through
    a listen spy, so the server must land past N+1 and serve 200.
  - A pinned-port message test.
  - Unit tests of the decision and the messages for win32, linux and darwin.
- Results on __tests__/ui-server.test.ts:
  - Main source: 9 failed / 40 passed. The walk fails with "Not allowed to
    listen on port 57423. Ports below 1024 usually need elevated privileges".
  - With the fix: 49 passed / 2 skipped (existing POSIX-gated tests).
- Built CLI on Windows, with 0.0.0.0:<port> held exclusively:
  - `codegraph ui` while 4747 is held: main exits 1 with the privileged-port
    message. With the fix it serves on 4748 (GET / 200).
  - `codegraph ui --port <held>`: prints the Windows message.
  - A 3-port walk over held ports reports "in use or reserved".
  - Re-listening on the same server after a real EACCES works on Node 22.22
    and 24.16.
- tsc --noEmit is clean and npm run build succeeds. The 20 viewer test
  files pass (439 passed, 5 skipped).
- Full suite: 5732 passed, 10 failed, 319 skipped. The 10 failures are
  timeouts and EBUSY teardown under load in function-ref,
  git-index-currency, index-daemon-rebuild and rust-self-owner. All four
  files pass when run alone.

Issue #2299 reported by @ijbranch.

Co-authored-by: Saim Shafique <msaimshafique4664@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:03:03 +00:00

725 lines
30 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `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, vi } 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,
describeBindFailure,
isAllowedHost,
isAllowedOrigin,
isSafeRequestPath,
PathRefusalError,
resolveProjectFile,
resolveStaticAsset,
shouldTryNextPort,
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<string, string> } = {}
): Promise<Response> {
return new Promise((resolve, reject) => {
const headers: Record<string, string> = { 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();
});
}
/** A failed `listen()` as node reports it. */
function bindError(code: string, port: number): NodeJS.ErrnoException {
return Object.assign(new Error(`listen ${code}: 127.0.0.1:${port}`), { code, syscall: 'listen' });
}
/**
* Make `listen()` on `refused` fail with EACCES, the way Windows refuses a port
* another program holds exclusively or one inside a reserved range, without
* needing either on the machine running the test (#2299). Every other port is
* a real bind.
*/
function refuseListenOn(...refused: number[]): { mockRestore(): void } {
const realListen = http.Server.prototype.listen;
return vi
.spyOn(http.Server.prototype, 'listen')
.mockImplementation(function (this: http.Server, ...args: unknown[]) {
const port = args[0];
if (typeof port === 'number' && refused.includes(port)) {
process.nextTick(() => this.emit('error', bindError('EACCES', port)));
return this;
}
return (realListen as (...a: unknown[]) => http.Server).apply(this, args);
});
}
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'),
'<!doctype html><html><body><div id="app"></div>' +
'<script type="module" src="./assets/index-abc123.js"></script></body></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('<div id="app">');
});
it('serves index.html directly too', async () => {
const res = await request(server.port, '/index.html');
expect(res.status).toBe(200);
expect(res.body).toContain('<div id="app">');
});
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('<div id="app">');
// 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<void>((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<void>((resolve) => blocker.close(() => resolve()));
}
});
it('refuses to move off a port the caller pinned', async () => {
const blocker = http.createServer(() => {});
await new Promise<void>((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<void>((resolve) => blocker.close(() => resolve()));
}
});
/**
* #2299: on Windows a port another program holds exclusively, or one in a
* reserved range, fails with EACCES rather than EADDRINUSE, and the walk
* used to stop there. Port N is really taken, N+1 is refused, so the server
* has to land beyond both. N+1 is an ephemeral port, above 1023, so this
* holds on every platform.
*/
it('falls back past a port refused with EACCES', async () => {
const blocker = http.createServer(() => {});
await new Promise<void>((resolve) => blocker.listen(0, '127.0.0.1', resolve));
const taken = (blocker.address() as { port: number }).port;
const refusal = refuseListenOn(taken + 1);
try {
const second = await startUiServer({ projectRoot, viewerDir, port: taken });
try {
expect(second.port).toBeGreaterThan(taken + 1);
// …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();
}
} finally {
refusal.mockRestore();
await new Promise<void>((resolve) => blocker.close(() => resolve()));
}
});
it('blames a refused pinned port on what refused it, not on privileged ports', async () => {
// The port from the report: nowhere near 1024.
const refusal = refuseListenOn(49912);
try {
const failure = startUiServer({ projectRoot, viewerDir, port: 49912, portFallback: false });
await expect(failure).rejects.toThrow(/49912/);
const message = await failure.catch((err: Error) => err.message);
expect(message).not.toMatch(/below 1024|elevated privileges/);
expect(message).not.toMatch(/already in use/);
expect(message).toMatch(/refused/);
} finally {
refusal.mockRestore();
}
});
});
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('<div id="app">');
}
});
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('methods', () => {
it('refuses every method it has never answered', async () => {
for (const method of ['PUT', 'PATCH', 'OPTIONS', 'TRACE']) {
const res = await request(server.port, '/', { method });
expect(res.status, method).toBe(405);
expect(res.headers['allow']).toBe('GET, HEAD, POST, DELETE');
}
});
/**
* The static side stayed a pure reader when `/api/trails` gained a write
* (CG-60). A POST at an asset path is 405 with `Allow: GET, HEAD` — the
* narrower answer, since nothing under the viewer bundle will ever take
* one.
*/
it('refuses a write outside /api/, whatever it carries', async () => {
for (const method of ['POST', 'DELETE']) {
const res = await request(server.port, '/', {
method,
headers: { 'X-CodeGraph-UI': '1' },
});
expect(res.status, method).toBe(405);
expect(res.headers['allow']).toBe('GET, HEAD');
}
});
/**
* Under `/api/` a write is answered as JSON even when refused — the viewer
* parses these, and a text/plain body surfaces as a parse error rather than
* the refusal it is. No API is mounted on this server, so the refusal is
* the boundary's own and not an endpoint's.
*/
it('refuses an unmarked write under /api/ as JSON', async () => {
const res = await request(server.port, '/api/trails', { method: 'POST' });
expect(res.status).toBe(403);
expect(res.headers['content-type']).toContain('application/json');
expect(JSON.parse(res.body).code).toBe('refused');
});
});
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('<div id="app">');
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('<div id="app">');
});
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');
});
});
/**
* What a bind failure means depends on the platform (#2299): Windows has no
* privileged ports and refuses a taken or reserved one with EACCES, while
* elsewhere EACCES below 1024 is the privileged-port rule. The platform is a
* parameter, so every host checks every platform's answer.
*/
describe('port fallback on each platform', () => {
const LIST_RESERVED = 'netsh int ipv4 show excludedportrange protocol=tcp';
it('moves past a port in use everywhere, and past EACCES on Windows at any port', () => {
for (const platform of ['win32', 'linux', 'darwin'] as const) {
expect(shouldTryNextPort('EADDRINUSE', 80, platform), platform).toBe(true);
expect(shouldTryNextPort('EADDRINUSE', 4747, platform), platform).toBe(true);
}
expect(shouldTryNextPort('EACCES', 49912, 'win32')).toBe(true);
// Windows has no privileged-port rule: a low port is held, not forbidden.
expect(shouldTryNextPort('EACCES', 80, 'win32')).toBe(true);
});
it('stops at EACCES below 1024 elsewhere, where the next port needs the same privilege', () => {
expect(shouldTryNextPort('EACCES', 80, 'linux')).toBe(false);
expect(shouldTryNextPort('EACCES', 1023, 'darwin')).toBe(false);
// From 1024 up the refusal is about that port, and the next may be allowed.
expect(shouldTryNextPort('EACCES', 1024, 'linux')).toBe(true);
expect(shouldTryNextPort('EACCES', 4747, 'darwin')).toBe(true);
});
it('never moves past any other failure', () => {
for (const platform of ['win32', 'linux'] as const) {
expect(shouldTryNextPort('EADDRNOTAVAIL', 4747, platform), platform).toBe(false);
expect(shouldTryNextPort('EPERM', 4747, platform), platform).toBe(false);
expect(shouldTryNextPort(undefined, 4747, platform), platform).toBe(false);
}
});
it('says why Windows refused a pinned port, without the POSIX privileged-port story', () => {
for (const port of [49912, 80]) {
const message = describeBindFailure(bindError('EACCES', port), port, { port, fallback: false }, 'win32').message;
expect(message).toMatch(
new RegExp(`^Windows refused port ${port}: another program holds it, or it is in a reserved range`)
);
expect(message).toContain(LIST_RESERVED);
expect(message).toContain('omit --port');
expect(message).not.toMatch(/below 1024|elevated privileges/);
}
});
it('keeps the privileged-port explanation off Windows, and only below 1024', () => {
for (const fallback of [false, true]) {
const message = describeBindFailure(bindError('EACCES', 80), 80, { port: 80, fallback }, 'linux').message;
expect(message).toBe(
'Not allowed to listen on port 80. Ports below 1024 usually need elevated privileges — pick a higher one with --port.'
);
}
const high = describeBindFailure(bindError('EACCES', 4747), 4747, { port: 4747, fallback: false }, 'darwin').message;
expect(high).toMatch(/^Not allowed to listen on port 4747: the system refused permission\./);
expect(high).not.toMatch(/below 1024|elevated privileges|Windows|netsh/);
});
it('says a walk that ran out met refusals, not only ports in use', () => {
const walk = { port: 4747, fallback: true };
const inUse = bindError('EADDRINUSE', 4766);
const refused = bindError('EACCES', 4766);
// Nothing refused: the message it always gave.
expect(describeBindFailure(inUse, 4766, walk, 'win32').message).toBe(
'Ports 4747–4766 are all in use. Free one, or pick another with --port.'
);
const windows = describeBindFailure(refused, 4766, walk, 'win32').message;
expect(windows).toMatch(/^Ports 4747–4766 are all in use or reserved/);
expect(windows).toContain(LIST_RESERVED);
// An earlier port was refused even though the last one was only in use.
expect(describeBindFailure(inUse, 4766, walk, 'win32', true).message).toBe(windows);
const posix = describeBindFailure(refused, 4766, walk, 'linux').message;
expect(posix).toMatch(/^Ports 4747–4766 are all in use or not allowed\./);
expect(posix).not.toMatch(/reserved|netsh/);
});
it('leaves the pinned in-use message and other failures as they were', () => {
expect(
describeBindFailure(bindError('EADDRINUSE', 8080), 8080, { port: 8080, fallback: false }, 'win32').message
).toBe('Port 8080 is already in use. Pick another with --port, or omit --port to let CodeGraph find a free one.');
const other = bindError('EADDRNOTAVAIL', 4747);
expect(describeBindFailure(other, 4747, { port: 4747, fallback: true }, 'linux')).toBe(other);
});
});
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'],
});
// Windows routes the override through cmd so a `.cmd`/`.bat` shim — which
// CreateProcess cannot launch directly — still works.
expect(browserOpenCommand('http://x', 'win32', 'C:\\tools\\open.cmd')).toEqual({
command: 'cmd',
args: ['/c', 'C:\\tools\\open.cmd', 'http://x'],
});
for (const off of ['none', 'NONE', '0', 'false', 'off', '', ' ']) {
expect(browserOpenCommand('http://x', 'darwin', off), off).toBeNull();
}
});
});