engine.ts ports the UDP part of Swift's "UDP sockets and Router RIP": a socket bound to a given port or to a free one drawn from 49152–65534 with Java's random numbers on Swift's seed; sending, also as a limited broadcast on every interface (or the bound one) with TTL 1; delivery into the queue of every socket on the destination port, which then answers to the last sender; receiving, with a timeout that advances the virtual clock; closing. Stopping forgets sockets and queues. RIP stays not ported; Swift's DHCP handling of a datagram has nothing to do until DHCP is in the snapshot. The oracle gets the steps bindUdp, sendUdp, receiveUdp and closeSocket on both sides and reports the sockets. Two new scenarios — a LAN, and two networks through a router with a UDP firewall rule and a broadcast that does not cross it — match the Swift engine trace for trace, the random ports included. Swift's four UDP engine tests are ported, with a test for stopping. 11 mutation probes were all caught (one only after the stop test was added). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
358 lines
12 KiB
TypeScript
358 lines
12 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,
|
|
firewallConfiguration,
|
|
firewallRule,
|
|
interfaceKey,
|
|
NetworkRuntimeEngine,
|
|
portSnapshot,
|
|
switchConfiguration,
|
|
topologySnapshot,
|
|
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';
|
|
}[];
|
|
}
|
|
>;
|
|
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;
|
|
}[];
|
|
}
|
|
|
|
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,
|
|
}),
|
|
),
|
|
]),
|
|
),
|
|
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 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 '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)),
|
|
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,
|
|
})),
|
|
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();
|
|
|
|
describe('same result as the Swift engine', () => {
|
|
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 expected = JSON.parse(readFileSync(join(scenarioDirectory, `${name}.swift.json`), 'utf8')) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
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);
|
|
});
|
|
}
|
|
});
|