Files
mdg-filius/packages/core/test/oracle.test.ts
T
julian.kiedaischandClaude Opus 5.5 396dbacfc5 feat(core): DHCP in the simulation core, as in Swift
engine.ts ports Swift's "DHCP server and automatic client": an active
server listens on port 67 from the start and offers a static assignment
before its range, round from the address after the last one offered;
offers last 10 s, dynamic leases a day, and a server whose offer another
server beat blacklists that address for a while. A client starts as an
event: DISCOVER, OFFER, a check by ARP, DECLINE, REQUEST, ACK or NAK,
2.5 s per wait, and after ten failures its old address back. The snapshot
carries the DHCP settings of clients and servers.

Four scenarios match the Swift engine. One Swift error is named in the
oracle test: a client run directly is advanced a second time, to FINISH,
by the server's answer within its own DISCOVER, and the outer run then
writes back its stale copy of the status (Swift's value types); the
address and every trace agree, only the reported status is wrong in
Swift. Swift's three DHCP engine tests are ported, with tests for
stopping and the message format. 15 mutation probes, 14 caught; the other
concerns the home router, which runs only once NAT is ported.

Two departures from Java remain, to be fixed next: from the event queue
the client's ARP check does not wait, so an address conflict is never
found, and each answer that wakes the client makes it send its state's
message again (a second DISCOVER and REQUEST, the second answered with
NAK). Java waits a round trip and sends once per attempt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 10:17:36 +00:00

491 lines
18 KiB
TypeScript

// Replays every scenario in test/scenarios with the TypeScript engine and
// compares the result with what the filius-ipad Swift engine produced for the
// same file (<name>.swift.json, written by tools/swift-oracle/run.sh).
// Every trace event must match: identity, time, node, headers, detail.
import { readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import {
deviceConfiguration,
dhcpServerConfiguration,
firewallConfiguration,
firewallRule,
interfaceKey,
NetworkRuntimeEngine,
portSnapshot,
switchConfiguration,
topologySnapshot,
type DhcpClientStatus,
type FirewallConfiguration,
type IPv4DeliveryResult,
type IcmpObservation,
type ManualRoute,
type NodeKind,
type TopologySnapshot,
type TraceEvent,
} from '../src/index.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
interface Scenario {
seed: number;
speedPercent?: number;
nodes: { id: string; kind: NodeKind; ports: { id: string; label: string; mac?: string }[] }[];
links: { id: string; sourceNode: string; sourcePort: string; targetNode: string; targetPort: string }[];
devices?: Record<string, { ip: string; mask: string; gateway?: string; dns?: string }>;
interfaces?: { node: string; port: string; ip: string; mask: string }[];
routes?: Record<string, { destination: string; mask: string; gateway: string; interface: string }[]>;
switches?: Record<string, { ssid: string; retention?: number }>;
firewalls?: Record<
string,
{
active?: boolean;
defaultPolicy?: 'accept' | 'drop';
dropIcmp?: boolean;
synOnly?: boolean;
filterUdp?: boolean;
rules?: {
source?: string;
sourceMask?: string;
destination?: string;
destinationMask?: string;
port?: number;
protocol?: 'all' | 'icmp' | 'tcp' | 'udp';
action?: 'accept' | 'drop';
}[];
}
>;
dhcpServers?: Record<
string,
{ active?: boolean; lower?: string; upper?: string; gateway?: string; dns?: string; own?: boolean; staticAssignments?: [string, string][] }
>;
dhcpClients?: string[];
steps: {
op: string;
from?: string;
node?: string;
target?: string;
ttl?: number;
identifier?: number;
sequence?: number;
maxTtl?: number;
to?: number;
enabled?: boolean;
percent?: number;
// UDP: a socket is named by the id bindUdp returned.
port?: number;
localIp?: string;
remoteIp?: string;
remotePort?: number;
socket?: string;
payload?: string;
timeout?: number;
// TCP: the payload repeated this many times, for messages longer than a segment.
repeatCount?: number;
localPort?: number;
}[];
}
function snapshotOf(scenario: Scenario): TopologySnapshot {
const firewalls = new Map<string, FirewallConfiguration>();
for (const [node, config] of Object.entries(scenario.firewalls ?? {})) {
firewalls.set(
node,
firewallConfiguration({
isActive: config.active ?? false,
defaultPolicy: config.defaultPolicy ?? 'drop',
dropIcmp: config.dropIcmp ?? false,
filterSynSegmentsOnly: config.synOnly ?? true,
filterUdp: config.filterUdp ?? true,
rules: (config.rules ?? []).map((rule) =>
firewallRule({
sourceIPAddress: rule.source ?? '',
sourceSubnetMask: rule.sourceMask ?? '',
destinationIPAddress: rule.destination ?? '',
destinationSubnetMask: rule.destinationMask ?? '',
...(rule.port === undefined ? {} : { port: rule.port }),
protocol: rule.protocol ?? 'tcp',
action: rule.action ?? 'drop',
}),
),
}),
);
}
return topologySnapshot({
nodes: scenario.nodes.map((node) => ({
id: node.id,
kind: node.kind,
ports: node.ports.map((port) => portSnapshot(port.id, port.label, port.mac)),
})),
links: scenario.links.map((link) => ({
id: link.id,
sourceNodeId: link.sourceNode,
sourcePortId: link.sourcePort,
targetNodeId: link.targetNode,
targetPortId: link.targetPort,
})),
deviceConfigurations: new Map(
Object.entries(scenario.devices ?? {}).map(([node, device]) => [
node,
deviceConfiguration(device.ip, device.mask, device.gateway ?? '', device.dns ?? ''),
]),
),
interfaceConfigurations: new Map(
(scenario.interfaces ?? []).map((item) => [
interfaceKey(item.node, item.port),
{ ipAddress: item.ip, subnetMask: item.mask },
]),
),
manualRoutesByNodeId: new Map(
Object.entries(scenario.routes ?? {}).map(([node, rows]) => [
node,
rows.map(
(row): ManualRoute => ({
destinationNetwork: row.destination,
subnetMask: row.mask,
gateway: row.gateway,
interfaceIPAddress: row.interface,
}),
),
]),
),
dhcpServerConfigurationsByNodeId: new Map(
Object.entries(scenario.dhcpServers ?? {}).map(([node, config]) => [
node,
dhcpServerConfiguration({
isActive: config.active ?? false,
lowerBoundIPAddress: config.lower ?? '0.0.0.0',
upperBoundIPAddress: config.upper ?? '0.0.0.0',
gatewayIPAddress: config.gateway ?? '0.0.0.0',
dnsServerIPAddress: config.dns ?? '0.0.0.0',
useOwnSettings: config.own ?? false,
staticAssignments: (config.staticAssignments ?? []).map(([macAddress, ipAddress], index) => ({
id: `static-${index}`,
macAddress,
ipAddress,
})),
}),
]),
),
dhcpClientConfigurationsByNodeId: new Map((scenario.dhcpClients ?? []).map((node) => [node, { isEnabled: true }])),
switchConfigurationsByNodeId: new Map(
Object.entries(scenario.switches ?? {}).map(([node, config]) => [
node,
config.retention === undefined ? switchConfiguration(config.ssid) : switchConfiguration(config.ssid, config.retention),
]),
),
firewallConfigurationsByNodeId: firewalls,
});
}
const upper = (id: string) => id.toUpperCase();
function dhcpStatusJson(status: DhcpClientStatus) {
return {
state: status.state,
errors: status.errorCount,
server: status.selectedServerIPAddress ?? null,
offered: status.offeredIPAddress ?? null,
succeeded: status.succeeded,
};
}
function traceJson(event: TraceEvent): Record<string, unknown> {
return {
id: event.id,
time: event.timeMilliseconds,
node: upper(event.nodeId),
direction: event.direction,
layer: event.layer,
operation: event.operation,
before: event.beforeHeaders.map((field) => [field.name, field.value]),
after: event.afterHeaders.map((field) => [field.name, field.value]),
...(event.frameIdentity === undefined ? {} : { frame: event.frameIdentity }),
...(event.packetIdentity === undefined ? {} : { packet: event.packetIdentity }),
...(event.interfaceId === undefined ? {} : { interface: upper(event.interfaceId) }),
...(event.detail === undefined ? {} : { detail: event.detail }),
};
}
function deliveryJson(result: IPv4DeliveryResult): Record<string, unknown> {
switch (result.type) {
case 'delivered':
return { type: 'delivered', packet: result.packetIdentity, node: upper(result.nodeId) };
case 'icmpError':
return { type: 'icmpError', packet: result.packetIdentity, node: upper(result.nodeId), kind: result.kind };
case 'dropped':
return {
type: 'dropped',
packet: result.packetIdentity,
...(result.nodeId === undefined ? {} : { node: upper(result.nodeId) }),
};
}
}
function observationJson(item: IcmpObservation): Record<string, unknown> {
return {
kind: item.message.kind,
identifier: item.message.identifier,
sequence: item.message.sequenceNumber,
sender: item.packet.senderIPAddress,
};
}
function run(scenario: Scenario): Record<string, unknown> {
const engine = new NetworkRuntimeEngine(scenario.seed);
if (scenario.speedPercent !== undefined) engine.setSimulationSpeed(scenario.speedPercent);
engine.handle({ type: 'start', snapshot: snapshotOf(scenario), seed: scenario.seed, initialTimeMilliseconds: 0 });
const results: unknown[] = [];
for (const step of scenario.steps) {
switch (step.op) {
case 'ping':
results.push(
deliveryJson(
engine.sendIcmpEcho(step.from!, step.target!, {
timeToLive: step.ttl ?? 64,
identifier: step.identifier ?? 1,
sequenceNumber: step.sequence ?? 1,
}),
),
);
break;
case 'traceroute':
results.push(
engine
.traceIcmpEcho(step.from!, step.target!, {
maximumTimeToLive: step.maxTtl ?? 64,
identifier: step.identifier ?? 1,
})
.map(observationJson),
);
break;
case 'resolveMac':
results.push(engine.resolveMacAddress(step.node!, step.target!) ?? null);
break;
case 'advance':
engine.handle({ type: 'advance', toMilliseconds: step.to! });
results.push(engine.state.currentTimeMilliseconds);
break;
case 'packetLoss':
engine.setGlobalPacketLossEnabled(step.enabled!);
results.push(engine.state.globalPacketLossEnabled);
break;
case 'sat':
results.push(
engine.switchSatEntries(step.node!).map((entry) => ({
mac: entry.macAddress,
port: entry.portLabel,
updated: entry.updatedAtMilliseconds,
})),
);
break;
case 'speed':
engine.setSimulationSpeed(step.percent!);
results.push(engine.simulationSpeed.percent);
break;
case 'bindUdp':
results.push(
engine.bindUdpSocket(step.node!, {
...(step.port === undefined ? {} : { localPort: step.port }),
...(step.localIp === undefined ? {} : { localIPAddress: step.localIp }),
...(step.remoteIp === undefined ? {} : { remoteIPAddress: step.remoteIp }),
...(step.remotePort === undefined ? {} : { remotePort: step.remotePort }),
}) ?? null,
);
break;
case 'sendUdp': {
const result = engine.sendUdp(step.socket!, new TextEncoder().encode(step.payload ?? ''), {
...(step.target === undefined ? {} : { ipAddress: step.target }),
...(step.port === undefined ? {} : { port: step.port }),
});
results.push(result === undefined ? null : deliveryJson(result));
break;
}
case 'receiveUdp': {
const item = engine.receiveUdp(step.socket!, step.timeout ?? 0);
results.push(
item === undefined
? null
: {
sender: item.senderIPAddress,
receiver: item.receiverIPAddress,
sourcePort: item.datagram.sourcePort,
destinationPort: item.datagram.destinationPort,
payload: new TextDecoder().decode(item.datagram.payload),
at: item.receivedAtMilliseconds,
},
);
break;
}
case 'openTcpServer':
results.push(engine.openTcpServerSocket(step.node!, step.port!, step.localIp) ?? null);
break;
case 'openTcpClient':
results.push(
engine.openTcpClientSocket(step.node!, step.target!, step.port!, {
...(step.localIp === undefined ? {} : { localIPAddress: step.localIp }),
...(step.localPort === undefined ? {} : { localPort: step.localPort }),
}) ?? null,
);
break;
case 'connectTcp':
results.push(engine.connectTcpWithResult(step.socket!));
break;
case 'sendTcp':
results.push(engine.sendTcp(step.socket!, new TextEncoder().encode((step.payload ?? '').repeat(step.repeatCount ?? 1))));
break;
case 'receiveTcp': {
const message = engine.receiveTcp(step.socket!);
results.push(message === undefined ? null : new TextDecoder().decode(message));
break;
}
case 'acceptedTcp':
results.push(engine.state.tcpAcceptedSocketIdsByListenerId.get(step.socket!) ?? []);
break;
case 'peerTcp':
results.push(engine.peerTcpSocketId(step.socket!) ?? null);
break;
case 'cleanTcp':
results.push(engine.cleanClosedTcpSocket(step.socket!));
break;
case 'runDhcp': {
const status = engine.runDhcpClient(step.node!);
results.push(status === undefined ? null : dhcpStatusJson(status));
break;
}
case 'closeSocket':
engine.closeSocket(step.socket!);
results.push(!engine.state.socketsById.has(step.socket!));
break;
default:
throw new Error(`unknown step ${step.op}`);
}
}
const state = engine.state;
const mapOf = <V>(map: Map<string, V>, convert: (value: V) => unknown) =>
Object.fromEntries([...map].map(([key, value]) => [upper(key), convert(value)]));
return {
results,
traces: state.packetTraces.map(traceJson),
time: state.currentTimeMilliseconds,
pendingEvents: state.pendingEvents.length,
arp: mapOf(state.arpCachesByNodeId, (cache) =>
Object.fromEntries([...cache].map(([ip, entry]) => [ip, entry.macAddress])),
),
switchTables: mapOf(state.switchForwardingTablesByNodeId, (table) =>
Object.fromEntries([...table].map(([mac, port]) => [mac, upper(port)])),
),
icmpObservations: mapOf(state.icmpObservationsByNodeId, (items) => items.map(observationJson)),
devices: Object.fromEntries(
[...state.topologySnapshot.deviceConfigurations].map(([node, device]) => [
upper(node),
[device.ipAddress, device.subnetMask, device.defaultGateway, device.dnsServer],
]),
),
dhcp: {
clients: mapOf(state.dhcpClientStatusesByNodeId, dhcpStatusJson),
leases: [...state.dhcpLeasesByIPAddress]
.sort(([lhs], [rhs]) => (lhs < rhs ? -1 : 1))
.map(([key, lease]) => [key, lease.clientMacAddress, lease.ipAddress, lease.expiresAtMilliseconds ?? null]),
offers: [...state.dhcpOffersByIPAddress]
.sort(([lhs], [rhs]) => (lhs < rhs ? -1 : 1))
.map(([key, offer]) => [key, offer.clientMacAddress, offer.expiresAtMilliseconds]),
blacklist: mapOf(state.dhcpBlacklistByServerNodeId, (entries) =>
[...entries].sort(([lhs], [rhs]) => (lhs < rhs ? -1 : 1)).map(([address, expiry]) => [address, expiry]),
),
servers: mapOf(state.dhcpServerSocketIdsByNodeId, upper),
},
sockets: [...state.socketsById.values()]
.sort((lhs, rhs) => (upper(lhs.id) < upper(rhs.id) ? -1 : 1))
.map((socket) => ({
id: upper(socket.id),
node: upper(socket.nodeId),
protocol: socket.protocol,
localIp: socket.localIPAddress ?? null,
localPort: socket.localPort,
remoteIp: socket.remoteIPAddress ?? null,
remotePort: socket.remotePort ?? null,
queued: state.udpReceiveQueuesBySocketId.get(socket.id)?.length ?? 0,
tcpState: socket.tcpState ?? null,
parent: socket.parentListenerSocketId === undefined ? null : upper(socket.parentListenerSocketId),
session: ((session) =>
session === undefined
? null
: {
attempts: session.sendAttempts,
next: session.nextSendSequenceNumber,
remote: session.remoteSequenceNumber,
pending: session.lastSentSegment !== undefined,
assembly: session.receiveAssembly.length,
messages: session.receivedMessages.length,
})(state.tcpSessionsById.get(socket.id)),
})),
firewallDecisions: state.firewallDecisions.map((decision) => ({
packet: decision.packetIdentity,
node: upper(decision.nodeId),
accepted: decision.accepted,
...(decision.ruleIndex === undefined ? {} : { rule: decision.ruleIndex }),
})),
};
}
const scenarioNames = readdirSync(scenarioDirectory)
.filter((name) => name.endsWith('.scenario.json'))
.map((name) => name.replace('.scenario.json', ''))
.sort();
/**
* Where the Swift engine is wrong, per scenario: its result corrected.
*
* dhcp-lan: a DHCP client run directly (not from the event queue) sends its
* DISCOVER at once; the server's OFFER arrives within that send, and the
* UDP delivery advances the client again — to FINISH, with the address set.
* The outer run then writes back its stale copy of the status (Swift's
* value types): DISCOVER, not succeeded. The address and every trace are
* the same; only the reported status is wrong in Swift.
*/
const SWIFT_ERRORS: Record<string, (expected: Record<string, unknown>) => Record<string, unknown>> = {
'dhcp-lan': (expected) => {
const client = '00000000-0000-0000-0000-000000000102';
const finished = { errors: 0, offered: '192.168.0.102', server: '192.168.0.10', state: 'FINISH', succeeded: true };
const results = [...(expected.results as unknown[])];
results[1] = finished;
const dhcp = expected.dhcp as { clients: Record<string, unknown> };
return { ...expected, results, dhcp: { ...dhcp, clients: { ...dhcp.clients, [client]: finished } } };
},
};
describe('same result as the Swift engine', () => {
it('corrects Swift only where it errs', () => {
for (const [name, correct] of Object.entries(SWIFT_ERRORS)) {
const expected = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record<string, unknown>;
expect(JSON.stringify(correct(expected)), name).not.toBe(JSON.stringify(expected));
}
});
it('has scenarios with Swift results', () => {
expect(scenarioNames.length).toBeGreaterThan(0);
});
for (const name of scenarioNames) {
it(name, () => {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.scenario.json`), 'utf8')) as Scenario;
const swift = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record<string, unknown>;
const expected = SWIFT_ERRORS[name]?.(swift) ?? swift;
const actual = run(scenario);
// Traces first and one by one: the first difference is the one that matters.
const expectedTraces = expected.traces as unknown[];
const actualTraces = actual.traces as unknown[];
for (let index = 0; index < Math.max(expectedTraces.length, actualTraces.length); index += 1) {
expect(actualTraces[index], `trace event #${index + 1}`).toEqual(expectedTraces[index]);
}
expect(actual).toEqual(expected);
});
}
});