// 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 (.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; interfaces?: { node: string; port: string; ip: string; mask: string }[]; routes?: Record; switches?: Record; 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(); 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 { 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 { 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 { return { kind: item.message.kind, identifier: item.message.identifier, sequence: item.message.sequenceNumber, sender: item.packet.senderIPAddress, }; } function run(scenario: Scenario): Record { 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 = (map: Map, 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); }); } });