The engine keeps Java's packet capture (capture.ts): per network card every frame it sent (when it leaves on the cable) or received (also one for another card), once, with time and number; switches log nothing; frames the packet loss dropped are kept apart; the capture outlives a stop and a new start, as in Java, until clearCapture. Per port instead of per MAC, at most 10,000 frames a card. expectSameCaptures compares each card's capture with Java's list of that card in the ARP, WLAN, DNS, TCP, NAT, RIP and single-client DHCP runs. It found that ScenarioNetwork ignored a switch's learning time (Java ran 300 s, the core the scenarios' 5 s); the oracle sets it now, and the TCP and DHCP runs are recorded again. A core test found that a new start numbers frames from 1 again and the capture took them as seen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
103 lines
4.9 KiB
TypeScript
103 lines
4.9 KiB
TypeScript
// DHCP as Java FILIUS runs it. tools/java-oracle's DhcpRun built each
|
|
// scenario's network in Java FILIUS, ran it and wrote what every network card
|
|
// saw (<name>.java.json). The engine runs the same network. For each DHCP
|
|
// client, the frames that concern it must come in the same order, with the
|
|
// same content, at about the same distance from each other (javaRun.ts), and
|
|
// every host must end with the same addresses.
|
|
//
|
|
// Java draws the link-local address with Math.random; it is compared as
|
|
// 169.254.1.x. Where several clients start together, which one is first
|
|
// varies in Java (the first thread pays for loading the classes); a client is
|
|
// therefore compared without the other clients' frames and the answers to
|
|
// them.
|
|
|
|
import { readdirSync, readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { stableMacAddress } from '../src/index.js';
|
|
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, javaFrames, watchedEngine, type JavaFrame, type SeenFrame } from './javaRun.js';
|
|
import { type Scenario } from './scenario.js';
|
|
|
|
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
|
|
|
|
interface JavaRun {
|
|
frames: Record<string, JavaFrame[]>;
|
|
devices: Record<string, [string, string, string, string]>;
|
|
}
|
|
|
|
const LINK_LOCAL = /169\.254\.1\.\d+/g;
|
|
|
|
/** The engine's run of the scenario's network, without its steps, for `milliseconds`. */
|
|
function runEngine(scenario: Scenario, milliseconds: number) {
|
|
const { engine, frames } = watchedEngine(scenario);
|
|
engine.handle({ type: 'advance', toMilliseconds: milliseconds });
|
|
const devices = Object.fromEntries(
|
|
[...engine.state.topologySnapshot.deviceConfigurations].map(([node, device]) => [
|
|
node,
|
|
[device.ipAddress, device.subnetMask, device.defaultGateway, device.dnsServer],
|
|
]),
|
|
);
|
|
return { engine, frames: frames(), devices };
|
|
}
|
|
|
|
/** Consecutive frames without the other clients' own: the text, and the time since the one before. */
|
|
function steps(frames: readonly SeenFrame[], otherClientMacs: readonly string[]) {
|
|
const theirs = (frame: SeenFrame) =>
|
|
otherClientMacs.some((mac) => frame.text.startsWith(`${mac} >`) || frame.text.includes(`chaddr=${mac}`));
|
|
const own = frames.filter((frame) => !theirs(frame));
|
|
return own.map((frame, index) => ({
|
|
text: frame.text.replace(LINK_LOCAL, '169.254.1.x'),
|
|
gap: index === 0 ? 0 : frame.time - own[index - 1]!.time,
|
|
}));
|
|
}
|
|
|
|
function clientMac(scenario: Scenario, nodeId: string): string {
|
|
const port = scenario.nodes.find((node) => node.id === nodeId)!.ports[0]!;
|
|
return (port.mac ?? stableMacAddress(port.id)).toUpperCase();
|
|
}
|
|
|
|
const runs = readdirSync(scenarioDirectory)
|
|
.filter((name) => name.startsWith('dhcp-') && name.endsWith('.java.json'))
|
|
.map((name) => name.replace('.java.json', ''))
|
|
.sort();
|
|
|
|
describe('DHCP as Java FILIUS runs it', () => {
|
|
it('has Java runs', () => {
|
|
expect(runs.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
for (const name of runs) {
|
|
it(name, () => {
|
|
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario;
|
|
const java = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.java.json`), 'utf8')) as JavaRun;
|
|
const lastJavaFrame = Math.max(...Object.values(java.frames).flatMap((frames) => frames.map((frame) => frame.time)));
|
|
const engine = runEngine(scenario, lastJavaFrame + 10_000);
|
|
|
|
const clients = scenario.dhcpClients ?? [];
|
|
for (const client of clients) {
|
|
const others = clients.filter((each) => each !== client).map((each) => clientMac(scenario, each));
|
|
const expected = steps(javaFrames(java.frames), others);
|
|
const actual = steps(engine.frames, others);
|
|
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);
|
|
expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `${client} gap before frame #${index + 1}`).toBeLessThanOrEqual(
|
|
GAP_TOLERANCE_MILLISECONDS,
|
|
);
|
|
}
|
|
}
|
|
// Each card's capture, with one client: of two that start together, Java's
|
|
// log loses the DISCOVER of the one that sends second, and which sends
|
|
// first varies (javaRun.ts).
|
|
if (clients.length === 1) expectSameCaptures(engine.engine, scenario, java.frames, { text: (text) => text.replace(LINK_LOCAL, '169.254.1.x') });
|
|
const linkLocal = (devices: Record<string, string[]>) =>
|
|
Object.fromEntries(Object.entries(devices).map(([node, device]) => [node, device.map((value) => value.replace(LINK_LOCAL, '169.254.1.x'))]));
|
|
expect(linkLocal(engine.devices)).toEqual(linkLocal(java.devices));
|
|
});
|
|
}
|
|
|
|
it('builds the MACs as DhcpRun does', () => {
|
|
expect(stableMacAddress('00000000-0000-0000-0000-000000000112')).toBe('02:00:00:00:01:12');
|
|
});
|
|
});
|