diff --git a/src/.gitkeep b/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..3708fef --- /dev/null +++ b/src/config.ts @@ -0,0 +1,157 @@ +/** + * src/config.ts — T4: environment config loader + * + * Reads env vars once at startup, validates them, derives allowedOrigins from + * local NIC IPs (M1 — NOT from bindHost; 0.0.0.0 is never a valid Origin), + * and returns a frozen Config object. + */ + +import os from 'node:os' +import type { Config, EnvLike } from './types.js' + +// ── constants ───────────────────────────────────────────────────────────────── + +const DEFAULT_PORT = 3000 +const DEFAULT_BIND_HOST = '0.0.0.0' +const DEFAULT_IDLE_TTL_SEC = 24 * 60 * 60 // 24 hours in seconds +const DEFAULT_SCROLLBACK_BYTES = 2 * 1024 * 1024 // 2 MB +const DEFAULT_MAX_PAYLOAD_BYTES = 1 * 1024 * 1024 // 1 MB +const DEFAULT_WS_PATH = '/term' + +// ── helpers ─────────────────────────────────────────────────────────────────── + +function parsePositiveInt( + raw: string | undefined, + label: string, + fallback: number, +): number { + if (raw === undefined) return fallback + const n = Number(raw) + if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) { + throw new Error( + `Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative integer`, + ) + } + return n +} + +function parsePort(raw: string | undefined): number { + if (raw === undefined) return DEFAULT_PORT + const n = Number(raw) + if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 65535) { + throw new Error( + `Invalid config: PORT=${JSON.stringify(raw)} — must be an integer 1–65535`, + ) + } + return n +} + +function parseIdleTtl(raw: string | undefined): number { + if (raw === undefined) return DEFAULT_IDLE_TTL_SEC * 1000 + const n = Number(raw) + if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) { + throw new Error( + `Invalid config: IDLE_TTL=${JSON.stringify(raw)} — must be a non-negative integer (seconds)`, + ) + } + return n * 1000 +} + +/** + * Derive allowedOrigins from host NIC IPs (M1). + * + * Rules: + * - Enumerate os.networkInterfaces(), pick IPv4 entries. + * - Skip internal (loopback) entries — 127.0.0.1 / localhost are added explicitly. + * - For every qualifying address, produce both http:// and https:// origins. + * - Append ALLOWED_ORIGINS env-var items (comma-separated). + * - Deduplicate. + * - Never include 0.0.0.0 (wildcard listen address, never a browser Origin). + */ +function deriveAllowedOrigins(port: number, extraEnv: string | undefined): readonly string[] { + const set = new Set() + + // 1. Always include localhost / loopback + const loopbackHosts = ['localhost', '127.0.0.1'] + for (const host of loopbackHosts) { + set.add(`http://${host}:${port}`) + set.add(`https://${host}:${port}`) + } + + // 2. Enumerate NIC IPv4 addresses — skip internal (loopback already handled above) + const ifaces = os.networkInterfaces() + for (const ifaceList of Object.values(ifaces)) { + if (!ifaceList) continue + for (const entry of ifaceList) { + // Only IPv4, non-internal entries; never 0.0.0.0 + if ( + entry.family === 'IPv4' && + !entry.internal && + entry.address !== '0.0.0.0' + ) { + set.add(`http://${entry.address}:${port}`) + set.add(`https://${entry.address}:${port}`) + } + } + } + + // 3. Merge ALLOWED_ORIGINS env var (comma-separated list of origin strings) + if (extraEnv) { + for (const raw of extraEnv.split(',')) { + const trimmed = raw.trim() + if (trimmed) set.add(trimmed) + } + } + + return Object.freeze([...set]) +} + +// ── loadConfig ──────────────────────────────────────────────────────────────── + +/** + * Read and validate environment variables; fill in defaults for missing keys. + * Invalid values (port out of range, TTL non-integer, etc.) throw immediately + * so the server never starts in a misconfigured state (fail-fast). + * + * The returned Config is Object.freeze'd — immutable after construction. + */ +export function loadConfig(env: EnvLike): Config { + const port = parsePort(env['PORT']) + + const bindHost = env['BIND_HOST'] ?? DEFAULT_BIND_HOST + + const shellPath = + env['SHELL_PATH'] ?? (process.env['SHELL'] as string | undefined) ?? '/bin/zsh' + + const homeDir = os.homedir() + + const idleTtlMs = parseIdleTtl(env['IDLE_TTL']) + + const scrollbackBytes = parsePositiveInt( + env['SCROLLBACK_BYTES'], + 'SCROLLBACK_BYTES', + DEFAULT_SCROLLBACK_BYTES, + ) + + const maxPayloadBytes = parsePositiveInt( + env['MAX_PAYLOAD_BYTES'], + 'MAX_PAYLOAD_BYTES', + DEFAULT_MAX_PAYLOAD_BYTES, + ) + + const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH + + const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS']) + + return Object.freeze({ + port, + bindHost, + shellPath, + homeDir, + idleTtlMs, + scrollbackBytes, + maxPayloadBytes, + wsPath, + allowedOrigins, + } satisfies Config) +} diff --git a/src/http/.gitkeep b/src/http/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/http/origin.ts b/src/http/origin.ts new file mode 100644 index 0000000..b82c465 --- /dev/null +++ b/src/http/origin.ts @@ -0,0 +1,53 @@ +/** + * src/http/origin.ts — Origin whitelist check (T7) + * + * Pure function, no side effects. Security-critical: + * this is the CSWSH (Cross-Site WebSocket Hijacking) defence described in + * TECH_DOC §7 and ARCHITECTURE §3.3. + * + * Strategy: + * - Parse the incoming Origin as a URL. + * - Compare scheme + hostname + port against every entry in the allowed list. + * - Both host AND port must match (ARCHITECTURE §3.3: "host+port 双匹配"). + * - undefined Origin (non-browser clients: curl, scripts) → reject by default. + * This is the single central policy point — no scattered `if` checks elsewhere. + */ + +/** + * Returns true iff `origin` exactly matches one of the `allowed` origins. + * + * Matching is scheme + hostname + port (all three must agree). + * `undefined` → false (non-browser client; default-deny, no scattered if-chains). + */ +export function isOriginAllowed( + origin: string | undefined, + allowed: readonly string[], +): boolean { + // Central policy: undefined Origin is rejected. No other code path handles this. + if (origin === undefined || origin === '') { + return false + } + + let incomingUrl: URL + try { + incomingUrl = new URL(origin) + } catch { + // Malformed Origin value → reject. + return false + } + + return allowed.some((entry) => { + let allowedUrl: URL + try { + allowedUrl = new URL(entry) + } catch { + return false + } + + return ( + incomingUrl.protocol === allowedUrl.protocol && + incomingUrl.hostname === allowedUrl.hostname && + incomingUrl.port === allowedUrl.port + ) + }) +} diff --git a/src/protocol.ts b/src/protocol.ts new file mode 100644 index 0000000..8361fb2 --- /dev/null +++ b/src/protocol.ts @@ -0,0 +1,158 @@ +/** + * src/protocol.ts — WebSocket message codec + validation (T5, pure functions) + * + * Invariant #3 (ARCHITECTURE §8): parseClientMessage NEVER throws. + * All invalid input flows through ParseResult.ok=false. + * + * Imports only from src/types.ts (frozen shared contract, T2). + */ + +import type { ClientMessage, ServerMessage, ParseResult } from './types.js' + +// ─── SESSION_ID_RE (M7) ─────────────────────────────────────────────────────── + +/** + * UUID v4 pattern. + * - 8-4-4-4-12 hex groups + * - version nibble must be '4' + * - variant nibble must be 8, 9, a, or b (10xx in binary) + * + * M7: strings like "abc123" must be rejected; crypto.randomUUID() values must pass. + */ +export const SESSION_ID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +// ─── Type whitelist ─────────────────────────────────────────────────────────── + +const ALLOWED_TYPES = new Set(['attach', 'input', 'resize']) + +// ─── parseClientMessage ─────────────────────────────────────────────────────── + +/** + * Parse and validate a raw WS text frame as a ClientMessage. + * + * Steps (ARCHITECTURE §3.2 / TECH_DOC §5.3): + * 1. JSON.parse — bad JSON → ok=false + * 2. Must be a plain object (not null, array, etc.) + * 3. type must be in whitelist {'attach','input','resize'} + * 4. Per-type field validation: + * - resize: cols and rows are integers in [1, 1000] + * - input: data is a string (passed through verbatim, invariant #9) + * - attach: sessionId is null OR a string matching SESSION_ID_RE (M7) + * + * NEVER throws — all errors return { ok: false, error: }. + */ +export function parseClientMessage(raw: string): ParseResult { + // Step 1: JSON parse + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return { ok: false, error: 'invalid JSON' } + } + + // Step 2: Must be a plain object + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false, error: 'message must be a JSON object' } + } + + const obj = parsed as Record + + // Step 3: type whitelist + const type = obj['type'] + if (typeof type !== 'string' || !ALLOWED_TYPES.has(type)) { + return { ok: false, error: `unknown or missing type: ${String(type)}` } + } + + // Step 4: per-type validation + if (type === 'resize') { + return validateResize(obj) + } + + if (type === 'input') { + return validateInput(obj) + } + + // type === 'attach' + return validateAttach(obj) +} + +// ─── Per-type validators ────────────────────────────────────────────────────── + +function validateResize(obj: Record): ParseResult { + const { cols, rows } = obj + + if (!isValidDimension(cols)) { + return { + ok: false, + error: `resize.cols must be an integer in [1, 1000], got ${String(cols)}`, + } + } + + if (!isValidDimension(rows)) { + return { + ok: false, + error: `resize.rows must be an integer in [1, 1000], got ${String(rows)}`, + } + } + + return { + ok: true, + message: { type: 'resize', cols: cols as number, rows: rows as number }, + } +} + +function isValidDimension(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 1000 +} + +function validateInput(obj: Record): ParseResult { + const { data } = obj + + if (typeof data !== 'string') { + return { ok: false, error: `input.data must be a string, got ${typeof data}` } + } + + // Invariant #9: data passed through verbatim, no content filtering + return { ok: true, message: { type: 'input', data } } +} + +function validateAttach(obj: Record): ParseResult { + const { sessionId } = obj + + // sessionId must be explicitly present as null or a UUID v4 string (M7) + if (!Object.prototype.hasOwnProperty.call(obj, 'sessionId')) { + return { ok: false, error: 'attach.sessionId field is required' } + } + + if (sessionId === null) { + return { ok: true, message: { type: 'attach', sessionId: null } } + } + + if (typeof sessionId !== 'string') { + return { + ok: false, + error: `attach.sessionId must be null or a UUID v4 string, got ${typeof sessionId}`, + } + } + + if (!SESSION_ID_RE.test(sessionId)) { + return { + ok: false, + error: `attach.sessionId is not a valid UUID v4: ${sessionId}`, + } + } + + return { ok: true, message: { type: 'attach', sessionId } } +} + +// ─── serialize ──────────────────────────────────────────────────────────────── + +/** + * Serialize a ServerMessage to a JSON text frame. + * + * Internal trusted data — JSON.stringify directly (ARCHITECTURE §3.2). + */ +export function serialize(msg: ServerMessage): string { + return JSON.stringify(msg) +} diff --git a/src/session/.gitkeep b/src/session/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/session/ring-buffer.ts b/src/session/ring-buffer.ts new file mode 100644 index 0000000..c937353 --- /dev/null +++ b/src/session/ring-buffer.ts @@ -0,0 +1,65 @@ +/** + * src/session/ring-buffer.ts (T6) — output scrollback for reconnect replay. + * + * M2 (the easy-to-get-wrong part). PTY output is an opaque ANSI byte stream; we + * must NEVER cut a multi-byte UTF-8 codepoint or an ANSI escape sequence in half, + * or the first screen on reconnect renders as garbage — exactly the vibe-coding + * "refresh and the Claude TUI is still there" path. So: + * + * - capacity is measured in REAL UTF-8 bytes (Buffer.byteLength), because + * `string.length` counts UTF-16 code units, not bytes; + * - eviction happens on whole append-chunk boundaries (drop the oldest chunk), + * so no chunk is ever sliced (the trade-off is the live size floats a little + * above/below maxBytes); + * - snapshot() prepends a soft-reset `\x1b[0m` so that even if the oldest + * surviving chunk started mid-attribute, the terminal is reset first. + * + * Implements the frozen `RingBuffer` contract in src/types.ts (read-only import). + */ + +import type { RingBuffer } from '../types.js'; + +const SOFT_RESET = '\x1b[0m'; + +interface Chunk { + readonly text: string; + readonly bytes: number; +} + +/** + * Create a ring buffer holding at most ~`maxBytes` UTF-8 bytes of recent output. + * + * @param maxBytes capacity in real UTF-8 bytes (e.g. `cfg.scrollbackBytes`). + */ +export function createRingBuffer(maxBytes: number): RingBuffer { + // Immutable-update style: each append produces a new chunk list / total rather + // than mutating shared state in place (per coding-style immutability rule). + let chunks: readonly Chunk[] = []; + let totalBytes = 0; + + function append(chunk: string): void { + if (chunk.length === 0) return; // nothing to store; keeps accounting clean + + const bytes = Buffer.byteLength(chunk, 'utf8'); + let nextChunks: Chunk[] = [...chunks, { text: chunk, bytes }]; + let nextTotal = totalBytes + bytes; + + // Evict whole oldest chunks until we fit — but never drop the only remaining + // chunk, so a lone chunk larger than capacity is kept intact (cannot split). + while (nextTotal > maxBytes && nextChunks.length > 1) { + const oldest = nextChunks[0]!; + nextChunks = nextChunks.slice(1); + nextTotal -= oldest.bytes; + } + + chunks = nextChunks; + totalBytes = nextTotal; + } + + function snapshot(): string { + // Soft-reset first (M2 safety net), then the current buffer verbatim. + return SOFT_RESET + chunks.map((c) => c.text).join(''); + } + + return { append, snapshot }; +} diff --git a/test/.gitkeep b/test/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..3a9c918 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,289 @@ +/** + * test/config.test.ts — T4: config module unit tests (TDD) + * + * Strategy: + * - Mock `node:os` networkInterfaces to inject fake NICs (M1) + * - Assert defaults, env overrides, validation errors, allowedOrigins shape + * + * vi.mock is hoisted to the top of the file by vitest — the factory runs before + * any import, so config.ts will receive the mocked os module. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { NetworkInterfaceInfo } from 'node:os' + +// ── mock node:os ───────────────────────────────────────────────────────────── +// vi.mock is hoisted by vitest, so this factory runs before config.ts is imported. + +const mockNetworkInterfaces = vi.fn<[], ReturnType>() +mockNetworkInterfaces.mockReturnValue({}) + +const mockHomedir = vi.fn<[], string>() +mockHomedir.mockReturnValue('/home/testuser') + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + default: { + ...actual.default, + homedir: () => mockHomedir(), + networkInterfaces: () => mockNetworkInterfaces(), + }, + homedir: () => mockHomedir(), + networkInterfaces: () => mockNetworkInterfaces(), + } +}) + +// Import config AFTER mock is set up (dynamic import ensures mock applies) +const { loadConfig } = await import('../src/config.js') + +// Helper: build a fake IPv4 NIC entry +function makeNic(address: string, internal: boolean): NetworkInterfaceInfo { + return { + address, + netmask: '255.255.255.0', + family: 'IPv4', + mac: '00:00:00:00:00:00', + internal, + cidr: `${address}/24`, + } +} + +// ── Default values ──────────────────────────────────────────────────────────── +describe('loadConfig — defaults', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('uses PORT 3000', () => { + const cfg = loadConfig({}) + expect(cfg.port).toBe(3000) + }) + + it('uses BIND_HOST 0.0.0.0', () => { + const cfg = loadConfig({}) + expect(cfg.bindHost).toBe('0.0.0.0') + }) + + it('uses IDLE_TTL 24h in ms', () => { + const cfg = loadConfig({}) + expect(cfg.idleTtlMs).toBe(24 * 60 * 60 * 1000) + }) + + it('uses SCROLLBACK_BYTES 2MB', () => { + const cfg = loadConfig({}) + expect(cfg.scrollbackBytes).toBe(2 * 1024 * 1024) + }) + + it('uses MAX_PAYLOAD_BYTES 1MB', () => { + const cfg = loadConfig({}) + expect(cfg.maxPayloadBytes).toBe(1 * 1024 * 1024) + }) + + it('uses WS_PATH /term', () => { + const cfg = loadConfig({}) + expect(cfg.wsPath).toBe('/term') + }) + + it('uses homeDir from os.homedir()', () => { + mockHomedir.mockReturnValue('/home/testuser') + const cfg = loadConfig({}) + expect(cfg.homeDir).toBe('/home/testuser') + }) +}) + +// ── Env overrides ───────────────────────────────────────────────────────────── +describe('loadConfig — env overrides', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('reads PORT from env', () => { + const cfg = loadConfig({ PORT: '8080' }) + expect(cfg.port).toBe(8080) + }) + + it('reads BIND_HOST from env', () => { + const cfg = loadConfig({ BIND_HOST: '127.0.0.1' }) + expect(cfg.bindHost).toBe('127.0.0.1') + }) + + it('reads SHELL_PATH from env', () => { + const cfg = loadConfig({ SHELL_PATH: '/bin/bash' }) + expect(cfg.shellPath).toBe('/bin/bash') + }) + + it('reads IDLE_TTL (seconds) and converts to ms', () => { + const cfg = loadConfig({ IDLE_TTL: '3600' }) + expect(cfg.idleTtlMs).toBe(3600 * 1000) + }) + + it('reads SCROLLBACK_BYTES from env', () => { + const cfg = loadConfig({ SCROLLBACK_BYTES: '1048576' }) + expect(cfg.scrollbackBytes).toBe(1048576) + }) + + it('reads MAX_PAYLOAD_BYTES from env', () => { + const cfg = loadConfig({ MAX_PAYLOAD_BYTES: '524288' }) + expect(cfg.maxPayloadBytes).toBe(524288) + }) + + it('reads WS_PATH from env', () => { + const cfg = loadConfig({ WS_PATH: '/ws' }) + expect(cfg.wsPath).toBe('/ws') + }) +}) + +// ── Validation errors ───────────────────────────────────────────────────────── +describe('loadConfig — invalid values throw', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + }) + + it('throws for PORT below 1', () => { + expect(() => loadConfig({ PORT: '0' })).toThrow() + }) + + it('throws for PORT above 65535', () => { + expect(() => loadConfig({ PORT: '65536' })).toThrow() + }) + + it('throws for PORT that is not a number', () => { + expect(() => loadConfig({ PORT: 'abc' })).toThrow() + }) + + it('throws for IDLE_TTL that is not a number', () => { + expect(() => loadConfig({ IDLE_TTL: 'abc' })).toThrow() + }) + + it('throws for IDLE_TTL that is negative', () => { + expect(() => loadConfig({ IDLE_TTL: '-1' })).toThrow() + }) + + it('throws for SCROLLBACK_BYTES that is not a number', () => { + expect(() => loadConfig({ SCROLLBACK_BYTES: 'nope' })).toThrow() + }) + + it('throws for MAX_PAYLOAD_BYTES that is not a number', () => { + expect(() => loadConfig({ MAX_PAYLOAD_BYTES: 'nope' })).toThrow() + }) +}) + +// ── allowedOrigins — M1 core tests ────────────────────────────────────────── +describe('loadConfig — allowedOrigins (M1)', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('always includes localhost variants', () => { + const cfg = loadConfig({}) + const origins = cfg.allowedOrigins + expect(origins).toContain('http://localhost:3000') + expect(origins).toContain('http://127.0.0.1:3000') + }) + + it('never contains 0.0.0.0 (M1: bindHost must not bleed in)', () => { + // even if BIND_HOST is 0.0.0.0 (default) + const cfg = loadConfig({ BIND_HOST: '0.0.0.0' }) + const hasWildcard = cfg.allowedOrigins.some((o) => o.includes('0.0.0.0')) + expect(hasWildcard).toBe(false) + }) + + it('includes injected LAN NIC IPv4', () => { + mockNetworkInterfaces.mockReturnValue({ + en0: [makeNic('192.168.1.100', false)], + }) + const cfg = loadConfig({}) + expect(cfg.allowedOrigins).toContain('http://192.168.1.100:3000') + }) + + it('includes https:// variant for injected NIC', () => { + mockNetworkInterfaces.mockReturnValue({ + en0: [makeNic('10.0.0.5', false)], + }) + const cfg = loadConfig({}) + expect(cfg.allowedOrigins).toContain('https://10.0.0.5:3000') + }) + + it('excludes internal (loopback) NICs from LAN list (127.x added separately)', () => { + mockNetworkInterfaces.mockReturnValue({ + lo0: [makeNic('127.0.0.1', true)], + }) + const cfg = loadConfig({}) + // 127.0.0.1 added via loopback constants; no duplicates beyond http+https + const count127 = cfg.allowedOrigins.filter((o) => + o.includes('127.0.0.1'), + ).length + // http and https = 2 + expect(count127).toBeLessThanOrEqual(2) + }) + + it('skips IPv6 entries on NICs', () => { + mockNetworkInterfaces.mockReturnValue({ + en0: [ + { + address: 'fe80::1', + netmask: 'ffff:ffff:ffff:ffff::', + family: 'IPv6', + mac: '00:00:00:00:00:00', + internal: false, + cidr: 'fe80::1/64', + scopeid: 0, + } as NetworkInterfaceInfo, + makeNic('192.168.1.50', false), + ], + }) + const cfg = loadConfig({}) + const hasIPv6 = cfg.allowedOrigins.some((o) => o.includes('fe80')) + expect(hasIPv6).toBe(false) + expect(cfg.allowedOrigins).toContain('http://192.168.1.50:3000') + }) + + it('merges ALLOWED_ORIGINS env var into the list', () => { + const extra = 'http://mymac.local:3000,https://mymac.local:3000' + const cfg = loadConfig({ ALLOWED_ORIGINS: extra }) + expect(cfg.allowedOrigins).toContain('http://mymac.local:3000') + expect(cfg.allowedOrigins).toContain('https://mymac.local:3000') + }) + + it('respects custom PORT when building origins', () => { + mockNetworkInterfaces.mockReturnValue({ + en0: [makeNic('172.16.0.1', false)], + }) + const cfg = loadConfig({ PORT: '4000' }) + expect(cfg.allowedOrigins).toContain('http://172.16.0.1:4000') + expect(cfg.allowedOrigins).toContain('https://172.16.0.1:4000') + }) + + it('deduplicates origins when ALLOWED_ORIGINS overlaps with derived set', () => { + const cfg = loadConfig({ + ALLOWED_ORIGINS: 'http://localhost:3000,http://localhost:3000', + }) + const localhostCount = cfg.allowedOrigins.filter( + (o) => o === 'http://localhost:3000', + ).length + expect(localhostCount).toBe(1) + }) +}) + +// ── Immutability ────────────────────────────────────────────────────────────── +describe('loadConfig — returned object is frozen', () => { + beforeEach(() => { + mockNetworkInterfaces.mockReturnValue({}) + mockHomedir.mockReturnValue('/home/testuser') + }) + + it('result is Object.frozen', () => { + const cfg = loadConfig({}) + expect(Object.isFrozen(cfg)).toBe(true) + }) + + it('allowedOrigins array is frozen', () => { + const cfg = loadConfig({}) + expect(Object.isFrozen(cfg.allowedOrigins)).toBe(true) + }) +}) diff --git a/test/helpers/.gitkeep b/test/helpers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/test/helpers/mock-pty.test.ts b/test/helpers/mock-pty.test.ts new file mode 100644 index 0000000..4bed77d --- /dev/null +++ b/test/helpers/mock-pty.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect } from 'vitest' +import { createMockPty } from './mock-pty' + +describe('createMockPty', () => { + it('emitData triggers all onData listeners', () => { + const pty = createMockPty() + const listener1Results: string[] = [] + const listener2Results: string[] = [] + + pty.onData((data) => { + listener1Results.push(data) + }) + pty.onData((data) => { + listener2Results.push(data) + }) + + pty.emitData('hello') + pty.emitData(' world') + + expect(listener1Results).toEqual(['hello', ' world']) + expect(listener2Results).toEqual(['hello', ' world']) + }) + + it('onData returns a disposable that unsubscribes', () => { + const pty = createMockPty() + const results: string[] = [] + + const disposable = pty.onData((data) => { + results.push(data) + }) + + pty.emitData('first') + disposable.dispose() + pty.emitData('second') + + expect(results).toEqual(['first']) + }) + + it('records write calls', () => { + const pty = createMockPty() + + pty.write('input 1') + pty.write('input 2') + + expect(pty.writes).toEqual(['input 1', 'input 2']) + }) + + it('records resize calls', () => { + const pty = createMockPty() + + pty.resize(100, 50) + pty.resize(120, 60) + + expect(pty.resizes).toEqual([ + { cols: 100, rows: 50 }, + { cols: 120, rows: 60 }, + ]) + }) + + it('emitExit triggers onExit listeners', () => { + const pty = createMockPty() + const results: Array<{ exitCode: number; signal?: number }> = [] + + pty.onExit((event) => { + results.push(event) + }) + + pty.emitExit(0) + pty.emitExit(1, 9) + + expect(results).toEqual([ + { exitCode: 0 }, + { exitCode: 1, signal: 9 }, + ]) + }) + + it('onExit returns a disposable that unsubscribes', () => { + const pty = createMockPty() + const results: Array<{ exitCode: number; signal?: number }> = [] + + const disposable = pty.onExit((event) => { + results.push(event) + }) + + pty.emitExit(0) + disposable.dispose() + pty.emitExit(1) + + expect(results).toEqual([{ exitCode: 0 }]) + }) + + it('kill marks killed flag', () => { + const pty = createMockPty() + + expect(pty.killed).toBe(false) + pty.kill() + expect(pty.killed).toBe(true) + }) + + it('kill can accept an optional signal', () => { + const pty = createMockPty() + + pty.kill('SIGTERM') + expect(pty.killed).toBe(true) + }) + + it('has IPty interface properties', () => { + const pty = createMockPty() + + expect(typeof pty.pid).toBe('number') + expect(typeof pty.cols).toBe('number') + expect(typeof pty.rows).toBe('number') + expect(typeof pty.onData).toBe('function') + expect(typeof pty.onExit).toBe('function') + expect(typeof pty.write).toBe('function') + expect(typeof pty.resize).toBe('function') + expect(typeof pty.kill).toBe('function') + }) +}) diff --git a/test/helpers/mock-pty.ts b/test/helpers/mock-pty.ts new file mode 100644 index 0000000..7ea27de --- /dev/null +++ b/test/helpers/mock-pty.ts @@ -0,0 +1,123 @@ +import { IPty, PtyEvent, IDisposable } from '../../src/types' + +/** + * Mock IPty for testing. + * + * Implements the full IPty interface with manual trigger methods + * (emitData, emitExit) and recording of calls (writes, resizes, killed). + * + * All properties are mutable for testing convenience. + */ +export interface MockIPty extends IPty { + /** Recorded write() calls. */ + writes: string[] + /** Recorded resize() calls. */ + resizes: Array<{ cols: number; rows: number }> + /** Whether kill() was called. */ + killed: boolean + /** Manually trigger onData listeners. */ + emitData(data: string): void + /** Manually trigger onExit listeners. */ + emitExit(exitCode: number, signal?: number): void +} + +/** + * Create a mock PTY for testing. + * + * pid, cols, rows are fixed but can be overridden via options for specific tests. + */ +export function createMockPty(options?: { + pid?: number + cols?: number + rows?: number +}): MockIPty { + const pid = options?.pid ?? 12345 + let cols = options?.cols ?? 80 + let rows = options?.rows ?? 24 + + const writes: string[] = [] + const resizes: Array<{ cols: number; rows: number }> = [] + let killed = false + + // Event listeners: store arrays of listeners for each event type + const dataListeners: Array<(data: string) => void> = [] + const exitListeners: Array<(event: { exitCode: number; signal?: number }) => void> = [] + + const onData: PtyEvent = (listener: (e: string) => void): IDisposable => { + dataListeners.push(listener) + return { + dispose() { + const index = dataListeners.indexOf(listener) + if (index !== -1) { + dataListeners.splice(index, 1) + } + }, + } + } + + const onExit: PtyEvent<{ exitCode: number; signal?: number }> = ( + listener: (e: { exitCode: number; signal?: number }) => void, + ): IDisposable => { + exitListeners.push(listener) + return { + dispose() { + const index = exitListeners.indexOf(listener) + if (index !== -1) { + exitListeners.splice(index, 1) + } + }, + } + } + + const write = (data: string): void => { + writes.push(data) + } + + const resize = (newCols: number, newRows: number): void => { + cols = newCols + rows = newRows + resizes.push({ cols: newCols, rows: newRows }) + } + + const kill = (signal?: string): void => { + killed = true + } + + const emitData = (data: string): void => { + for (const listener of dataListeners) { + listener(data) + } + } + + const emitExit = (exitCode: number, signal?: number): void => { + const event: { exitCode: number; signal?: number } = { exitCode } + if (signal !== undefined) { + event.signal = signal + } + for (const listener of exitListeners) { + listener(event) + } + } + + return { + pid, + get cols() { + return cols + }, + get rows() { + return rows + }, + onData, + onExit, + write, + resize, + kill, + writes, + resizes, + get killed() { + return killed + }, + emitData, + emitExit, + } +} diff --git a/test/origin.test.ts b/test/origin.test.ts new file mode 100644 index 0000000..9792cf8 --- /dev/null +++ b/test/origin.test.ts @@ -0,0 +1,78 @@ +/** + * test/origin.test.ts — TDD for src/http/origin.ts (T7) + * + * Covers ARCHITECTURE §3.3 and TECH_DOC §7 (CSWSH defence): + * - whitelist hit → allow + * - wrong hostname → deny + * - wrong port → deny + * - undefined Origin (non-browser client) → deny + * - evil.com → deny + */ + +import { describe, it, expect } from 'vitest' +import { isOriginAllowed } from '../src/http/origin.js' + +const ALLOWED: readonly string[] = [ + 'http://localhost:3000', + 'http://192.168.1.42:3000', + 'http://my-host.local:3000', +] + +describe('isOriginAllowed', () => { + // ── whitelist hits ──────────────────────────────────────────────── + it('allows an exact localhost origin', () => { + expect(isOriginAllowed('http://localhost:3000', ALLOWED)).toBe(true) + }) + + it('allows an exact LAN IP origin', () => { + expect(isOriginAllowed('http://192.168.1.42:3000', ALLOWED)).toBe(true) + }) + + it('allows an exact hostname.local origin', () => { + expect(isOriginAllowed('http://my-host.local:3000', ALLOWED)).toBe(true) + }) + + // ── hostname mismatch ───────────────────────────────────────────── + it('rejects when the hostname does not match (evil.com)', () => { + expect(isOriginAllowed('http://evil.com:3000', ALLOWED)).toBe(false) + }) + + it('rejects a subdomain of an allowed hostname', () => { + expect(isOriginAllowed('http://sub.localhost:3000', ALLOWED)).toBe(false) + }) + + it('rejects a hostname that merely contains an allowed hostname', () => { + // "evillocalhost:3000" should NOT match "localhost:3000" + expect(isOriginAllowed('http://evillocalhost:3000', ALLOWED)).toBe(false) + }) + + // ── port mismatch ───────────────────────────────────────────────── + it('rejects when the port does not match (correct host, wrong port)', () => { + expect(isOriginAllowed('http://localhost:9999', ALLOWED)).toBe(false) + }) + + it('rejects when port is absent but allowed origin has a port', () => { + // Browser sends Origin without port only when it is the default (80/443). + // Our server runs on 3000, so no-port origins must not sneak in. + expect(isOriginAllowed('http://localhost', ALLOWED)).toBe(false) + }) + + // ── undefined Origin (non-browser clients: curl, scripts) ───────── + it('rejects undefined Origin by default', () => { + expect(isOriginAllowed(undefined, ALLOWED)).toBe(false) + }) + + // ── empty / malformed origins ───────────────────────────────────── + it('rejects an empty string origin', () => { + expect(isOriginAllowed('', ALLOWED)).toBe(false) + }) + + it('rejects a completely different scheme (https vs http in allowed)', () => { + // Allowed list uses http://; https:// should not silently match. + expect(isOriginAllowed('https://localhost:3000', ALLOWED)).toBe(false) + }) + + it('rejects when allowed list is empty', () => { + expect(isOriginAllowed('http://localhost:3000', [])).toBe(false) + }) +}) diff --git a/test/protocol.test.ts b/test/protocol.test.ts new file mode 100644 index 0000000..3ff3d0d --- /dev/null +++ b/test/protocol.test.ts @@ -0,0 +1,344 @@ +/** + * test/protocol.test.ts — TDD tests for src/protocol.ts (T5) + * + * Coverage: + * - SESSION_ID_RE: accepts valid UUID v4, rejects "abc123" and other non-UUIDs + * - parseClientMessage: all valid paths + every invalid branch (never throws) + * - serialize: all three ServerMessage types + */ + +import { describe, it, expect } from 'vitest' +import { SESSION_ID_RE, parseClientMessage, serialize } from '../src/protocol' + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const VALID_UUID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' + +// ─── SESSION_ID_RE ───────────────────────────────────────────────────────────── + +describe('SESSION_ID_RE', () => { + it('accepts a valid UUID v4', () => { + expect(SESSION_ID_RE.test(VALID_UUID)).toBe(true) + }) + + it('accepts UUID v4 with uppercase hex digits', () => { + expect(SESSION_ID_RE.test('F47AC10B-58CC-4372-A567-0E02B2C3D479')).toBe(true) + }) + + it('accepts another valid UUID v4 (variant bits 8, version 4)', () => { + // 41d4 → version nibble = 4 ✓; 8716 → variant nibble = 8 ✓ + expect(SESSION_ID_RE.test('550e8400-e29b-41d4-8716-446655440000')).toBe(true) + }) + + it('rejects "abc123" (M7)', () => { + expect(SESSION_ID_RE.test('abc123')).toBe(false) + }) + + it('rejects empty string', () => { + expect(SESSION_ID_RE.test('')).toBe(false) + }) + + it('rejects UUID v1 (version bit = 1)', () => { + expect(SESSION_ID_RE.test('550e8400-e29b-11d4-a716-446655440000')).toBe(false) + }) + + it('rejects UUID v4 with wrong variant (not 8/9/a/b)', () => { + // variant bits should be 10xx → 8,9,a,b; 'c' is 1100 = wrong + expect(SESSION_ID_RE.test('f47ac10b-58cc-4372-c567-0e02b2c3d479')).toBe(false) + }) +}) + +// ─── parseClientMessage — valid paths ───────────────────────────────────────── + +describe('parseClientMessage — valid messages', () => { + it('parses a valid attach message with null sessionId', () => { + const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: null })) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.message).toEqual({ type: 'attach', sessionId: null }) + } + }) + + it('parses a valid attach message with a UUID v4 sessionId', () => { + const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: VALID_UUID })) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.message).toEqual({ type: 'attach', sessionId: VALID_UUID }) + } + }) + + it('parses a valid input message', () => { + const result = parseClientMessage(JSON.stringify({ type: 'input', data: 'ls -la\r' })) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.message).toEqual({ type: 'input', data: 'ls -la\r' }) + } + }) + + it('passes input.data through verbatim (raw keyboard bytes)', () => { + const raw = '\x1b[A\x03\r\t\x1b[Z' + const result = parseClientMessage(JSON.stringify({ type: 'input', data: raw })) + expect(result.ok).toBe(true) + if (result.ok && result.message.type === 'input') { + expect(result.message.data).toBe(raw) + } + }) + + it('parses a valid resize message with boundary values (1)', () => { + const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 1, rows: 1 })) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.message).toEqual({ type: 'resize', cols: 1, rows: 1 }) + } + }) + + it('parses a valid resize message with boundary values (1000)', () => { + const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 1000, rows: 1000 })) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.message).toEqual({ type: 'resize', cols: 1000, rows: 1000 }) + } + }) + + it('parses a valid resize message with typical values', () => { + const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 120, rows: 40 })) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.message).toEqual({ type: 'resize', cols: 120, rows: 40 }) + } + }) +}) + +// ─── parseClientMessage — invalid / error paths ─────────────────────────────── + +describe('parseClientMessage — invalid input (ok=false, never throws)', () => { + // Bad JSON + it('returns ok=false for bad JSON', () => { + const result = parseClientMessage('not json at all') + expect(result.ok).toBe(false) + }) + + it('returns ok=false for empty string', () => { + const result = parseClientMessage('') + expect(result.ok).toBe(false) + }) + + it('returns ok=false for JSON null', () => { + const result = parseClientMessage('null') + expect(result.ok).toBe(false) + }) + + it('returns ok=false for JSON array', () => { + const result = parseClientMessage('[]') + expect(result.ok).toBe(false) + }) + + it('returns ok=false for JSON number', () => { + const result = parseClientMessage('42') + expect(result.ok).toBe(false) + }) + + // Unknown type + it('returns ok=false for unknown type "ping"', () => { + const result = parseClientMessage(JSON.stringify({ type: 'ping' })) + expect(result.ok).toBe(false) + }) + + it('returns ok=false for missing type field', () => { + const result = parseClientMessage(JSON.stringify({ data: 'hello' })) + expect(result.ok).toBe(false) + }) + + it('returns ok=false for type=number', () => { + const result = parseClientMessage(JSON.stringify({ type: 42 })) + expect(result.ok).toBe(false) + }) + + // resize: cols out of range + it('returns ok=false for resize cols=0 (below range)', () => { + const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 0, rows: 40 })) + expect(result.ok).toBe(false) + }) + + it('returns ok=false for resize cols=1001 (above range)', () => { + const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 1001, rows: 40 })) + expect(result.ok).toBe(false) + }) + + it('returns ok=false for resize rows=0 (below range)', () => { + const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 80, rows: 0 })) + expect(result.ok).toBe(false) + }) + + it('returns ok=false for resize rows=1001 (above range)', () => { + const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 80, rows: 1001 })) + expect(result.ok).toBe(false) + }) + + // resize: non-integer cols/rows + it('returns ok=false for resize cols=80.5 (non-integer)', () => { + const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 80.5, rows: 40 })) + expect(result.ok).toBe(false) + }) + + it('returns ok=false for resize rows=24.9 (non-integer)', () => { + const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 80, rows: 24.9 })) + expect(result.ok).toBe(false) + }) + + it('returns ok=false for resize cols="80" (string, not number)', () => { + const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: '80', rows: 40 })) + expect(result.ok).toBe(false) + }) + + it('returns ok=false for resize missing cols', () => { + const result = parseClientMessage(JSON.stringify({ type: 'resize', rows: 40 })) + expect(result.ok).toBe(false) + }) + + it('returns ok=false for resize missing rows', () => { + const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 80 })) + expect(result.ok).toBe(false) + }) + + // input.data non-string + it('returns ok=false when input.data is a number', () => { + const result = parseClientMessage(JSON.stringify({ type: 'input', data: 42 })) + expect(result.ok).toBe(false) + }) + + it('returns ok=false when input.data is null', () => { + const result = parseClientMessage(JSON.stringify({ type: 'input', data: null })) + expect(result.ok).toBe(false) + }) + + it('returns ok=false when input.data is missing', () => { + const result = parseClientMessage(JSON.stringify({ type: 'input' })) + expect(result.ok).toBe(false) + }) + + it('returns ok=false when input.data is an object', () => { + const result = parseClientMessage(JSON.stringify({ type: 'input', data: {} })) + expect(result.ok).toBe(false) + }) + + // attach.sessionId: "abc123" rejected (M7) + it('returns ok=false for attach.sessionId="abc123" (M7)', () => { + const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 'abc123' })) + expect(result.ok).toBe(false) + }) + + it('returns ok=false for attach.sessionId="" (empty string, not null or UUID)', () => { + const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: '' })) + expect(result.ok).toBe(false) + }) + + it('returns ok=false for attach.sessionId=42 (not string or null)', () => { + const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 42 })) + expect(result.ok).toBe(false) + }) + + it('returns ok=false for attach.sessionId=undefined (missing field)', () => { + // JSON.stringify omits undefined keys → { type: 'attach' } — no sessionId property + const result = parseClientMessage(JSON.stringify({ type: 'attach' })) + expect(result.ok).toBe(false) + }) + + // attach.sessionId: valid UUID passes (M7 positive case) + it('returns ok=true for attach.sessionId = valid UUID v4 (M7 positive)', () => { + const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: VALID_UUID })) + expect(result.ok).toBe(true) + }) +}) + +// ─── parseClientMessage — fuzzing: must never throw ─────────────────────────── + +describe('parseClientMessage — fuzzy inputs never throw', () => { + const fuzzInputs: unknown[] = [ + undefined, + null, + 0, + true, + {}, + [], + '\x00\x01\xFF', + '{"type":"attach","sessionId":{"$ne":null}}', + '{"type":"resize","cols":null,"rows":null}', + '{"type":"input","data":["a","b"]}', + '{"type":"attach","sessionId":' + 'x'.repeat(10000) + '}', + // valid JSON but with extra fields + '{"type":"resize","cols":80,"rows":40,"extra":"ignored"}', + ] + + for (const input of fuzzInputs) { + it(`does not throw for input: ${JSON.stringify(input)?.slice(0, 60) ?? String(input)}`, () => { + expect(() => parseClientMessage(input as string)).not.toThrow() + }) + } +}) + +// ─── serialize ──────────────────────────────────────────────────────────────── + +describe('serialize', () => { + it('serializes "attached" message', () => { + const msg = { type: 'attached' as const, sessionId: VALID_UUID } + const json = serialize(msg) + expect(JSON.parse(json)).toEqual(msg) + }) + + it('serializes "output" message', () => { + const msg = { type: 'output' as const, data: '\x1b[1;32mHello\x1b[0m' } + const json = serialize(msg) + expect(JSON.parse(json)).toEqual(msg) + }) + + it('serializes "exit" message with code only', () => { + const msg = { type: 'exit' as const, code: 0 } + const json = serialize(msg) + expect(JSON.parse(json)).toEqual(msg) + }) + + it('serializes "exit" message with code and reason', () => { + const msg = { type: 'exit' as const, code: -1, reason: 'spawn failed: /bin/badshell' } + const json = serialize(msg) + expect(JSON.parse(json)).toEqual(msg) + }) + + it('serialize output is a string', () => { + expect(typeof serialize({ type: 'output', data: 'x' })).toBe('string') + }) + + it('round-trip: parseClientMessage(JSON) produces the right attach message', () => { + const original: { type: 'attach'; sessionId: string | null } = { + type: 'attach', + sessionId: VALID_UUID, + } + const result = parseClientMessage(JSON.stringify(original)) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.message).toEqual(original) + } + }) + + it('round-trip: parseClientMessage(JSON) produces the right input message', () => { + const original: { type: 'input'; data: string } = { type: 'input', data: 'echo hi\r' } + const result = parseClientMessage(JSON.stringify(original)) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.message).toEqual(original) + } + }) + + it('round-trip: parseClientMessage(JSON) produces the right resize message', () => { + const original: { type: 'resize'; cols: number; rows: number } = { + type: 'resize', + cols: 80, + rows: 24, + } + const result = parseClientMessage(JSON.stringify(original)) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.message).toEqual(original) + } + }) +}) diff --git a/test/ring-buffer.test.ts b/test/ring-buffer.test.ts new file mode 100644 index 0000000..7c23b4b --- /dev/null +++ b/test/ring-buffer.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from 'vitest'; +import { createRingBuffer } from '../src/session/ring-buffer.js'; +import type { RingBuffer } from '../src/types.js'; + +/** + * T6 / M2 tests — ring-buffer.ts + * + * Invariants under test (ARCHITECTURE §3.4 / types.ts impl anchor): + * - capacity measured in REAL UTF-8 bytes (Buffer.byteLength), not string.length; + * - over-capacity evicts the WHOLE oldest chunk (append boundary), never splitting a + * multi-byte UTF-8 codepoint or an ANSI escape sequence mid-way; + * - snapshot() prepends a soft-reset "\x1b[0m" then the current buffer. + */ + +const SOFT_RESET = '\x1b[0m'; + +/** snapshot() minus the soft-reset prefix = the live buffer content. */ +function body(rb: RingBuffer): string { + const snap = rb.snapshot(); + expect(snap.startsWith(SOFT_RESET)).toBe(true); + return snap.slice(SOFT_RESET.length); +} + +describe('createRingBuffer', () => { + describe('snapshot()', () => { + it('prepends a soft-reset escape even when empty', () => { + const rb = createRingBuffer(1024); + expect(rb.snapshot()).toBe(SOFT_RESET); + }); + + it('prepends soft-reset then the current buffer', () => { + const rb = createRingBuffer(1024); + rb.append('hello'); + expect(rb.snapshot()).toBe(SOFT_RESET + 'hello'); + }); + }); + + describe('within capacity', () => { + it('replays everything appended, in order', () => { + const rb = createRingBuffer(1024); + rb.append('a'); + rb.append('b'); + rb.append('c'); + expect(body(rb)).toBe('abc'); + }); + + it('keeps full content when total exactly equals capacity', () => { + const rb = createRingBuffer(6); // "abcdef" = 6 bytes + rb.append('abc'); + rb.append('def'); + expect(body(rb)).toBe('abcdef'); + }); + }); + + describe('over capacity — evict oldest whole chunk', () => { + it('drops the oldest chunk(s) when capacity is exceeded', () => { + const rb = createRingBuffer(6); + rb.append('aaa'); // 3 bytes + rb.append('bbb'); // 3 bytes -> total 6, fits + rb.append('ccc'); // 3 bytes -> total 9 > 6, evict oldest 'aaa' + expect(body(rb)).toBe('bbbccc'); + }); + + it('evicts multiple oldest chunks if needed to fit a large new chunk', () => { + const rb = createRingBuffer(5); + rb.append('11'); // 2 + rb.append('22'); // 2 -> total 4 + rb.append('33333'); // 5 bytes; must evict '11' and '22' to fit + expect(body(rb)).toBe('33333'); + }); + + it('evicts on chunk boundaries — never a partial chunk', () => { + const rb = createRingBuffer(4); + rb.append('xxx'); // 3 + rb.append('yy'); // 2 -> total 5 > 4, evict whole 'xxx', leaving 'yy' + // 'xxx' is removed entirely, not trimmed to fit exactly 4 bytes. + expect(body(rb)).toBe('yy'); + }); + }); + + describe('multi-byte UTF-8 is never split mid-codepoint', () => { + it('does not corrupt Chinese characters on eviction', () => { + // '中' is 3 UTF-8 bytes. + const oneChar = '中'; + expect(Buffer.byteLength(oneChar, 'utf8')).toBe(3); + + // Capacity holds ~3 of these chars (9 bytes). Append many in separate chunks. + const rb = createRingBuffer(9); + for (let i = 0; i < 10; i++) { + rb.append('中'); // each chunk = one 3-byte codepoint + } + const out = body(rb); + + // No replacement char (U+FFFD) => nothing was split into invalid UTF-8. + expect(out).not.toContain('�'); + // Every char is a clean '中'. + expect([...out].every((c) => c === '中')).toBe(true); + // Byte budget respected. + expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(9); + }); + + it('round-trips through Buffer without producing replacement chars', () => { + const rb = createRingBuffer(12); // 4 chars * 3 bytes + const chars = ['日', '本', '語', '中', '文', '字']; + for (const c of chars) rb.append(c); + const out = body(rb); + // Re-encode/decode: if any codepoint were split, we'd see U+FFFD. + const reDecoded = Buffer.from(out, 'utf8').toString('utf8'); + expect(reDecoded).toBe(out); + expect(out).not.toContain('�'); + }); + }); + + describe('ANSI escape sequences are never cut mid-sequence', () => { + it('keeps escape sequences whole when each is its own chunk', () => { + const rb = createRingBuffer(20); + // Each chunk is a complete colored token. + rb.append('\x1b[1;32mA\x1b[0m'); // green A + reset + rb.append('\x1b[1;31mB\x1b[0m'); // red B + reset + rb.append('\x1b[1;34mC\x1b[0m'); // blue C + reset + const out = body(rb); + + // The oldest chunk(s) may be evicted, but whichever remain are intact: + // every ESC '[' begins a complete sequence ending in 'm'. + const escIndexes = [...out.matchAll(/\x1b/g)].map((m) => m.index!); + for (const idx of escIndexes) { + const seq = out.slice(idx); + expect(seq).toMatch(/^\x1b\[[0-9;]*m/); + } + // No dangling ESC at the very end (would mean a cut sequence). + expect(out.endsWith('\x1b')).toBe(false); + }); + + it('does not slice a single ANSI token in half on eviction', () => { + const rb = createRingBuffer(8); + const token = '\x1b[1;32m'; // 7 bytes, a complete SGR sequence + rb.append(token); + rb.append(token); // total 14 > 8 -> evict whole first token, keep second intact + const out = body(rb); + expect(out).toBe(token); // exactly one whole token, not a fragment + expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(8); + }); + }); + + describe('byte accounting (not string.length)', () => { + it('uses real UTF-8 byte count for capacity, not UTF-16 code units', () => { + // '😀' is 2 UTF-16 code units (string.length === 2) but 4 UTF-8 bytes. + const emoji = '😀'; + expect(emoji.length).toBe(2); + expect(Buffer.byteLength(emoji, 'utf8')).toBe(4); + + // Capacity 8 bytes = exactly two emoji. + const rb = createRingBuffer(8); + rb.append(emoji); // 4 bytes + rb.append(emoji); // 8 bytes total, fits + rb.append(emoji); // 12 > 8 -> evict oldest, leaving two emoji (8 bytes) + const out = body(rb); + + expect(Buffer.byteLength(out, 'utf8')).toBe(8); + expect([...out].length).toBe(2); + expect(out).not.toContain('�'); + }); + + it('counts multi-byte content by bytes when deciding eviction', () => { + // If accounting used string.length, '中中' (length 2) would seem to fit a + // capacity-of-3 buffer; by bytes it's 6 and must not both stay. + const rb = createRingBuffer(3); + rb.append('中'); // 3 bytes, fits exactly + rb.append('中'); // 6 > 3 -> evict the first, keep the second + const out = body(rb); + expect(out).toBe('中'); + expect(Buffer.byteLength(out, 'utf8')).toBe(3); + }); + }); + + describe('edge cases', () => { + it('ignores empty appends', () => { + const rb = createRingBuffer(8); + rb.append(''); + rb.append('ab'); + rb.append(''); + expect(body(rb)).toBe('ab'); + }); + + it('a single chunk larger than capacity is kept as the sole chunk (cannot split)', () => { + // Eviction is on whole-chunk boundaries; a lone oversized chunk has nothing + // older to drop, so it stays intact rather than being sliced (M2: never split). + const rb = createRingBuffer(4); + rb.append('abcdefgh'); // 8 bytes > 4 + expect(body(rb)).toBe('abcdefgh'); + }); + }); +});