// What tools/java-oracle's DhcpRun and TcpRun recorded in Java FILIUS // (.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 { NetworkRuntimeEngine, TCP_FLAG, type EthernetFrame } 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 = { 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'], ]; 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=${segment.payload.length}`; } 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 whose frames can be read afterwards as FILIUS logs them. */ export function watchedEngine(scenario: Scenario) { const engine = new NetworkRuntimeEngine(scenario.seed); const framesByIdentity = new Map(); 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: snapshotOf(scenario), 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(); 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): 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 <= DUPLICATE_WINDOW_MILLISECONDS)) 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. */ export function expectSameCaptures( engine: NetworkRuntimeEngine, scenario: Scenario, java: Record, options: { text?: (text: string) => string; normalized?: (frames: SeenFrame[]) => SeenFrame[]; from?: number | undefined; until?: number | undefined; tolerance?: number; starts?: { java: number; engine: number }; } = {}, ): void { const text = options.text ?? ((each: string) => each); const engineByCard = new Map(); for (const node of scenario.nodes) { if (node.kind === 'networkSwitch') continue; node.ports.forEach((port, index) => { const frames = engine.capture.frames(port.id).map((each) => ({ time: each.timeMilliseconds, text: text(engineText(each.frame)) })); engineByCard.set(index === 0 ? node.id : `${node.id}#${index}`, frames); }); } 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) => 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[]) => 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 })); for (const [card, javaList] of javaByCard) { const expected = normalized(window(javaList, javaStart)); const actual = normalized(window(engineByCard.get(card)!, engineStart)); 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)); }