Files
julian.kiedaischandClaude Opus 5.5 21a404693c feat(core): the packet capture's table after Java's Lauscher, measured against Java's own tables
captureRows.ts builds Java's rows per frame (Lauscher.datenVorbereiten):
Ethernet; ARP, ICMP or IP; TCP or UDP; the data with Java's protocol
recognition; with addresses and ports or ports only. The comment stays
structured; captureCommentText says it with given words and numbers.

ScenarioNetwork writes Java's finished tables per card (Lauscher.getDaten,
German locale); all runtime runs are recorded again, NatRun has ttlOut.
expectSameCaptures compares both views for every frame. Fixed where the
engine differed: an ARP request's target MAC is the broadcast address,
an ICMP error's identifier and sequence number are 0 (RFC 792), and host
unreachable from a router carries the packet as forwarded. Not taken:
Java's lower-case broadcast address in a reply to 0.0.0.0, and the typo
'Exeeded'. RIP keeps its recordings: Java's staggered router start now
gives a different startup on this machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:11:05 +00:00

124 lines
6.2 KiB
TypeScript

// 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-<story>.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<string, JavaFrame[]>;
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<string, (engine: NetworkRuntimeEngine, call: Call) => 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<T extends { text: string }>(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 });
});
}
});