// FILIUS' own examples run as Java FILIUS runs them (the acceptance of F2). // tools/java-oracle's ExampleRun opened each file in Java FILIUS as FILIUS // opens it, started it as the action mode does, played a story and wrote // every frame each card saw, the capture's tables, the story's calls and the // hosts' addresses (examples/.java.json). Here the file is read with // this package's reader and run in the core (projectSnapshot); each card's // capture must hold the same frames at about the same times with the same // table rows (javaRun.ts, cards by their MAC address from the file), the // calls must return the same, the hosts must end with the same addresses. import { describe, expect, it } from 'vitest'; import { interfaceKey, NetworkRuntimeEngine, stableMacAddress, type DnsAddressResolution, type Id, type IPv4DeliveryResult } from '@mdg-filius/core'; import { projectSnapshot } from '../src/index.js'; import { exampleCards, exampleRecording, namesInJavaOrder, openExample } from './exampleRun.js'; import { engineText, expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, gaps, javaFrames, withClientPortText, type JavaFrame, type JavaTables, type SeenFrame, } from '../../core/test/javaRun.js'; const SEED = 14; interface JavaRun extends JavaTables { frames: Record; events: [number, string][]; devices: Record; /** Each card's MAC address in Java and its node's name with the card's index, `name/index`. */ cards: Record; } type Call = (label: string, run: () => string) => void; interface Story { readonly file: string; /** * Hosts that start their DHCP clients together: which sends first is up to * Java's threads, and its log loses some of their frames (dhcpJava.test.ts). * Compared per client instead of per card: its own frames and the * server's answers to it, the long waits at about the same distances. Which address a * client gets follows the order the server hears them in: an address of * the server's range (`dhcpRange`) is a placeholder, and the hosts must * end with the same addresses, in any order. */ readonly dhcpClients?: readonly string[]; readonly dhcpRange?: RegExp; /** * When the story's calls begin (the engine's time), after the DHCP clients * are done: the clients are compared before it, each card from it on, both * counted from the first call. */ readonly storyStart?: number; readonly play: (engine: NetworkRuntimeEngine, node: (name: string) => Id, call: Call) => void; } const ICMP_CODES: Record = { timeExceeded: '11/0', destinationNetworkUnreachable: '3/0', destinationHostUnreachable: '3/1' }; /** A ping in Java's words (ICMP.sendProbe): the answer's type, code and sender, or null. */ function ping(engine: NetworkRuntimeEngine, from: Id, target: string, sequenceNumber: number): string { const result: IPv4DeliveryResult = engine.sendIcmpEcho(from, target, { sequenceNumber, identifier: 1 }); const observations = engine.state.icmpObservationsByNodeId.get(from) ?? []; if (result.type === 'icmpError') { const error = observations.findLast((observation) => observation.message.kind === result.kind); return `ICMP ${ICMP_CODES[result.kind]} from ${error?.packet.senderIPAddress ?? '?'}`; } const reply = observations.find((observation) => observation.message.kind === 'echoReply' && observation.message.sequenceNumber === sequenceNumber); return reply === undefined ? 'null' : `ICMP 0/0 from ${reply.packet.senderIPAddress}`; } /** A lookup in Java's words (Resolver): the address, null, or the exception. */ function lookup(resolution: DnsAddressResolution): string { return resolution.type === 'timedOut' ? 'failed TimeOutException' : `returned ${resolution.address ?? 'null'}`; } const STORIES: Record = { routing: { file: 'internetwork_komplett.fls', play: (engine, node, call) => { call('sendProbe 141.99.5.10', () => ping(engine, node('Notebook 1.10'), '141.99.5.10', 1)); call('sendProbe 141.99.5.10', () => ping(engine, node('Notebook 1.11'), '141.99.5.10', 2)); }, }, dhcp: { file: 'einfaches_rechnernetz_komplett.fls', dhcpClients: ['Notebook 1', 'Notebook 2', 'Notebook 3', 'Notebook 4'], dhcpRange: /192\.168\.0\.2\d/g, play: () => {}, }, dns: { file: 'dns_server.fls', play: (engine, node, call) => { call('holeIPAdresse www.filius.de', () => lookup(engine.resolveDnsAddress(node('Notebook'), 'www.filius.de'))); call('holeIPAdresseMailServer filius.de', () => lookup(engine.resolveDnsMailServer(node('Notebook'), 'filius.de'))); }, }, firewall: { file: 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls', dhcpClients: ['Notebook', 'Notebook#2', 'Heimrouter', 'Notebook#3', 'iPad 1', 'iPad 2', 'iPad 3', 'iPad 4'], dhcpRange: /10\.50\.50\.\d+/g, storyStart: 8_000, play: (engine, node, call) => { engine.handle({ type: 'advance', toMilliseconds: 8_000 }); call('sendProbe 130.100.200.25', () => ping(engine, node('iPad 1'), '130.100.200.25', 1)); call('sendProbe 130.100.200.25', () => ping(engine, node('Notebook'), '130.100.200.25', 2)); call('sendProbe 42.0.0.10', () => ping(engine, node('mail.gmx.de (130.100.200.25)'), '42.0.0.10', 3)); }, }, nat: { file: 'nat_with_mapped_port.fls', play: (engine, node, call) => { call('sendProbe 141.99.4.10', () => ping(engine, node('Notebook 1.10'), '141.99.4.10', 1)); call('sendProbe 141.99.5.10', () => ping(engine, node('Notebook 1.10'), '141.99.5.10', 2)); }, }, }; /** * Where Java errs, per story: its run corrected, as Java means it. * * routing: Notebook 1.11 has no gateway; Java's sendProbe fails with a * NullPointerException (no route), the engine answers network unreachable * from the notebook itself, as a router does. * * nat: both pings leave with identifier 1. Java's Heimrouter Paul keeps one * entry per LAN endpoint, for its first remote end (natJava.test.ts, * udpTwoRemotes): the echo reply of the second, Heimrouter Vincent, finds no * entry and stays at Paul's WAN card. The engine adds an entry for the second * remote end: the reply goes on through Paul's LAN card to the notebook, a * cable later. */ const JAVA_BUGS: Record JavaRun> = { routing: (java) => ({ ...java, events: java.events.map(([time, event]) => [time, event === 'sendProbe 141.99.5.10 failed NullPointerException' ? 'sendProbe 141.99.5.10 returned ICMP 3/0 from 141.99.1.11' : event]), }), nat: (java) => { const card = (name: string) => Object.entries(java.cards).find(([, each]) => each === name)![0]; const [paulWan, paulLan, notebook] = [card('Heimrouter Paul/0'), card('Heimrouter Paul/1'), card('Notebook 1.10/0')]; const reply = java.frames[paulWan]!.find((frame) => frame.icmp === 0 && frame.sourceIp === '141.99.5.10')!; const request = java.frames[paulLan]!.find((frame) => frame.icmp === 8 && frame.destinationIp === '141.99.5.10')!; const onLan: JavaFrame = { ...reply, time: reply.time + 1, source: request.destination, destination: request.source, destinationIp: request.sourceIp! }; const called = java.events.findIndex(([, event]) => event === 'sendProbe 141.99.5.10 called'); return { ...java, frames: { ...java.frames, [paulLan]: [...java.frames[paulLan]!, onLan], [notebook]: [...java.frames[notebook]!, { ...onLan, time: onLan.time + 55 }] }, events: java.events.map(([time, event], index) => index === called + 1 ? [onLan.time + 55, 'sendProbe 141.99.5.10 returned ICMP 0/0 from 141.99.5.10'] : [time, event], ), }; }, }; describe("FILIUS' examples as Java FILIUS runs them", () => { it('corrects Java only where it errs', () => { for (const [name, correct] of Object.entries(JAVA_BUGS)) { const java = exampleRecording(name); expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java)); } }); for (const [name, story] of Object.entries(STORIES)) { it(`${name}: ${story.file}`, () => { const measured = exampleRecording(name); const java = JAVA_BUGS[name]?.(measured) ?? measured; const project = openExample(story.file); const names = namesInJavaOrder(project); const engine = new NetworkRuntimeEngine(SEED); engine.handle({ type: 'start', snapshot: projectSnapshot(project), seed: SEED, initialTimeMilliseconds: 0 }); engine.handle({ type: 'advance', toMilliseconds: 500 }); const events: [number, string][] = []; story.play(engine, (nodeName) => names.get(nodeName)!, (label, run) => { events.push([engine.state.currentTimeMilliseconds, `${label} called`]); events.push([engine.state.currentTimeMilliseconds, `${label} ${run().replace(/^(?!returned |failed )/, 'returned ')}`]); }); const last = Math.max(...Object.values(java.frames).flat().map((frame) => frame.time), ...java.events.map(([time]) => time)); engine.handle({ type: 'advance', toMilliseconds: Math.max(engine.state.currentTimeMilliseconds, last) + 1_000 }); expect(events.map(([, event]) => event)).toEqual(java.events.map(([, event]) => event)); const { cards, withEngineMacs } = exampleCards(project, names, java.cards); const javaStoryStart = java.events[0]?.[0]; if (story.storyStart !== undefined) { expectSameCaptures(engine, cards, java.frames, { text: (text) => withClientPortText(withEngineMacs(text)).replace(/ID=\d+/g, 'ID=x'), cell: withEngineMacs, tables: measured, from: javaStoryStart!, starts: { java: javaStoryStart!, engine: story.storyStart }, }); } if (story.dhcpClients === undefined) { expectSameCaptures(engine, cards, java.frames, { text: (text) => withClientPortText(withEngineMacs(text)).replace(/ID=\d+/g, 'ID=x'), cell: withEngineMacs, tables: measured, }); } else { // Each frame where it first shows on any card. Java's log loses some of a client's own frames at // its card, and four clients' broadcasts queue on the cables: a frame reaches the cards up to some // 450 ms apart, so the same text within a second is the same frame (a client repeats a message // only after a round trip, 2750 ms). const engineFrames = new Map(); for (const { portId } of cards) { for (const each of engine.capture.frames(portId)) { if (!engineFrames.has(each.frame.identity)) engineFrames.set(each.frame.identity, { time: each.timeMilliseconds, text: engineText(each.frame) }); } } const allEngine = [...engineFrames.values()].filter((frame) => frame.time < (story.storyStart ?? Infinity)).sort((lhs, rhs) => lhs.time - rhs.time); const allJava = javaFrames(java.frames, 1_000) .filter((frame) => story.storyStart === undefined || frame.time < javaStoryStart!).map((frame) => ({ ...frame, text: withEngineMacs(frame.text) })); for (const client of story.dhcpClients) { const port = project.graph.nodes.find((node) => node.id === names.get(client))!.ports[0]!; const mac = (port.importedMacAddress ?? stableMacAddress(port.id)).toUpperCase(); const own = (frames: readonly SeenFrame[]) => gaps( frames .filter((frame) => frame.text.startsWith(`${mac} >`) || frame.text.includes(`chaddr=${mac}`)) .map((frame) => ({ ...frame, text: frame.text.replace(story.dhcpRange!, 'leased') })), ); const [expected, actual] = [own(allJava), own(allEngine)]; for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) { expect(actual[index]?.text, `${client} frame #${index + 1}`).toBe(expected[index]?.text); // The server serves clients that come together one after the other, in the order Java's // threads happen to send, and their broadcasts queue on the cables: a short step depends on // how many frames are ahead (measured: up to some 550 ms with eight clients). Only the long // waits (a round trip) are compared, as tightly as elsewhere. if (expected[index]!.gap < 1_000) continue; expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `${client} gap before frame #${index + 1}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS); } } } // Java's addresses of a host's card (ScenarioNetwork.writeDevices: the first card of every node with // an IP stack); a router without a gateway has only its cards' settings in the model. const snapshot = engine.state.topologySnapshot; const devices = Object.fromEntries( [...names].flatMap(([nodeName, id]) => { const device = snapshot.deviceConfigurations.get(id); if (device !== undefined) return [[nodeName, [device.ipAddress, device.subnetMask, device.defaultGateway, device.dnsServer]]]; const node = project.graph.nodes.find((each) => each.id === id)!; const card = node.ports[0] === undefined ? undefined : snapshot.interfaceConfigurations.get(interfaceKey(id, node.ports[0].id)); return card === undefined || node.kind === 'networkSwitch' ? [] : [[nodeName, [card.ipAddress, card.subnetMask, '', '']]]; }), ); if (story.dhcpRange === undefined) { expect(devices).toEqual(java.devices); } else { const leased = (byName: Record) => Object.values(byName) .map((device) => device.join(' ')) .sort(); expect(leased(devices)).toEqual(leased(java.devices)); expect(Object.keys(devices).sort()).toEqual(Object.keys(java.devices).sort()); } }); } });