// The command line's network commands as Java FILIUS runs them. // tools/java-oracle's TerminalRun typed lines into FILIUS' command line on // the hosts of terminal-net at set times and wrote every frame each card // saw, Java's tables of them, and everything each command line told its // window (terminal-java-.java.json). The engine types the same lines // at the same times (apps/terminal.ts): each card's frames in the same // order, with the same content and rows, at about the same time; the same // texts and busy marks, at about the same time. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { NetworkRuntimeEngine, withSnapshot } from '../src/index.js'; import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, watchedEngine, withClientPortText, type JavaFrame, type JavaTables, type SeenFrame } from './javaRun.js'; import { snapshotOf, type Scenario } from './scenario.js'; const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios'); const CLIENT = '00000000-0000-0000-0000-000000000401'; const DNS = '00000000-0000-0000-0000-000000000402'; const WEB = '00000000-0000-0000-0000-000000000403'; const ECHO = '00000000-0000-0000-0000-000000000404'; const FAR = '00000000-0000-0000-0000-000000000406'; const HOSTS = [CLIENT, DNS, WEB, ECHO, FAR]; type Told = ['text' | 'busy', string]; interface JavaRun extends JavaTables { frames: Record; acts: [number, string, string][]; /** time, host, kind (call, text or busy), text */ events: [number, string, string, string][]; } const STORIES = ['pingLan', 'pingRouted', 'pingNobody', 'pingUnreachable', 'pingBroadcast', 'pingSelf', 'pingErrors', 'interrupt', 'dns', 'dnsTimeout', 'arp', 'netstat'] as const; type Lines = Record; function javaLines(java: JavaRun): Lines { const lines: Lines = Object.fromEntries(HOSTS.map((host) => [host, []])); for (const [time, host, kind, text] of java.events) { if (kind === 'call') continue; lines[host]!.push([time, [kind as Told[0], text]]); } return lines; } function engineLines(engine: NetworkRuntimeEngine): Lines { return Object.fromEntries( HOSTS.map((host) => [ host, engine.terminal(host)!.log.all.map((line): [number, Told] => [line.timeMilliseconds, line.kind === 'text' ? ['text', line.text] : ['busy', String(line.busy)]]), ]), ); } /** * A text with placeholders: a client's port; a ping's time (compared on its * own); the rows of an ARP or netstat table sorted (Java lists them in a * HashMap's order, the engine in the order learned, or made). */ function lineText(text: string): string { const table = /^(\| [^\n]*\|\n-+\n)((?:\|[^\n]*\n)*)(-+\n)?$/.exec(text); // Sorted within each protocol of netstat's, the protocols in the order they come (TCP, then UDP, in Java too). const rows = table?.[2]!.split('\n').filter(Boolean) ?? []; const protocols = [...new Set(rows.map((row) => /^\| (TCP|UDP) /.exec(row)?.[1] ?? ''))]; const protocol = (row: string) => protocols.indexOf(/^\| (TCP|UDP) /.exec(row)?.[1] ?? ''); rows.sort((lhs, rhs) => protocol(lhs) - protocol(rhs) || (lhs < rhs ? -1 : lhs > rhs ? 1 : 0)); const sorted = table === null ? text : `${table[1]}${rows.join('\n')}\n${table[3] ?? ''}`; return withClientPortText(sorted).replace(/time=\d+ms/, 'time=…ms'); } const pingTime = (text: string) => Number(/time=(\d+)ms/.exec(text)?.[1] ?? 0); /** The identifier a ping draws at random (Java: new Random()). */ const cellText = (text: string) => text.replace(/Identifier: \d+/, 'Identifier: x').replace(/identifier=\d+/, 'identifier=x'); /** Java's run with what each line told replaced where Java errs. */ function withTold(java: JavaRun, change: (host: string, text: string, time: number) => string | undefined): JavaRun { return { ...java, events: java.events.flatMap(([time, host, kind, text]): [number, string, string, string][] => { if (kind !== 'text') return [[time, host, kind, text]]; const changed = change(host, text, time); return changed === undefined ? [] : [[time, host, kind, changed]]; }), }; } /** The answers to an echo from `from` to the client, in Java's frames and rows, as from `to`. */ function withEchoReplySource(java: JavaRun, from: string, to: string, after: number): JavaRun { const frames = Object.fromEntries( Object.entries(java.frames).map(([card, list]) => [ card, list.map((frame) => (frame.icmp === 0 && frame.sourceIp === from && frame.time >= after ? { ...frame, sourceIp: to } : frame)), ]), ); const replies = (card: string) => new Set(java.frames[card]!.flatMap((frame, index) => (frame.icmp === 0 && frame.sourceIp === from && frame.time >= after ? [String(index + 1)] : []))); const rows = (tables: Record | undefined) => tables === undefined ? undefined : Object.fromEntries( Object.entries(tables).map(([card, list]) => [card, list.map((row) => (replies(card).has(row[0]!) && row[1] === from ? [row[0]!, to, ...row.slice(2)] : row))]), ); return { ...java, frames, rows: rows(java.rows), layeredRows: rows(java.layeredRows) }; } /** Java's tables with each cell changed. */ function withCells(java: JavaRun, change: (cell: string) => string): JavaRun { const tables = (list: Record | undefined) => list === undefined ? undefined : Object.fromEntries(Object.entries(list).map(([card, rows]) => [card, rows.map((row) => row.map((cell) => (cell === null ? null : change(cell))))])); return { ...java, rows: tables(java.rows), layeredRows: tables(java.layeredRows) }; } const USAGE_PING = 'ping - test connection to other computer\nUsage: ping \n the remote address as IP address or host name\n'; /** * Where Java errs: its run corrected, as Java means it. * - Java's ping names its first parameter where it means the host (-b in the * statistics of ping -b), and takes any first word for -b (ping -x pings). * - An echo's answer comes from the address asked (RFC 792: the addresses * reversed): Java answers from 127.0.0.1 when the host's own address is * asked, from the near card when a router's far address is. * - netstat on a UDP server lists its listening socket only: Java also lists * a socket for each address and port it ever answered (never closed). * - A router's network unreachable carries the echo as it would have * forwarded it, with its sender (Java's is half made: sender null). */ const JAVA_BUGS: Partial JavaRun>> = { pingBroadcast: (java) => withTold(java, (host, text, time) => { const target = host === CLIENT && time >= 7_000 ? '255.255.255.255' : '192.168.0.255'; return text.replace('--- -b ', `--- ${target} `).replace('From -b (', `From ${target} (`); }), pingErrors: (java) => { const typed = java.acts.find(([, , line]) => line.startsWith('ping -x'))![0]; const corrected = withTold(java, (_, text, time) => (time < typed ? text : undefined)); return { ...corrected, frames: Object.fromEntries(Object.entries(java.frames).map(([card, list]) => [card, list.filter((frame) => frame.time < typed)])), events: [...corrected.events.filter(([time, , kind]) => time < typed || kind !== 'busy'), [typed, CLIENT, 'text', USAGE_PING]], }; }, pingSelf: (java) => withTold(java, (_, text, time) => (time < 6_000 ? text.replace('From 127.0.0.1:', 'From 192.168.0.20:') : text)), pingRouted: (java) => { const typed = java.acts.find(([, , line]) => line === 'ping 10.0.0.1')![0]; return withEchoReplySource( withTold(java, (_, text, time) => (time >= typed ? text.replace('From 192.168.0.1:', 'From 10.0.0.1:') : text)), '192.168.0.1', '10.0.0.1', typed, ); }, pingUnreachable: (java) => withCells(java, (cell) => cell.replace('sender=null;', 'sender=192.168.0.20;')), netstat: (java) => withTold(java, (host, text) => (host === DNS ? text.replace(/\| UDP {5}\| {4}192\.168\.0\.10:53 {4}\|[^\n]*CONNECTED +\|\n/g, '') : text)), }; /** Where the frames are compared only up to a time (Java's): pingErrors, the ping Java sends for ping -x. */ const UNTIL: Partial number>> = { pingErrors: (java) => java.acts.find(([, , line]) => line.startsWith('ping -x'))![0], }; /** The hosts that answer a broadcast echo at once: which answer comes first is a race of Java's threads. */ const BROADCAST_ANSWER = /From 192\.168\.0\.(1|10|30|31): icmp_seq/; /** * pingBroadcast: the hosts answer each broadcast echo at once, and the order * of their ARP requests and answers on the cables is a race of Java's * threads. Its frames are compared second by second (each ping's), as a * set; Java's tables, which go frame by frame, are left out. */ const RACING = new Set(['pingBroadcast']); const bySecond = (frames: SeenFrame[]): SeenFrame[] => frames .map((frame) => ({ time: Math.floor((frame.time - 900) / 1_000) * 1_000 + 900, text: frame.text })) .sort((lhs, rhs) => lhs.time - rhs.time || lhs.text.localeCompare(rhs.text)); describe('the command line on the network as Java FILIUS runs it', () => { it('corrects Java only where it errs, and cuts only after the story began', () => { for (const [name, correct] of Object.entries(JAVA_BUGS)) { const java = JSON.parse(readFileSync(join(scenarioDirectory, `terminal-java-${name}.java.json`), 'utf8')) as JavaRun; expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java)); expect(UNTIL[name as (typeof STORIES)[number]]?.(java) ?? Number.POSITIVE_INFINITY, name).toBeGreaterThan(1_000); } }); // ICMPThread.waitForIcmpResponse misses its wake-up when the answer is there before it waits (a race of Java's // threads: an echo to the host itself, the host unreachable a host gives itself), and waits a whole round trip. // The engine answers at once; a recording with a missed wake-up is made again (tools/java-oracle/run.sh). it("was recorded without a missed wake-up of Java's", () => { const told = (name: string) => (JSON.parse(readFileSync(join(scenarioDirectory, `terminal-java-${name}.java.json`), 'utf8')) as JavaRun).events.filter(([, , kind]) => kind === 'text'); for (const [, , , text] of told('pingSelf')) expect(pingTime(text), `pingSelf: ${JSON.stringify(text)}`).toBeLessThan(1_000); const timeouts = told('pingNobody').filter(([, , , text]) => text.includes('Timeout!')); for (const [index, [time, , , text]] of timeouts.slice(1).entries()) { if (text.includes('icmp_seq=1 ')) continue; expect(time - timeouts[index]![0], 'pingNobody: two ARP round trips per echo').toBeLessThan(6_000); } }); const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario; for (const name of STORIES) { it(name, () => { const measured = JSON.parse(readFileSync(join(scenarioDirectory, `terminal-java-${name}.java.json`), 'utf8')) as JavaRun; const java = JAVA_BUGS[name]?.(measured) ?? measured; const snapshot = withSnapshot(snapshotOf(scenario), { language: 'de', terminalNodeIds: new Set(HOSTS), ...(name === 'netstat' ? { echoServersByNodeId: new Map([ [ECHO, { port: 55555, isActive: true }], [DNS, { port: 55555, isActive: true }], ]), simpleClientsByNodeId: new Map([[CLIENT, { targetAddress: '', targetPort: 55555 }]]), } : {}), }); const { engine } = watchedEngine(scenario, snapshot); const acts: [number, () => void][] = java.acts.map(([time, host, line]) => [ time, () => { if (line === '^C') engine.terminal(host)!.interrupt(); else if (line === 'disconnect') engine.simpleClient(CLIENT)!.disconnect(); else engine.terminal(host)!.enter(line); }, ]); if (name === 'netstat') acts.unshift([500, () => engine.simpleClient(CLIENT)!.connect('192.168.0.31', 55555)]); for (const [time, act] of acts) { engine.handle({ type: 'advance', toMilliseconds: time }); act(); } engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 5_000 }); const racing = RACING.has(name); expectSameCaptures(engine, scenario, java.frames, { text: (text) => withClientPortText(text).replace(/ID=\d+/g, 'ID=x'), ...(racing ? { normalized: bySecond, starts: { java: 0, engine: 0 } } : { tables: java }), cell: cellText, until: UNTIL[name]?.(measured), }); if (racing) { // ICMP.sendBroadcast: with TTL 1, to every card. const echoes = engine.capture.frames(scenario.nodes.find((node) => node.id === CLIENT)!.ports[0]!.id).flatMap(({ frame }) => { const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined; return packet?.payload.kind === 'icmp' && packet.payload.message.kind === 'echoRequest' ? [[frame.destinationMacAddress, packet.timeToLive]] : []; }); expect(echoes).toEqual(Array.from({ length: 8 }, () => ['FF:FF:FF:FF:FF:FF', 1])); } const expected = javaLines(java); const actual = engineLines(engine); const told = ([kind, text]: Told): Told => [kind, lineText(name === 'pingBroadcast' ? text.replace(BROADCAST_ANSWER, 'From : icmp_seq') : text)]; for (const host of HOSTS) { expect( actual[host]!.map(([, line]) => told(line)), `${host}'s lines`, ).toEqual(expected[host]!.map(([, line]) => told(line))); for (const [index, [javaTime, line]] of expected[host]!.entries()) { const [engineTime, engineLine] = actual[host]![index]!; const late = javaTime - engineTime; expect(Math.abs(late), `${host}'s line ${JSON.stringify(line[1].slice(0, 60))}: Java ${late} ms later`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS); expect(Math.abs(pingTime(line[1]) - pingTime(engineLine[1])), `${host}'s ping time in ${JSON.stringify(line[1])}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS); } } }); } }); describe('the command line on the network beyond the stories', () => { const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario; const started = () => watchedEngine(scenario, withSnapshot(snapshotOf(scenario), { language: 'de', terminalNodeIds: new Set(HOSTS) })).engine; const texts = (engine: NetworkRuntimeEngine) => engine.terminal(CLIENT)!.log.all.flatMap((line) => (line.kind === 'text' ? [line.text] : [])); const echoIdentifiers = (engine: NetworkRuntimeEngine) => engine.capture.frames(scenario.nodes.find((node) => node.id === CLIENT)!.ports[0]!.id).flatMap(({ frame }) => { const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined; return packet?.payload.kind === 'icmp' && packet.payload.message.kind === 'echoRequest' ? [packet.payload.message.identifier] : []; }); it('sends no echo for a ping whose Ctrl+C came before it began', () => { const engine = started(); engine.terminal(CLIENT)!.enter('host www.filius.de'); engine.terminal(CLIENT)!.enter('ping 192.168.0.31'); engine.terminal(CLIENT)!.interrupt(); engine.handle({ type: 'advance', toMilliseconds: 5_000 }); expect(texts(engine).slice(-2)).toEqual([ 'PING 192.168.0.31 (192.168.0.31)', '\n--- 192.168.0.31 Paketstatistik ---\n0 Paket(e) gesendet, 0 Paket(e) empfangen, 0% Paketverlust\n', ]); expect(echoIdentifiers(engine)).toEqual([]); }); it("counts no answer to another ping's echo, and waits out its second", () => { const engine = started(); engine.terminal(CLIENT)!.enter('ping 192.168.0.31'); // An echo reply with the first echo's number, but another identifier (a multiple of 23 is drawn), first in the queue. const reply = { kind: 'echoReply', identifier: 1, sequenceNumber: 1, data: new Uint8Array() } as const; const packet = { identity: 0, senderIPAddress: '192.168.0.31', receiverIPAddress: '192.168.0.20', timeToLive: 64, protocol: 'icmp', payload: { kind: 'icmp', message: reply }, } as const; const queue = engine.state.icmpObservationsByNodeId; queue.set(CLIENT, [...(queue.get(CLIENT) ?? []), { nodeId: CLIENT, packet, message: reply }]); engine.handle({ type: 'advance', toMilliseconds: 6_000 }); const lines = engine.terminal(CLIENT)!.log.all.flatMap((line) => (line.kind === 'text' ? [[line.timeMilliseconds, line.text] as const] : [])); expect(lines.map(([, text]) => text.replace(/time=\d+ms/, 'time=…ms'))).toEqual([ 'PING 192.168.0.31 (192.168.0.31)', '\nFrom 192.168.0.31: icmp_seq=2 ttl=64 time=…ms', '\nFrom 192.168.0.31: icmp_seq=3 ttl=64 time=…ms', '\nFrom 192.168.0.31: icmp_seq=4 ttl=64 time=…ms', '\n--- 192.168.0.31 Paketstatistik ---\n4 Paket(e) gesendet, 3 Paket(e) empfangen, 25% Paketverlust\n', ]); expect(lines[1]![0]).toBeGreaterThanOrEqual(1_000); }); it('goes on with the next identifier, from one drawn at random', () => { const engine = started(); engine.terminal(CLIENT)!.enter('ping 192.168.0.31'); engine.terminal(CLIENT)!.enter('ping 192.168.0.30'); engine.handle({ type: 'advance', toMilliseconds: 10_000 }); const identifiers = echoIdentifiers(engine); expect(identifiers).toHaveLength(8); expect(identifiers[0]! % 23).toBe(0); expect(identifiers[0]!).toBeLessThanOrEqual(42 * 23); expect(identifiers).toEqual([...Array(4).fill(identifiers[0]), ...Array(4).fill(identifiers[0]! + 1)]); }); /** An ICMP message put first in the client's queue, as if it had come (an answer to another echo). */ const queueEchoReply = (engine: NetworkRuntimeEngine, sequenceNumber: number) => { const reply = { kind: 'echoReply', identifier: 1, sequenceNumber, data: new Uint8Array() } as const; const packet = { identity: 0, senderIPAddress: '192.168.0.31', receiverIPAddress: '192.168.0.20', timeToLive: 64, protocol: 'icmp', payload: { kind: 'icmp', message: reply }, } as const; const queue = engine.state.icmpObservationsByNodeId; queue.set(CLIENT, [...(queue.get(CLIENT) ?? []), { nodeId: CLIENT, packet, message: reply }]); }; it('takes an echo reply with another number for a timeout, and sends the next echo at once', () => { const engine = started(); engine.terminal(CLIENT)!.enter('ping 192.168.0.31'); queueEchoReply(engine, 9); engine.handle({ type: 'advance', toMilliseconds: 6_000 }); const lines = engine.terminal(CLIENT)!.log.all.flatMap((line) => (line.kind === 'text' ? [[line.timeMilliseconds, line.text] as const] : [])); expect(lines[1]![1]).toBe('\nFrom 192.168.0.31 (192.168.0.31): icmp_seq=1 -- Timeout!'); // The next echo goes at once, and the late answer to the first comes first: a timeout too. expect(lines[2]![1]).toBe('\nFrom 192.168.0.31 (192.168.0.31): icmp_seq=2 -- Timeout!'); expect(lines[2]![0] - lines[1]![0]).toBeLessThan(500); }); it('takes the first ICMP message that came, not the last', () => { const engine = started(); engine.terminal(CLIENT)!.enter('ping 192.168.0.99'); // First the answer to another echo, then (when ARP gives up) the host unreachable the client gives itself. queueEchoReply(engine, 1); engine.handle({ type: 'advance', toMilliseconds: 30_000 }); expect(texts(engine).slice(1)).toEqual([ '\nFrom 192.168.0.99 (192.168.0.99): icmp_seq=2 -- Timeout!', '\nFrom 192.168.0.99 (192.168.0.99): icmp_seq=3 -- Timeout!', '\nFrom 192.168.0.99 (192.168.0.99): icmp_seq=4 -- Timeout!', '\n--- 192.168.0.99 Paketstatistik ---\n4 Paket(e) gesendet, 0 Paket(e) empfangen, 100% Paketverlust\n', ]); }); it('rounds the loss (Math.round)', () => { const engine = started(); engine.terminal(CLIENT)!.enter('ping 192.168.0.31'); queueEchoReply(engine, 1); engine.handle({ type: 'advance', toMilliseconds: 1_000 }); queueEchoReply(engine, 2); engine.handle({ type: 'advance', toMilliseconds: 2_500 }); engine.terminal(CLIENT)!.interrupt(); engine.handle({ type: 'advance', toMilliseconds: 6_000 }); expect(texts(engine).at(-1)).toBe('\n--- 192.168.0.31 Paketstatistik ---\n3 Paket(e) gesendet, 1 Paket(e) empfangen, 67% Paketverlust\n'); }); it("refuses the broadcast of the route's network, whatever its mask", () => { const wide = { ...scenario, devices: { ...scenario.devices, [CLIENT]: { ...scenario.devices![CLIENT]!, mask: '255.255.0.0' } } } as Scenario; const engine = watchedEngine(wide, withSnapshot(snapshotOf(wide), { language: 'de', terminalNodeIds: new Set([CLIENT]) })).engine; engine.terminal(CLIENT)!.enter('ping 192.168.255.255'); expect(texts(engine)).toEqual(['Ping zu Broadcast-Adressen wird nicht unterstützt.']); }); it('draws the first identifier at random', () => { const drawn = new Set(Array.from({ length: 10 }, (_, seed) => new NetworkRuntimeEngine(seed).drawIcmpIdentifier())); expect(drawn.size).toBeGreaterThan(1); }); it('routes by the first of rows with the same mask (determineRouteFromStaticRoutingTable)', () => { const snapshot = withSnapshot(snapshotOf(scenario), { manualRoutesByNodeId: new Map([[CLIENT, [{ destinationNetwork: '192.168.0.0', subnetMask: '255.255.255.0', gateway: '192.168.0.99', interfaceIPAddress: '192.168.0.20' }]]]), }); const engine = watchedEngine(scenario, snapshot).engine; const rows = engine.forwardingTable(CLIENT).filter((row) => row.destinationNetwork === '192.168.0.0' && row.subnetMask === '255.255.255.0'); expect(rows).toHaveLength(2); expect(engine.routeRow(CLIENT, '192.168.0.31')).toEqual(rows[0]); }); });