engine.ts ports Swift's "Java-compatible TCP sockets" in one piece, since half a state machine could not be checked: server and client sockets (a free port from Java's random numbers on their own seed), the three-way handshake with its result (connected, invalid socket, unreachable, timed out), segments of 1460 bytes, messages up to the push, a timeout of 3 × 2.5 s with at most three attempts, closing through every Java state including TIME_WAIT (2.5 s), sequence numbers modulo 2^32. Three scenarios match the Swift engine trace for trace: a LAN with data over three segments, closing from both ends, a closed port and an unreachable host; two networks through a router with a firewall rule, data lost until the connection closes, and an accepted socket that does not hold the port of its closed listener; CLOSING, reached by a lost FIN. The engine delivers a FIN within the same close, so both ends close at once only with packet loss; an empty message does not arrive in Swift (it does not advance the sequence number), nor here. Swift's four TCP engine tests are ported, with tests for stopping, a foreign SYN+ACK at a listener and the flag names. 17 mutation probes, 16 caught; the other (delivered, not waited, not connected gives unreachable) cannot be reached through Swift's engine, which never sends a reset and always acknowledges correctly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
405 lines
14 KiB
TypeScript
405 lines
14 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;
|
|
// 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,
|
|
}),
|
|
),
|
|
]),
|
|
),
|
|
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 '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 '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,
|
|
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();
|
|
|
|
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);
|
|
});
|
|
}
|
|
});
|