// ARP as Java FILIUS runs it. tools/java-oracle's ArpRun sent to addresses no // card has on arp-routed's network in Java FILIUS and wrote every frame each // card saw and when each call started and ended (arp-java-.java.json). // The engine makes the same calls: the frames must come in the same order, // with the same content, at about the same distance from each other // (javaRun.ts); the calls must end at about the same times and return the same. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { icmpMessage, type NetworkRuntimeEngine } from '../src/index.js'; import { expectSameCaptures, withClientPortText, GAP_TOLERANCE_MILLISECONDS, gaps, javaFrames, watchedEngine, type JavaFrame, type JavaTables } from './javaRun.js'; import type { Scenario } from './scenario.js'; const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios'); const HOST = '00000000-0000-0000-0000-000000000301'; interface JavaRun extends JavaTables { frames: Record; events: [number, string][]; } /** A ping from HOST in Java's words: the ICMP answer's type and code, or null when none came in time. */ function ping(engine: NetworkRuntimeEngine, target: string, sequenceNumber = 1): string { const result = engine.sendIcmpEcho(HOST, target, { sequenceNumber }); if (result.type === 'icmpError' && result.kind === 'destinationHostUnreachable') return 'ICMP 3/1'; const answered = (engine.state.icmpObservationsByNodeId.get(HOST) ?? []).some( (observation) => observation.message.kind === 'echoReply' && observation.message.sequenceNumber === sequenceNumber, ); return answered ? 'ICMP 0/0' : 'null'; } /** Records a call as ArpRun does: when it started, when it ended and what it returned. */ type Call = (label: string, run: () => string) => void; function udpSend(engine: NetworkRuntimeEngine, target: string, payload: string): string { const socket = engine.bindUdpSocket(HOST, { remoteIPAddress: target, remotePort: 9 })!; engine.sendUdp(socket, new TextEncoder().encode(payload)); return ''; } const STORIES: Record void> = { pingLan: (engine, call) => call('sendProbe 10.0.0.9', () => ping(engine, '10.0.0.9')), pingRouted: (engine, call) => call('sendProbe 20.0.0.9', () => ping(engine, '20.0.0.9')), udpTwice: (engine, call) => { call('senden eins', () => udpSend(engine, '10.0.0.9', 'eins')); call('senden zwei', () => udpSend(engine, '10.0.0.9', 'zwei')); }, udpBehind: (engine, call) => { call('senden verloren', () => udpSend(engine, '10.0.0.9', 'verloren')); call('senden dahinter', () => udpSend(engine, '20.0.0.2', 'dahinter')); }, routedUdpBehind: (engine, call) => { call('senden verloren', () => udpSend(engine, '20.0.0.9', 'verloren')); call('senden dahinter', () => udpSend(engine, '20.0.0.2', 'dahinter')); }, routedPingBesideUdp: (engine, call) => { call('sendProbe 20.0.0.2', () => ping(engine, '20.0.0.2')); call('senden verloren', () => udpSend(engine, '20.0.0.9', 'verloren')); engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 100 }); call('sendProbe 20.0.0.2', () => ping(engine, '20.0.0.2', 2)); }, routedPingBehindPing: (engine, call) => { // Java's sendeICMP sends in the caller's thread, which waits for ARP. call('sendeICMP 20.0.0.9', () => { engine.resolveMacAddress(HOST, '10.0.0.1'); engine.sendIPv4Packet(HOST, { identity: engine.allocatePacketIdentity(), senderIPAddress: '10.0.0.2', receiverIPAddress: '20.0.0.9', timeToLive: 64, protocol: 'icmp', payload: { kind: 'icmp', message: icmpMessage('echoRequest', { identifier: 2, sequenceNumber: 2, data: new Uint8Array() }) }, }); return ''; }); call('sendProbe 20.0.0.2', () => ping(engine, '20.0.0.2')); }, }; /** The client's free port as "client": Java draws it at random, the engine from its seed. */ function withClientPort(frames: readonly T[]): T[] { return frames.map((frame) => ({ ...frame, text: frame.text.replace(/:(\d{5})\b/g, (whole, port: string) => (Number(port) >= 49_152 ? ':client' : whole)) })); } /** Java's events without its answer's sender ("from …"), which the engine's results do not name. */ function javaEvent(event: string): string { return event.replace(/ from .*$/, ''); } describe('ARP as Java FILIUS runs it', () => { const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'arp-routed.scenario.json'), 'utf8')) as Scenario; for (const [name, story] of Object.entries(STORIES)) { it(name, () => { const java = JSON.parse(readFileSync(join(scenarioDirectory, `arp-java-${name}.java.json`), 'utf8')) as JavaRun; const { engine, frames } = watchedEngine(scenario); engine.handle({ type: 'advance', toMilliseconds: 500 }); const events: [number, string][] = []; story(engine, (label, run) => { events.push([engine.state.currentTimeMilliseconds, `${label} called`]); const result = run(); events.push([engine.state.currentTimeMilliseconds, `${label} returned ${result}`]); }); engine.handle({ type: 'advance', toMilliseconds: 16_000 }); const expected = gaps(withClientPort(javaFrames(java.frames))); const actual = gaps(withClientPort(frames())); expect(events.map(([, event]) => event)).toEqual(java.events.map(([, event]) => javaEvent(event))); for (const [index, [time]] of events.entries()) { expect(Math.abs(time - events[0]![0] - (java.events[index]![0] - java.events[0]![0])), `time of ${events[index]![1]}`).toBeLessThanOrEqual( GAP_TOLERANCE_MILLISECONDS, ); } for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) { expect(actual[index]?.text, `frame #${index + 1}`).toBe(expected[index]?.text); expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `gap before frame #${index + 1}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS); } expectSameCaptures(engine, scenario, java.frames, { text: withClientPortText, tables: java }); }); } });