Files
mdg-filius/packages/core/test/javaRun.ts
T
julian.kiedaischandClaude Opus 5.5 7dac57cbbe feat(core): the pages of routers and home routers after Java, measured against Java
Routers and home routers start a web server on port 80 with Java's index,
log and routes pages in the project's language (routerPages.ts); the
firewall keeps a log per node that outlives a new start; RIP routes carry
the telling router's public address. TCP counts sequence numbers and
segments in Java's characters, not bytes. RouterWebRun records six stories,
with firewalls now built by ScenarioNetwork. Not taken from Java: its router
server sometimes never listening (a race in setAktiv), and </titel>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:41:52 +00:00

301 lines
15 KiB
TypeScript

// What tools/java-oracle's DhcpRun and TcpRun recorded in Java FILIUS
// (<name>.java.json: every frame each card saw, FILIUS' own packet log), and
// the same view of an engine run: each frame when it first shows on any
// host's card, as a line of text. Shared by dhcpJava.test.ts and
// tcpJava.test.ts.
//
// Not only the frames of one card: FILIUS' log misses the DISCOVER of the
// DHCP client that sends second when two start together (measured in four of
// four runs of dhcp-lan; the server receives it) — a fault of the log, not of
// the network. Java logs no frame identity; the same text within
// DUPLICATE_WINDOW_MILLISECONDS is one frame (measured: a frame reaches the
// last card within some 130 ms; the closest two distinct frames of the same
// text are the DISCOVERs around a DECLINE, some 520 ms apart).
//
// Java runs on threads in real time, at the default speed of 90 % (55 ms a
// cable, a round trip time of 2750 ms). Its gaps match the engine's within a
// few milliseconds; where they do not, it is Java's threads (a sender blocked
// by a busy cable wakes late, up to some 110 ms measured), the log's lost
// DISCOVER above, or a story whose calls the engine makes one after the
// other. A gap may differ by GAP_TOLERANCE_MILLISECONDS; a wrong round trip
// time (250 ms) may not. A cable carries one frame after the other in both
// (engine.test.ts checks that: one cable's delay is below this tolerance).
import { expect } from 'vitest';
import {
captureCommentText,
captureRows,
javaTextLength,
NetworkRuntimeEngine,
TCP_FLAG,
type CapturedFrame,
type CaptureLayer,
type EthernetFrame,
type TopologySnapshot,
} from '../src/index.js';
import { snapshotOf, type Scenario } from './scenario.js';
export const GAP_TOLERANCE_MILLISECONDS = 120;
const DUPLICATE_WINDOW_MILLISECONDS = 300;
export interface JavaFrame {
time: number;
source: string;
destination: string;
arp?: number;
senderIp?: string;
targetIp?: string;
sourceIp?: string;
destinationIp?: string;
protocol?: number;
sourcePort?: number;
destinationPort?: number;
payload?: string | null;
icmp?: number;
icmpCode?: number;
tcpFlags?: string;
sequence?: number;
acknowledgement?: number;
}
export interface SeenFrame {
readonly time: number;
readonly text: string;
}
function javaText(frame: JavaFrame): string {
const macs = `${frame.source} > ${frame.destination}`.toUpperCase();
if (frame.arp !== undefined) return `${macs} ARP ${frame.arp === 1 ? 'request' : 'reply'} ${frame.senderIp} > ${frame.targetIp}`;
if (frame.icmp !== undefined) return icmpText(macs, frame.icmp, frame.icmpCode!, frame.sourceIp!, frame.destinationIp!, frame.sequence!);
const ends = `${frame.sourceIp}:${frame.sourcePort} > ${frame.destinationIp}:${frame.destinationPort}`;
if (frame.tcpFlags !== undefined) {
return `${macs} TCP ${ends} ${frame.tcpFlags || '-'} seq=${frame.sequence} ack=${frame.acknowledgement} length=${(frame.payload ?? '').length}`;
}
return `${macs} UDP ${ends} ${frame.payload}`;
}
/** The ICMP type numbers Java logs. */
/** Type and code of each ICMP message the engine knows. */
const ICMP_TYPES: Record<string, [number, number]> = {
echoReply: [0, 0],
destinationNetworkUnreachable: [3, 0],
destinationHostUnreachable: [3, 1],
echoRequest: [8, 0],
timeExceeded: [11, 0],
};
/** The sequence number only for echo messages: an error carries none of its own. */
function icmpText(macs: string, type: number, code: number, source: string, destination: string, sequence: number): string {
const echo = type === 0 || type === 8 ? ` seq=${sequence}` : '';
return `${macs} ICMP ${type}/${code} ${source} > ${destination}${echo}`;
}
const FLAG_LETTERS: [number, string][] = [
[TCP_FLAG.synchronize, 'S'],
[TCP_FLAG.acknowledgement, 'A'],
[TCP_FLAG.finish, 'F'],
[TCP_FLAG.push, 'P'],
[TCP_FLAG.reset, 'R'],
];
export function engineText(frame: EthernetFrame): string {
const macs = `${frame.sourceMacAddress} > ${frame.destinationMacAddress}`.toUpperCase();
if (frame.payload.kind === 'arp') {
const arp = frame.payload.packet;
return `${macs} ARP ${arp.operation} ${arp.senderIPAddress} > ${arp.targetIPAddress}`;
}
const packet = frame.payload.packet;
if (packet.payload.kind === 'icmp') {
const message = packet.payload.message;
const [type, code] = ICMP_TYPES[message.kind]!;
return icmpText(macs, type, code, packet.senderIPAddress, packet.receiverIPAddress, message.sequenceNumber);
}
if (packet.payload.kind === 'tcp') {
const segment = packet.payload.segment;
const flags = FLAG_LETTERS.filter(([bit]) => (segment.flags & bit) !== 0).map(([, letter]) => letter).join('');
return `${macs} TCP ${packet.senderIPAddress}:${segment.sourcePort} > ${packet.receiverIPAddress}:${segment.destinationPort} ${flags || '-'} seq=${segment.sequenceNumber} ack=${segment.acknowledgementNumber} length=${javaTextLength(segment.payload)}`;
}
const datagram = packet.payload.datagram;
return `${macs} UDP ${packet.senderIPAddress}:${datagram.sourcePort} > ${packet.receiverIPAddress}:${datagram.destinationPort} ${new TextDecoder().decode(datagram.payload)}`;
}
/**
* A started engine on the scenario's network (or on `snapshot`, made from
* it) whose frames can be read afterwards as FILIUS logs them.
*/
export function watchedEngine(scenario: Scenario, snapshot: TopologySnapshot = snapshotOf(scenario)) {
const engine = new NetworkRuntimeEngine(scenario.seed);
const framesByIdentity = new Map<number, EthernetFrame>();
const send = engine.sendEthernetFrame.bind(engine);
engine.sendEthernetFrame = (fromNodeId, outgoingPortId, frame) => {
framesByIdentity.set(frame.identity, frame);
send(fromNodeId, outgoingPortId, frame);
};
engine.handle({ type: 'start', snapshot, seed: scenario.seed, initialTimeMilliseconds: 0 });
/** Every frame when it first shows on a host's card (sent, or received). */
const frames = (): SeenFrame[] => {
const hosts = new Set(scenario.nodes.filter((node) => node.kind !== 'networkSwitch').map((node) => node.id));
const firstSeen = new Map<number, SeenFrame>();
for (const event of engine.state.packetTraces) {
if (event.layer !== 'physical' || !hosts.has(event.nodeId) || event.frameIdentity === undefined) continue;
if (!((event.direction === 'outbound' && event.operation === 'sent') || (event.direction === 'inbound' && event.operation === 'received'))) {
continue;
}
if (firstSeen.has(event.frameIdentity)) continue;
firstSeen.set(event.frameIdentity, { time: event.timeMilliseconds, text: engineText(framesByIdentity.get(event.frameIdentity)!) });
}
return [...firstSeen.values()];
};
return { engine, frames };
}
/** Java's frames of all cards, each once: the same text within the window is the same frame. */
export function javaFrames(framesByNode: Record<string, JavaFrame[]>, windowMilliseconds = DUPLICATE_WINDOW_MILLISECONDS): SeenFrame[] {
const all = Object.values(framesByNode)
.flat()
.map((frame) => ({ time: frame.time, text: javaText(frame) }))
.sort((lhs, rhs) => lhs.time - rhs.time);
const frames: SeenFrame[] = [];
for (const frame of all) {
if (frames.some((each) => each.text === frame.text && frame.time - each.time <= windowMilliseconds)) continue;
frames.push(frame);
}
return frames;
}
/** Consecutive frames: the text, and the time since the one before. */
export function gaps(frames: readonly SeenFrame[]) {
return frames.map((frame, index) => ({ text: frame.text, gap: index === 0 ? 0 : frame.time - frames[index - 1]!.time }));
}
/**
* Each card's packet capture against Java's (the Lauscher's list of the
* card, as the oracle wrote it: under the node's id for its first card,
* `id#index` for the others): the same frames in the same order, each at
* about the same time (both counted from the run's first frame, or from
* `starts`). `text`
* puts placeholders in a frame's text (ports, IDs a test cannot predict),
* `normalized` does so for a card's whole list; with `until` (Java's time),
* only the frames before it; with `from`, only those from it.
*/
/** A card to compare: the key Java's lists have it under, and its port in the engine. */
export interface CaptureCard {
readonly key: string;
readonly portId: string;
}
/** A scenario's cards: its hosts' ports, under the node's id for the first card and `id#index` for the others (ScenarioNetwork). */
export function scenarioCards(scenario: Scenario): CaptureCard[] {
return scenario.nodes
.filter((node) => node.kind !== 'networkSwitch')
.flatMap((node) => node.ports.map((port, index) => ({ key: index === 0 ? node.id : `${node.id}#${index}`, portId: port.id })));
}
export function expectSameCaptures(
engine: NetworkRuntimeEngine,
cardsOrScenario: Scenario | readonly CaptureCard[],
java: Record<string, JavaFrame[]>,
options: {
text?: (text: string) => string;
normalized?: (frames: SeenFrame[], card: string) => SeenFrame[];
from?: number | undefined;
until?: number | undefined;
tolerance?: number;
starts?: { java: number; engine: number };
/** Java's tables of the cards (ScenarioNetwork.writeRows), compared for the frames compared. */
tables?: JavaTables;
/** Placeholders in a table cell, beyond those of tableCell. */
cell?: (text: string) => string;
} = {},
): void {
const text = options.text ?? ((each: string) => each);
const engineByCard = new Map<string, SeenFrame[]>();
const capturedByCard = new Map<string, readonly CapturedFrame[]>();
const cards = 'nodes' in cardsOrScenario ? scenarioCards(cardsOrScenario) : cardsOrScenario;
for (const { key, portId } of cards) {
capturedByCard.set(key, engine.capture.frames(portId));
engineByCard.set(key, capturedByCard.get(key)!.map((each) => ({ time: each.timeMilliseconds, text: text(engineText(each.frame)) })));
}
const javaByCard = new Map(Object.entries(java).map(([card, frames]) => [card, frames.map((frame) => ({ time: frame.time, text: text(javaText(frame)) }))]));
expect([...engineByCard.keys()].sort()).toEqual([...javaByCard.keys()].sort());
const first = (byCard: Map<string, SeenFrame[]>) => Math.min(...[...byCard.values()].flat().map((frame) => frame.time));
const engineStart = options.starts?.engine ?? first(engineByCard);
const javaStart = options.starts?.java ?? first(javaByCard);
const from = (options.from ?? Number.NEGATIVE_INFINITY) - javaStart;
const until = (options.until ?? Number.POSITIVE_INFINITY) - javaStart;
const normalized = options.normalized ?? ((frames: SeenFrame[], _card: string) => frames);
const window = (frames: SeenFrame[], start: number) =>
frames.filter((frame) => frame.time - start >= from && frame.time - start < until).map((frame) => ({ time: frame.time - start, text: frame.text }));
const inWindow = (time: number, start: number) => time - start >= from && time - start < until;
for (const [card, javaList] of javaByCard) {
if (options.tables !== undefined) {
// The rows of the frames in the window, frame by frame.
const javaNumbers = javaList.flatMap((frame, index) => (inWindow(frame.time, javaStart) ? [index + 1] : []));
const engineFrames = capturedByCard.get(card)!.filter((each) => inWindow(each.timeMilliseconds, engineStart));
for (const [view, withAddresses] of [['rows', true], ['layeredRows', false]] as const) {
const javaRows = options.tables[view]?.[card];
if (javaRows === undefined) continue;
const engineRows = captureRows(engineFrames, { withAddresses });
const cell = (value: string | null) => (value === null ? null : (options.cell ?? ((each: string) => each))(tableCell(value)));
for (const [index, number] of javaNumbers.entries()) {
const frame = engineFrames[index];
const expectedRows = javaRows.filter((row) => row[0] === String(number)).map((row) => row.slice(1).map(cell));
// A frame a test added where Java errs (JAVA_BUGS) has no table of Java's.
if (expectedRows.length === 0 && number > Math.max(0, ...javaRows.map((row) => Number(row[0])))) continue;
const actualRows = engineRows
.filter((row) => row.number === frame?.number)
.map((row) => [row.source, row.destination, row.protocol, JAVA_LAYERS[row.layer], captureCommentText(row.comment, JAVA_WORDS, javaNumber)].map(cell));
expect(actualRows, `${card} ${view} of frame #${index + 1}`).toEqual(expectedRows);
}
}
}
const expected = normalized(window(javaList, javaStart), card);
const actual = normalized(window(engineByCard.get(card)!, engineStart), card);
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
expect(actual[index]?.text, `${card} frame #${index + 1}`).toBe(expected[index]?.text);
expect(Math.abs(actual[index]!.time - expected[index]!.time), `${card} time of frame #${index + 1}`).toBeLessThanOrEqual(
options.tolerance ?? GAP_TOLERANCE_MILLISECONDS,
);
}
}
}
/** A client's free port as "client": Java draws it at random, the engine from its seed. */
export function withClientPortText(text: string): string {
return text.replace(/:(\d{5})\b/g, (whole, port: string) => (Number(port) >= 49_152 ? ':client' : whole));
}
/** Java's capture tables per card: number, source, destination, protocol, layer, comment (ScenarioNetwork.writeRows). */
export interface JavaTables {
rows?: Record<string, (string | null)[][]>;
layeredRows?: Record<string, (string | null)[][]>;
}
/** Java's layer names and comment words: its German message bundle, the locale it runs in (Locale.GERMANY). */
const JAVA_LAYERS: Record<CaptureLayer, string> = { networkAccess: 'Netzzugang', internet: 'Vermittlung', transport: 'Transport', application: 'Anwendung' };
const JAVA_WORDS = { protocol: 'Protokoll', searchForMac: 'Suche nach MAC f\u00fcr', macIs: 'MAC ist' };
/** NumberFormat for Locale.GERMANY: groups of three digits with dots. */
function javaNumber(value: number): string {
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, '.');
}
/**
* Placeholders every test needs in a table cell: free ports, DNS message IDs,
* IP packet IDs, link-local addresses. And two departures from Java: its ARP
* reply to 0.0.0.0 writes the broadcast address in lower case
* (ARP.sendArpReply), everything else in upper case — the engine always in
* upper case (better than Java where nothing depends on it); and Java
* misspells "ICMP Time Exeeded" (a typo, not taken).
*/
function tableCell(text: string): string {
return withClientPortText(text)
.replaceAll('ff:ff:ff:ff:ff:ff', 'FF:FF:FF:FF:FF:FF')
.replace('ICMP Time Exeeded (poof)', 'ICMP Time Exceeded (poof)')
.replace(/^(\d{5})$/, (whole, port: string) => (Number(port) >= 49_152 ? 'client' : whole))
.replace(/ID=\d+/g, 'ID=x')
.replace(/\[id=\d+/g, '[id=x')
.replace(/169\.254\.1\.\d+/g, '169.254.1.x');
}