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>
870 lines
36 KiB
TypeScript
870 lines
36 KiB
TypeScript
// Ported from filius-ipad ios/FiliusPadTests/TopologyNetworkRuntimeEngineTests.swift:
|
|
// the event queue, the virtual clock, Java-compatible randomness, restart,
|
|
// Ethernet/switch/ARP/IPv4/ICMP, routing errors, the firewall, packet loss
|
|
// and trace retention, UDP and TCP sockets. The remaining Swift tests (DHCP,
|
|
// DNS, RIP, NAT, remote link, packet viewer) follow with their protocol
|
|
// slices.
|
|
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
EMPTY_TOPOLOGY_SNAPSHOT,
|
|
ETHERNET_BROADCAST_MAC_ADDRESS,
|
|
firewallConfiguration,
|
|
firewallRule,
|
|
DIRECTLY_CONNECTED_SOURCE_MARKER,
|
|
forwardingClone,
|
|
icmpMessage,
|
|
interfaceKey,
|
|
JavaCompatibleRandom,
|
|
NetworkRuntimeEngine,
|
|
portSnapshot,
|
|
stableMacAddress,
|
|
switchConfiguration,
|
|
TCP_FLAG,
|
|
TCP_ROUND_TRIP_TIME_MILLISECONDS,
|
|
tcpFlagDescription,
|
|
topologySnapshot,
|
|
traceRetentionPolicy,
|
|
UNSPECIFIED_MAC_ADDRESS,
|
|
type EthernetFrame,
|
|
type EthernetPayload,
|
|
type FirewallConfiguration,
|
|
type IPv4Packet,
|
|
type NodeSnapshot,
|
|
type RuntimeOutput,
|
|
type ScheduledEventKind,
|
|
} from '../src/index.js';
|
|
import { device, link, pc, startedEngine, uuid } from './fixtures.js';
|
|
|
|
const marker = (label: string): ScheduledEventKind => ({ kind: 'parityMarker', label });
|
|
|
|
function firedKinds(outputs: RuntimeOutput[]): ScheduledEventKind[] {
|
|
return outputs.flatMap((output) => (output.kind.type === 'fired' ? [output.kind.event.kind] : []));
|
|
}
|
|
|
|
function routedTopology() {
|
|
const sourceNodeId = uuid(0x1451);
|
|
const sourcePortId = uuid(0x1461);
|
|
const routerNodeId = uuid(0x1452);
|
|
const routerPortA = uuid(0x1462);
|
|
const routerPortB = uuid(0x1463);
|
|
const targetNodeId = uuid(0x1453);
|
|
const targetPortId = uuid(0x1464);
|
|
const router: NodeSnapshot = {
|
|
id: routerNodeId,
|
|
kind: 'router',
|
|
ports: [portSnapshot(routerPortA, 'rt1'), portSnapshot(routerPortB, 'rt2')],
|
|
};
|
|
const snapshot = topologySnapshot({
|
|
nodes: [pc(sourceNodeId, sourcePortId), router, pc(targetNodeId, targetPortId)],
|
|
links: [
|
|
link(sourceNodeId, sourcePortId, routerNodeId, routerPortA),
|
|
link(routerNodeId, routerPortB, targetNodeId, targetPortId),
|
|
],
|
|
deviceConfigurations: new Map([
|
|
[sourceNodeId, device('10.0.0.2', { gateway: '10.0.0.1' })],
|
|
[routerNodeId, device('10.0.0.1')],
|
|
[targetNodeId, device('20.0.0.2', { gateway: '20.0.0.1' })],
|
|
]),
|
|
interfaceConfigurations: new Map([
|
|
[interfaceKey(routerNodeId, routerPortA), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
|
|
[interfaceKey(routerNodeId, routerPortB), { ipAddress: '20.0.0.1', subnetMask: '255.255.255.0' }],
|
|
]),
|
|
});
|
|
const engine = new NetworkRuntimeEngine(14);
|
|
engine.handle({ type: 'start', snapshot, seed: 14, initialTimeMilliseconds: 0 });
|
|
return { engine, sourceNodeId, routerNodeId };
|
|
}
|
|
|
|
function firewallEngine(configuration: FirewallConfiguration) {
|
|
const routerId = uuid(0x1802);
|
|
const portId = uuid(0x1812);
|
|
const engine = startedEngine(
|
|
{
|
|
nodes: [{ id: routerId, kind: 'router', ports: [portSnapshot(portId, 'rt1')] }],
|
|
links: [],
|
|
interfaceConfigurations: new Map([
|
|
[interfaceKey(routerId, portId), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
|
|
]),
|
|
firewallConfigurationsByNodeId: new Map([[routerId, configuration]]),
|
|
},
|
|
18,
|
|
);
|
|
return { engine, routerId };
|
|
}
|
|
|
|
function tcpPacket(identity: number, destinationPort: number, flags: number): IPv4Packet {
|
|
return {
|
|
identity,
|
|
senderIPAddress: '203.0.113.10',
|
|
receiverIPAddress: '10.0.0.1',
|
|
timeToLive: 64,
|
|
protocol: 'tcp',
|
|
payload: {
|
|
kind: 'tcp',
|
|
segment: {
|
|
sourcePort: 50_000,
|
|
destinationPort,
|
|
sequenceNumber: identity,
|
|
acknowledgementNumber: 0,
|
|
flags,
|
|
payload: new Uint8Array(),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('event queue and virtual clock', () => {
|
|
it('orders events by deadline, then by insertion sequence', () => {
|
|
const engine = new NetworkRuntimeEngine(42);
|
|
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 42, initialTimeMilliseconds: 0 });
|
|
engine.handle({ type: 'schedule', deadlineMilliseconds: 100, kind: marker('first-at-100') });
|
|
engine.handle({ type: 'schedule', deadlineMilliseconds: 50, kind: marker('at-50') });
|
|
engine.handle({ type: 'schedule', deadlineMilliseconds: 100, kind: marker('second-at-100') });
|
|
|
|
const outputs = engine.handle({ type: 'advance', toMilliseconds: 100 });
|
|
|
|
expect(firedKinds(outputs)).toEqual([marker('at-50'), marker('first-at-100'), marker('second-at-100')]);
|
|
expect(engine.state.currentTimeMilliseconds).toBe(100);
|
|
expect(engine.state.pendingEvents).toEqual([]);
|
|
});
|
|
|
|
it('rejects a past time without changing the clock', () => {
|
|
const engine = new NetworkRuntimeEngine(7);
|
|
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 7, initialTimeMilliseconds: 10 });
|
|
engine.handle({ type: 'advance', toMilliseconds: 25 });
|
|
|
|
const outputs = engine.handle({ type: 'advance', toMilliseconds: 24 });
|
|
|
|
expect(outputs.map((output) => output.kind)).toEqual([{ type: 'advanceRejectedPastTime' }]);
|
|
expect(engine.state.currentTimeMilliseconds).toBe(25);
|
|
});
|
|
|
|
it('rejects a past deadline without using up a sequence number', () => {
|
|
const engine = new NetworkRuntimeEngine(8);
|
|
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 8, initialTimeMilliseconds: 25 });
|
|
|
|
const outputs = engine.handle({ type: 'schedule', deadlineMilliseconds: 24, kind: marker('past') });
|
|
|
|
expect(outputs.map((output) => output.kind)).toEqual([
|
|
{ type: 'scheduleRejectedPastTime', deadlineMilliseconds: 24 },
|
|
]);
|
|
expect(engine.state.pendingEvents).toEqual([]);
|
|
expect(engine.state.nextEventSequenceNumber).toBe(0);
|
|
expect(engine.state.currentTimeMilliseconds).toBe(25);
|
|
});
|
|
|
|
it('replays a named scenario straight to a stable state', () => {
|
|
const finalState = NetworkRuntimeEngine.run({
|
|
name: 'equal-deadline-order',
|
|
seed: 1,
|
|
advanceToMilliseconds: 1_000,
|
|
scheduledEvents: [
|
|
{ deadlineMilliseconds: 500, kind: marker('a') },
|
|
{ deadlineMilliseconds: 500, kind: marker('b') },
|
|
],
|
|
});
|
|
|
|
expect(finalState.currentTimeMilliseconds).toBe(1_000);
|
|
expect(finalState.pendingEvents).toEqual([]);
|
|
expect(finalState.seed).toBe(1n);
|
|
});
|
|
|
|
it('clears transient state on restart and numbers packets from one again', () => {
|
|
const nodeId = uuid(0x12);
|
|
const engine = new NetworkRuntimeEngine(12);
|
|
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 12, initialTimeMilliseconds: 0 });
|
|
engine.handle({ type: 'schedule', deadlineMilliseconds: 40, kind: marker('stale') });
|
|
expect(engine.allocateFrameIdentity()).toBe(1);
|
|
expect(engine.allocatePacketIdentity()).toBe(1);
|
|
engine.recordTrace({ packetIdentity: 1, nodeId, direction: 'local', layer: 'application', operation: 'created' });
|
|
engine.handle({ type: 'stop' });
|
|
|
|
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 12, initialTimeMilliseconds: 0 });
|
|
|
|
const state = engine.state;
|
|
expect(state.pendingEvents).toEqual([]);
|
|
expect(state.packetTraces).toEqual([]);
|
|
expect(state.arpCachesByNodeId.size).toBe(0);
|
|
expect(state.switchForwardingTablesByNodeId.size).toBe(0);
|
|
expect(state.switchSeenFrameIdentitiesByNodeId.size).toBe(0);
|
|
expect(state.deliveredIPv4PacketsByNodeId.size).toBe(0);
|
|
expect(state.icmpObservationsByNodeId.size).toBe(0);
|
|
expect(state.firewallDecisions).toEqual([]);
|
|
expect(engine.allocateFrameIdentity()).toBe(1);
|
|
expect(engine.allocatePacketIdentity()).toBe(1);
|
|
});
|
|
|
|
it('forgets UDP sockets on stop, binds none while stopped, and numbers them from one again', () => {
|
|
const nodeId = uuid(0x13);
|
|
const portId = uuid(0x14);
|
|
const input = { nodes: [pc(nodeId, portId)], links: [], deviceConfigurations: new Map([[nodeId, device('10.2.0.1')]]) };
|
|
const engine = startedEngine(input);
|
|
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBe(uuid(1));
|
|
expect(engine.bindUdpSocket(nodeId)).toBe(uuid(2));
|
|
|
|
engine.handle({ type: 'stop' });
|
|
expect(engine.state.socketsById.size).toBe(0);
|
|
expect(engine.state.udpReceiveQueuesBySocketId.size).toBe(0);
|
|
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBeUndefined();
|
|
|
|
engine.handle({ type: 'start', snapshot: topologySnapshot(input), seed: 14, initialTimeMilliseconds: 0 });
|
|
expect(engine.bindUdpSocket(nodeId, { localPort: 7 })).toBe(uuid(1));
|
|
});
|
|
|
|
it('forgets TCP sessions and accepted sockets on stop', () => {
|
|
const [clientId, clientPortId, serverId, serverPortId] = [0x21, 0x22, 0x23, 0x24].map(uuid) as [string, string, string, string];
|
|
const engine = startedEngine({
|
|
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
|
|
links: [link(clientId, clientPortId, serverId, serverPortId)],
|
|
deviceConfigurations: new Map([
|
|
[clientId, device('10.3.0.1')],
|
|
[serverId, device('10.3.0.2')],
|
|
]),
|
|
});
|
|
engine.openTcpServerSocket(serverId, 80);
|
|
expect(engine.connectTcp(engine.openTcpClientSocket(clientId, '10.3.0.2', 80)!)).toBe(true);
|
|
expect(engine.state.tcpSessionsById.size).toBe(3);
|
|
|
|
engine.handle({ type: 'stop' });
|
|
expect(engine.state.tcpSessionsById.size).toBe(0);
|
|
expect(engine.state.tcpAcceptedSocketIdsByListenerId.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('UDP sockets', () => {
|
|
const text = (payload: Uint8Array | undefined) => new TextDecoder().decode(payload ?? new Uint8Array());
|
|
|
|
it('binds, delivers and releases a port', () => {
|
|
const [sourceNodeId, sourcePortId, targetNodeId, targetPortId] = [0x15b1, 0x15b2, 0x15b3, 0x15b4].map(uuid) as [string, string, string, string];
|
|
const engine = startedEngine({
|
|
nodes: [pc(sourceNodeId, sourcePortId), pc(targetNodeId, targetPortId)],
|
|
links: [link(sourceNodeId, sourcePortId, targetNodeId, targetPortId)],
|
|
deviceConfigurations: new Map([
|
|
[sourceNodeId, device('192.168.5.10')],
|
|
[targetNodeId, device('192.168.5.20')],
|
|
]),
|
|
});
|
|
const automatic = engine.bindUdpSocket(targetNodeId);
|
|
const automaticPort = automatic === undefined ? undefined : engine.state.socketsById.get(automatic)?.localPort;
|
|
expect(automaticPort).toBeGreaterThanOrEqual(49_152);
|
|
expect(automaticPort).toBeLessThan(65_535);
|
|
|
|
const receiver = engine.bindUdpSocket(targetNodeId, { localPort: 9000 });
|
|
const sender = engine.bindUdpSocket(sourceNodeId, { localPort: 8000, remoteIPAddress: '192.168.5.20', remotePort: 9000 });
|
|
expect(receiver).toBeDefined();
|
|
expect(sender).toBeDefined();
|
|
expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeUndefined();
|
|
|
|
engine.sendUdp(sender!, new TextEncoder().encode('hello'));
|
|
const received = engine.receiveUdp(receiver!);
|
|
expect(text(received?.datagram.payload)).toBe('hello');
|
|
expect(received?.senderIPAddress).toBe('192.168.5.10');
|
|
|
|
engine.closeSocket(receiver!);
|
|
expect(engine.bindUdpSocket(targetNodeId, { localPort: 9000 })).toBeDefined();
|
|
});
|
|
|
|
it('draws ephemeral ports without disturbing the seeded RIP jitter', () => {
|
|
const nodeId = uuid(0x15f1);
|
|
const input = { nodes: [pc(nodeId, uuid(0x15f2))], links: [], deviceConfigurations: new Map([[nodeId, device('192.168.10.10')]]) };
|
|
const socketEngine = startedEngine(input);
|
|
const ripOnlyEngine = startedEngine(input);
|
|
|
|
expect(socketEngine.bindUdpSocket(nodeId)).toBeDefined();
|
|
expect(socketEngine.nextRipJitterMilliseconds()).toBe(ripOnlyEngine.nextRipJitterMilliseconds());
|
|
});
|
|
|
|
it('broadcasts on every interface, and the receiver replies to its last peer', () => {
|
|
const [routerId, routerPortA, routerPortB, pcAId, pcAPort, pcBId, pcBPort] = [0x15e1, 0x15e2, 0x15e3, 0x15e4, 0x15e5, 0x15e6, 0x15e7].map(
|
|
uuid,
|
|
) as [string, string, string, string, string, string, string];
|
|
const engine = startedEngine(
|
|
{
|
|
nodes: [
|
|
{ id: routerId, kind: 'router', ports: [portSnapshot(routerPortA, 'rt1'), portSnapshot(routerPortB, 'rt2')] },
|
|
pc(pcAId, pcAPort),
|
|
pc(pcBId, pcBPort),
|
|
],
|
|
links: [link(routerId, routerPortA, pcAId, pcAPort), link(routerId, routerPortB, pcBId, pcBPort)],
|
|
deviceConfigurations: new Map([
|
|
[pcAId, device('10.0.0.2')],
|
|
[pcBId, device('20.0.0.2')],
|
|
]),
|
|
interfaceConfigurations: new Map([
|
|
[interfaceKey(routerId, routerPortA), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
|
|
[interfaceKey(routerId, routerPortB), { ipAddress: '20.0.0.1', subnetMask: '255.255.255.0' }],
|
|
]),
|
|
},
|
|
15,
|
|
);
|
|
const sender = engine.bindUdpSocket(routerId, { localPort: 8000 })!;
|
|
const receiverA = engine.bindUdpSocket(pcAId, { localPort: 9000 })!;
|
|
const receiverB = engine.bindUdpSocket(pcBId, { localPort: 9000 })!;
|
|
|
|
engine.sendUdp(sender, new TextEncoder().encode('broadcast'), { ipAddress: '255.255.255.255', port: 9000 });
|
|
|
|
expect(engine.receiveUdp(receiverA)?.senderIPAddress).toBe('10.0.0.1');
|
|
expect(engine.receiveUdp(receiverB)?.senderIPAddress).toBe('20.0.0.1');
|
|
expect(engine.state.socketsById.get(receiverA)?.remoteIPAddress).toBe('10.0.0.1');
|
|
expect(engine.state.socketsById.get(receiverA)?.remotePort).toBe(8000);
|
|
|
|
engine.sendUdp(receiverA, new TextEncoder().encode('reply'));
|
|
expect(text(engine.receiveUdp(sender)?.datagram.payload)).toBe('reply');
|
|
});
|
|
|
|
it('advances the virtual clock for a receive timeout and returns nothing', () => {
|
|
const nodeId = uuid(0x15d1);
|
|
const engine = startedEngine({ nodes: [pc(nodeId, uuid(0x15d2))], links: [], deviceConfigurations: new Map([[nodeId, device('10.1.0.1')]]) });
|
|
const socketId = engine.bindUdpSocket(nodeId, { localPort: 9999 })!;
|
|
|
|
expect(engine.receiveUdp(socketId, 250)).toBeUndefined();
|
|
expect(engine.state.currentTimeMilliseconds).toBe(250);
|
|
});
|
|
});
|
|
|
|
describe('Java-compatible randomness', () => {
|
|
it('draws the same RIP jitter as java.util.Random', () => {
|
|
const random = new JavaCompatibleRandom(42);
|
|
|
|
expect(Array.from({ length: 5 }, () => random.nextRipJitterMilliseconds())).toEqual([
|
|
32_475, 25_746, 32_032, 25_679, 28_287,
|
|
]);
|
|
});
|
|
|
|
it('matches java.util.Random.nextInt for bounded and power-of-two ranges', () => {
|
|
// Measured on OpenJDK 21 (29.09.2026): new java.util.Random(42),
|
|
// nextInt(100) three times, then nextInt(16) three times.
|
|
const random = new JavaCompatibleRandom(42);
|
|
|
|
expect([random.nextInt(100), random.nextInt(100), random.nextInt(100)]).toEqual([30, 63, 48]);
|
|
expect([random.nextInt(16), random.nextInt(16), random.nextInt(16)]).toEqual([0, 4, 15]);
|
|
});
|
|
});
|
|
|
|
describe('IPv4 packets', () => {
|
|
it('keeps the identity and lowers the TTL when forwarding', () => {
|
|
const packet: IPv4Packet = {
|
|
identity: 99,
|
|
senderIPAddress: '192.168.0.10',
|
|
receiverIPAddress: '192.168.1.20',
|
|
timeToLive: 2,
|
|
protocol: 'icmp',
|
|
payload: { kind: 'icmp', message: icmpMessage('echoRequest', { identifier: 3, sequenceNumber: 4 }) },
|
|
};
|
|
|
|
const forwarded = forwardingClone(packet);
|
|
const expired = forwardingClone(forwarded);
|
|
|
|
expect(forwarded).toMatchObject({ identity: 99, timeToLive: 1 });
|
|
expect(expired).toMatchObject({ identity: 99, timeToLive: 0 });
|
|
});
|
|
});
|
|
|
|
describe('Ethernet, ARP and ICMP', () => {
|
|
it('fills both ARP caches with one request and reply over a direct cable', () => {
|
|
const sourceNodeId = uuid(0x1401);
|
|
const sourcePortId = uuid(0x1411);
|
|
const targetNodeId = uuid(0x1402);
|
|
const targetPortId = uuid(0x1412);
|
|
const engine = startedEngine({
|
|
nodes: [pc(sourceNodeId, sourcePortId), pc(targetNodeId, targetPortId)],
|
|
links: [link(sourceNodeId, sourcePortId, targetNodeId, targetPortId)],
|
|
deviceConfigurations: new Map([
|
|
[sourceNodeId, device('192.168.0.10')],
|
|
[targetNodeId, device('192.168.0.20')],
|
|
]),
|
|
});
|
|
|
|
const resolved = engine.resolveMacAddress(sourceNodeId, '192.168.0.20');
|
|
|
|
expect(resolved).toBe(stableMacAddress(targetPortId));
|
|
expect(engine.state.arpCachesByNodeId.get(sourceNodeId)?.get('192.168.0.20')?.macAddress).toBe(resolved);
|
|
expect(engine.state.arpCachesByNodeId.get(targetNodeId)?.get('192.168.0.10')?.macAddress).toBe(
|
|
stableMacAddress(sourcePortId),
|
|
);
|
|
});
|
|
|
|
it('pings across a switch with Ethernet, ARP and ICMP packets', () => {
|
|
const sourceNodeId = uuid(0x1421);
|
|
const sourcePortId = uuid(0x1431);
|
|
const switchNodeId = uuid(0x1422);
|
|
const switchPortA = uuid(0x1432);
|
|
const switchPortB = uuid(0x1433);
|
|
const targetNodeId = uuid(0x1423);
|
|
const targetPortId = uuid(0x1434);
|
|
const switchNode: NodeSnapshot = {
|
|
id: switchNodeId,
|
|
kind: 'networkSwitch',
|
|
ports: [portSnapshot(switchPortA, 'sw1'), portSnapshot(switchPortB, 'sw2')],
|
|
};
|
|
const engine = startedEngine({
|
|
nodes: [pc(sourceNodeId, sourcePortId), switchNode, pc(targetNodeId, targetPortId)],
|
|
links: [
|
|
link(sourceNodeId, sourcePortId, switchNodeId, switchPortA),
|
|
link(switchNodeId, switchPortB, targetNodeId, targetPortId),
|
|
],
|
|
deviceConfigurations: new Map([
|
|
[sourceNodeId, device('192.168.0.10')],
|
|
[targetNodeId, device('192.168.0.20')],
|
|
]),
|
|
});
|
|
|
|
const result = engine.sendIcmpEcho(sourceNodeId, '192.168.0.20', { identifier: 14, sequenceNumber: 1 });
|
|
|
|
expect(result.type).toBe('delivered');
|
|
expect(engine.state.icmpObservationsByNodeId.get(sourceNodeId)?.at(-1)?.message.kind).toBe('echoReply');
|
|
expect(engine.state.switchForwardingTablesByNodeId.get(switchNodeId)?.size).toBe(2);
|
|
expect(engine.state.packetTraces.some((trace) => trace.detail === 'ARP request for 192.168.0.20')).toBe(true);
|
|
expect(
|
|
engine.state.packetTraces.some((trace) => trace.packetIdentity !== undefined && trace.layer === 'network'),
|
|
).toBe(true);
|
|
});
|
|
|
|
it('answers an expired TTL at the router with time exceeded and the original packet', () => {
|
|
const topology = routedTopology();
|
|
|
|
const result = topology.engine.sendIcmpEcho(topology.sourceNodeId, '20.0.0.2', {
|
|
timeToLive: 1,
|
|
identifier: 140,
|
|
sequenceNumber: 1,
|
|
});
|
|
|
|
expect(result).toMatchObject({ type: 'icmpError', kind: 'timeExceeded' });
|
|
const observation = topology.engine.state.icmpObservationsByNodeId.get(topology.sourceNodeId)?.at(-1);
|
|
expect(observation?.message.kind).toBe('timeExceeded');
|
|
expect(observation?.message.embeddedOriginalPacket?.timeToLive).toBe(1);
|
|
expect(observation?.message.embeddedOriginalPacket?.receiverIPAddress).toBe('20.0.0.2');
|
|
});
|
|
|
|
it('keeps the packet identity and lowers the TTL when a router forwards', () => {
|
|
const topology = routedTopology();
|
|
|
|
const result = topology.engine.sendIcmpEcho(topology.sourceNodeId, '20.0.0.2', {
|
|
timeToLive: 8,
|
|
identifier: 141,
|
|
sequenceNumber: 1,
|
|
});
|
|
|
|
expect(result.type).toBe('delivered');
|
|
const forwardingTrace = topology.engine.state.packetTraces.find(
|
|
(trace) =>
|
|
trace.nodeId === topology.routerNodeId && trace.operation === 'forwarded' && trace.detail === 'TTL decremented',
|
|
);
|
|
expect(forwardingTrace?.packetIdentity).toBeDefined();
|
|
expect(forwardingTrace?.beforeHeaders.find((field) => field.name === 'ttl')?.value).toBe('8');
|
|
expect(forwardingTrace?.afterHeaders.find((field) => field.name === 'ttl')?.value).toBe('7');
|
|
});
|
|
|
|
it('reports network unreachable when the router has no route', () => {
|
|
const topology = routedTopology();
|
|
const snapshot = topology.engine.state.topologySnapshot;
|
|
const sourceToRouter = snapshot.links.find(
|
|
(candidate) => candidate.sourceNodeId === topology.sourceNodeId || candidate.targetNodeId === topology.sourceNodeId,
|
|
);
|
|
const engine = new NetworkRuntimeEngine(14);
|
|
engine.handle({
|
|
type: 'start',
|
|
snapshot: topologySnapshot({
|
|
nodes: snapshot.nodes,
|
|
links: sourceToRouter === undefined ? [] : [sourceToRouter],
|
|
deviceConfigurations: snapshot.deviceConfigurations,
|
|
interfaceConfigurations: snapshot.interfaceConfigurations,
|
|
manualRoutesByNodeId: snapshot.manualRoutesByNodeId,
|
|
}),
|
|
seed: 14,
|
|
initialTimeMilliseconds: 0,
|
|
});
|
|
|
|
const result = engine.sendIcmpEcho(topology.sourceNodeId, '30.0.0.2', { identifier: 142, sequenceNumber: 1 });
|
|
|
|
expect(result).toMatchObject({ type: 'icmpError', kind: 'destinationNetworkUnreachable' });
|
|
});
|
|
|
|
it('reports host unreachable when a connected target never answers ARP', () => {
|
|
const sourceNodeId = uuid(0x1481);
|
|
const sourcePortId = uuid(0x1491);
|
|
const engine = startedEngine({
|
|
nodes: [pc(sourceNodeId, sourcePortId)],
|
|
links: [],
|
|
deviceConfigurations: new Map([[sourceNodeId, device('192.168.50.10')]]),
|
|
});
|
|
|
|
const result = engine.sendIcmpEcho(sourceNodeId, '192.168.50.99', { identifier: 143, sequenceNumber: 1 });
|
|
|
|
expect(result).toMatchObject({ type: 'icmpError', kind: 'destinationHostUnreachable' });
|
|
});
|
|
});
|
|
|
|
describe('switch', () => {
|
|
it('learns, floods, forwards, filters and ages its address table', () => {
|
|
const hostAId = uuid(0x1a01);
|
|
const hostAPortId = uuid(0x1a11);
|
|
const hostBId = uuid(0x1a02);
|
|
const hostBPortId = uuid(0x1a12);
|
|
const hostCId = uuid(0x1a03);
|
|
const hostCPortId = uuid(0x1a13);
|
|
const switchId = uuid(0x1a04);
|
|
const switchPorts = [1, 2, 3].map((index) => portSnapshot(uuid(0x1a20 + index), `sw${index}`));
|
|
const engine = startedEngine(
|
|
{
|
|
nodes: [
|
|
pc(hostAId, hostAPortId),
|
|
pc(hostBId, hostBPortId),
|
|
pc(hostCId, hostCPortId),
|
|
{ id: switchId, kind: 'networkSwitch', ports: switchPorts },
|
|
],
|
|
links: [
|
|
link(hostAId, hostAPortId, switchId, switchPorts[0]!.id),
|
|
link(hostBId, hostBPortId, switchId, switchPorts[1]!.id),
|
|
link(hostCId, hostCPortId, switchId, switchPorts[2]!.id),
|
|
],
|
|
switchConfigurationsByNodeId: new Map([[switchId, switchConfiguration('Classroom', 1_000)]]),
|
|
},
|
|
20,
|
|
);
|
|
const macA = stableMacAddress(hostAPortId);
|
|
const macB = stableMacAddress(hostBPortId);
|
|
const payload: EthernetPayload = {
|
|
kind: 'arp',
|
|
packet: {
|
|
operation: 'request',
|
|
senderMacAddress: macA,
|
|
senderIPAddress: '192.0.2.1',
|
|
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
|
|
targetIPAddress: '192.0.2.200',
|
|
},
|
|
};
|
|
const frame = (sourceMacAddress: string, destinationMacAddress: string): EthernetFrame => ({
|
|
identity: engine.allocateFrameIdentity(),
|
|
sourceMacAddress,
|
|
destinationMacAddress,
|
|
payload,
|
|
});
|
|
const switchTraces = (detail: string) =>
|
|
engine.state.packetTraces.filter((trace) => trace.nodeId === switchId && trace.detail === detail);
|
|
|
|
engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, '02:00:00:00:00:FE'));
|
|
expect(switchTraces('switch unknown unicast flooding')).toHaveLength(2);
|
|
|
|
engine.sendEthernetFrame(hostBId, hostBPortId, frame(macB, macA));
|
|
expect(switchTraces('switch learned unicast forwarding')).toHaveLength(1);
|
|
|
|
engine.sendEthernetFrame(hostAId, hostAPortId, frame(macA, macA));
|
|
expect(switchTraces('switch filtered destination on incoming port').length).toBeGreaterThan(0);
|
|
expect(engine.switchSatEntries(switchId)).toHaveLength(2);
|
|
|
|
const retention = 1_000;
|
|
const updateTimes = [...engine.state.switchForwardingUpdatedAtMillisecondsByNodeId.get(switchId)!.values()];
|
|
const expirationDeadlines = updateTimes.map((time) => time + retention);
|
|
const firstExpiration = Math.min(...expirationDeadlines);
|
|
const lastExpiration = Math.max(...expirationDeadlines);
|
|
|
|
engine.handle({ type: 'advance', toMilliseconds: firstExpiration - 1 });
|
|
expect(engine.switchSatEntries(switchId)).toHaveLength(2);
|
|
engine.handle({ type: 'advance', toMilliseconds: lastExpiration });
|
|
expect(engine.switchSatEntries(switchId)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('firewall', () => {
|
|
it('accepts when inactive, and an active ICMP drop bypasses the ordered rules', () => {
|
|
const routerId = uuid(0x1801);
|
|
const portId = uuid(0x1811);
|
|
const router: NodeSnapshot = { id: routerId, kind: 'router', ports: [portSnapshot(portId, 'rt1')] };
|
|
const packet: IPv4Packet = {
|
|
identity: 18,
|
|
senderIPAddress: '203.0.113.10',
|
|
receiverIPAddress: '10.0.0.1',
|
|
timeToLive: 64,
|
|
protocol: 'icmp',
|
|
payload: { kind: 'icmp', message: icmpMessage('echoRequest') },
|
|
};
|
|
const interfaces = new Map([
|
|
[interfaceKey(routerId, portId), { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }],
|
|
]);
|
|
|
|
const inactive = startedEngine(
|
|
{
|
|
nodes: [router],
|
|
links: [],
|
|
interfaceConfigurations: interfaces,
|
|
firewallConfigurationsByNodeId: new Map([[routerId, firewallConfiguration()]]),
|
|
},
|
|
18,
|
|
);
|
|
expect(inactive.evaluateFirewall(packet, routerId).accepted).toBe(true);
|
|
|
|
const active = startedEngine(
|
|
{
|
|
nodes: [router],
|
|
links: [],
|
|
interfaceConfigurations: interfaces,
|
|
firewallConfigurationsByNodeId: new Map([
|
|
[
|
|
routerId,
|
|
firewallConfiguration({
|
|
isActive: true,
|
|
defaultPolicy: 'accept',
|
|
dropIcmp: true,
|
|
rules: [firewallRule({ protocol: 'icmp', action: 'accept' })],
|
|
}),
|
|
],
|
|
]),
|
|
},
|
|
18,
|
|
);
|
|
expect(active.evaluateFirewall(packet, routerId).accepted).toBe(false);
|
|
expect(active.state.firewallDecisions.at(-1)?.ruleIndex).toBeUndefined();
|
|
expect(active.state.packetTraces.at(-1)?.operation).toBe('dropped');
|
|
});
|
|
|
|
it('lets the first matching TCP rule win, and established traffic bypasses SYN-only rules', () => {
|
|
const { engine, routerId } = firewallEngine(
|
|
firewallConfiguration({
|
|
isActive: true,
|
|
defaultPolicy: 'drop',
|
|
filterSynSegmentsOnly: true,
|
|
rules: [
|
|
firewallRule({ port: 443, protocol: 'tcp', action: 'accept' }),
|
|
firewallRule({ port: 443, protocol: 'tcp', action: 'drop' }),
|
|
],
|
|
}),
|
|
);
|
|
|
|
const synDecision = engine.evaluateFirewall(tcpPacket(181, 443, TCP_FLAG.synchronize), routerId);
|
|
expect(synDecision.accepted).toBe(true);
|
|
expect(synDecision.ruleIndex).toBe(0);
|
|
expect(engine.evaluateFirewall(tcpPacket(182, 22, TCP_FLAG.acknowledgement), routerId).accepted).toBe(true);
|
|
});
|
|
|
|
it('matches a UDP response by its explicit source port and the direct-network marker', () => {
|
|
const { engine, routerId } = firewallEngine(
|
|
firewallConfiguration({
|
|
isActive: true,
|
|
defaultPolicy: 'drop',
|
|
rules: [
|
|
firewallRule({
|
|
sourceIPAddress: DIRECTLY_CONNECTED_SOURCE_MARKER,
|
|
destinationIPAddress: '8.8.8.8',
|
|
destinationSubnetMask: '255.255.255.255',
|
|
port: 53,
|
|
protocol: 'udp',
|
|
action: 'accept',
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
const udp = (identity: number, sender: string, receiver: string, sourcePort: number, destinationPort: number): IPv4Packet => ({
|
|
identity,
|
|
senderIPAddress: sender,
|
|
receiverIPAddress: receiver,
|
|
timeToLive: 64,
|
|
protocol: 'udp',
|
|
payload: { kind: 'udp', datagram: { sourcePort, destinationPort, payload: new Uint8Array() } },
|
|
});
|
|
|
|
expect(engine.evaluateFirewall(udp(183, '10.0.0.20', '8.8.8.8', 50_001, 53), routerId).accepted).toBe(true);
|
|
expect(engine.evaluateFirewall(udp(184, '8.8.8.8', '10.0.0.20', 53, 50_001), routerId).accepted).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('packet loss and trace retention', () => {
|
|
it('drops every physical delivery while global packet loss is on', () => {
|
|
const firstNodeId = uuid(0x101);
|
|
const firstPortId = uuid(0x102);
|
|
const secondNodeId = uuid(0x103);
|
|
const secondPortId = uuid(0x104);
|
|
const engine = startedEngine({
|
|
nodes: [pc(firstNodeId, firstPortId), pc(secondNodeId, secondPortId)],
|
|
links: [link(firstNodeId, firstPortId, secondNodeId, secondPortId)],
|
|
deviceConfigurations: new Map([
|
|
[firstNodeId, device('10.0.0.1')],
|
|
[secondNodeId, device('10.0.0.2')],
|
|
]),
|
|
});
|
|
const frame: EthernetFrame = {
|
|
identity: engine.allocateFrameIdentity(),
|
|
sourceMacAddress: stableMacAddress(firstPortId),
|
|
destinationMacAddress: ETHERNET_BROADCAST_MAC_ADDRESS,
|
|
payload: {
|
|
kind: 'arp',
|
|
packet: {
|
|
operation: 'request',
|
|
senderMacAddress: stableMacAddress(firstPortId),
|
|
senderIPAddress: '10.0.0.1',
|
|
targetMacAddress: UNSPECIFIED_MAC_ADDRESS,
|
|
targetIPAddress: '10.0.0.2',
|
|
},
|
|
},
|
|
};
|
|
|
|
engine.setGlobalPacketLossEnabled(true);
|
|
engine.sendEthernetFrame(firstNodeId, firstPortId, frame);
|
|
|
|
expect(engine.state.globalPacketLossEnabled).toBe(true);
|
|
expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.size ?? 0).toBe(0);
|
|
expect(
|
|
engine.state.packetTraces.some(
|
|
(trace) =>
|
|
trace.frameIdentity === frame.identity &&
|
|
trace.nodeId === secondNodeId &&
|
|
trace.operation === 'dropped' &&
|
|
trace.detail === 'global packet-loss simulation',
|
|
),
|
|
).toBe(true);
|
|
|
|
engine.setGlobalPacketLossEnabled(false);
|
|
engine.sendEthernetFrame(firstNodeId, firstPortId, frame);
|
|
|
|
expect(engine.state.globalPacketLossEnabled).toBe(false);
|
|
expect(engine.state.arpCachesByNodeId.get(secondNodeId)?.get('10.0.0.1')?.macAddress).toBe(
|
|
stableMacAddress(firstPortId),
|
|
);
|
|
});
|
|
|
|
it('resets packet loss across stop and restart', () => {
|
|
const engine = new NetworkRuntimeEngine(42);
|
|
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 42, initialTimeMilliseconds: 0 });
|
|
engine.setGlobalPacketLossEnabled(true);
|
|
expect(engine.state.globalPacketLossEnabled).toBe(true);
|
|
|
|
engine.handle({ type: 'stop' });
|
|
expect(engine.state.globalPacketLossEnabled).toBe(false);
|
|
|
|
engine.handle({ type: 'start', snapshot: EMPTY_TOPOLOGY_SNAPSHOT, seed: 42, initialTimeMilliseconds: 0 });
|
|
expect(engine.state.globalPacketLossEnabled).toBe(false);
|
|
});
|
|
|
|
it('evicts the oldest trace events in configured batches', () => {
|
|
const nodeId = uuid(0x2001);
|
|
const interfaceId = uuid(0x2002);
|
|
const engine = new NetworkRuntimeEngine(20, traceRetentionPolicy(3, 2));
|
|
|
|
for (let identity = 1; identity <= 6; identity += 1) {
|
|
engine.recordTrace({
|
|
frameIdentity: identity,
|
|
nodeId,
|
|
interfaceId,
|
|
direction: 'outbound',
|
|
layer: 'dataLink',
|
|
operation: 'sent',
|
|
detail: `trace-${identity}`,
|
|
});
|
|
}
|
|
|
|
expect(engine.state.packetTraces.map((trace) => trace.id)).toEqual([5, 6]);
|
|
expect(engine.state.packetTraces.map((trace) => trace.detail)).toEqual(['trace-5', 'trace-6']);
|
|
expect(engine.state.discardedPacketTraceCount).toBe(4);
|
|
expect(engine.state.nextTraceIdentity).toBe(7);
|
|
});
|
|
});
|
|
|
|
describe('TCP sockets', () => {
|
|
/** Two PCs on one cable, started. */
|
|
const pair = (first: number, second: number, prefix: string) => {
|
|
const [clientId, clientPortId, serverId, serverPortId] = [first, first + 0x10, second, second + 0x10].map(uuid) as [
|
|
string,
|
|
string,
|
|
string,
|
|
string,
|
|
];
|
|
const engine = startedEngine({
|
|
nodes: [pc(clientId, clientPortId), pc(serverId, serverPortId)],
|
|
links: [link(clientId, clientPortId, serverId, serverPortId)],
|
|
deviceConfigurations: new Map([
|
|
[clientId, device(`${prefix}.1`)],
|
|
[serverId, device(`${prefix}.2`)],
|
|
]),
|
|
});
|
|
return { engine, clientId, serverId };
|
|
};
|
|
|
|
it('makes an established client and an accepted server socket with the three-way handshake', () => {
|
|
const { engine, clientId, serverId } = pair(0x1701, 0x1702, '10.17.0');
|
|
const listenerId = engine.openTcpServerSocket(serverId, 8080)!;
|
|
const clientSocketId = engine.openTcpClientSocket(clientId, '10.17.0.2', 8080)!;
|
|
|
|
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
|
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
|
|
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('ESTABLISHED');
|
|
expect(engine.state.socketsById.get(acceptedSocketId)?.tcpState).toBe('ESTABLISHED');
|
|
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_SENT -> ESTABLISHED')).toBe(true);
|
|
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP SYN_RCVD -> ESTABLISHED')).toBe(true);
|
|
});
|
|
|
|
it("sends segments of Java's MSS and reassembles one message up to the push", () => {
|
|
const { engine, clientId, serverId } = pair(0x1721, 0x1722, '10.18.0');
|
|
const listenerId = engine.openTcpServerSocket(serverId, 8081)!;
|
|
const clientSocketId = engine.openTcpClientSocket(clientId, '10.18.0.2', 8081)!;
|
|
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
|
const acceptedSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
|
|
const payload = Uint8Array.from({ length: 3_001 }, (_, index) => index % 251);
|
|
|
|
expect(engine.sendTcp(clientSocketId, payload)).toBe(true);
|
|
expect(engine.receiveTcp(acceptedSocketId)).toEqual(payload);
|
|
const payloadLengths = engine.state.packetTraces
|
|
.filter((trace) => trace.detail?.startsWith('TCP data') === true)
|
|
.map((trace) => Number(trace.afterHeaders.find((header) => header.name === 'payloadLength')?.value));
|
|
expect(payloadLengths).toEqual([1_460, 1_460, 81]);
|
|
});
|
|
|
|
it('retransmits a connection attempt at most three times and releases the port', () => {
|
|
const clientId = uuid(0x1741);
|
|
const engine = startedEngine({ nodes: [pc(clientId, uuid(0x1751))], links: [], deviceConfigurations: new Map([[clientId, device('10.19.0.1')]]) });
|
|
const socketId = engine.openTcpClientSocket(clientId, '10.19.0.99', 80)!;
|
|
const localPort = engine.state.socketsById.get(socketId)!.localPort;
|
|
|
|
expect(engine.connectTcp(socketId)).toBe(false);
|
|
expect(engine.state.tcpSessionsById.get(socketId)?.sendAttempts).toBe(3);
|
|
expect(engine.state.socketsById.get(socketId)?.tcpState).toBe('CLOSED');
|
|
expect(engine.isTcpPortReserved(clientId, localPort)).toBe(false);
|
|
expect(engine.state.packetTraces.filter((trace) => trace.detail?.includes('TCP SYN retransmission') === true)).toHaveLength(2);
|
|
});
|
|
|
|
it('drops a SYN+ACK at a listening port with no connection of its own', () => {
|
|
const { engine, clientId, serverId } = pair(0x1781, 0x1782, '10.21.0');
|
|
const listenerId = engine.openTcpServerSocket(serverId, 7000)!;
|
|
const segment = { sourcePort: 80, destinationPort: 7000, sequenceNumber: 5, acknowledgementNumber: 9, flags: TCP_FLAG.synchronize | TCP_FLAG.acknowledgement, payload: new Uint8Array() };
|
|
engine.sendIPv4Packet(clientId, {
|
|
identity: engine.allocatePacketIdentity(),
|
|
senderIPAddress: '10.21.0.1',
|
|
receiverIPAddress: '10.21.0.2',
|
|
timeToLive: 64,
|
|
protocol: 'tcp',
|
|
payload: { kind: 'tcp', segment },
|
|
});
|
|
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 1_000 });
|
|
|
|
expect(engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)).toEqual([]);
|
|
expect(engine.state.packetTraces.some((trace) => trace.detail === 'TCP destination port closed')).toBe(true);
|
|
});
|
|
|
|
it('names the flags of a segment in Swift\'s order', () => {
|
|
expect(tcpFlagDescription(TCP_FLAG.acknowledgement | TCP_FLAG.push | TCP_FLAG.reset | TCP_FLAG.synchronize | TCP_FLAG.finish)).toBe(
|
|
'FIN+SYN+RST+PSH+ACK',
|
|
);
|
|
expect(tcpFlagDescription(TCP_FLAG.synchronize | TCP_FLAG.acknowledgement)).toBe('SYN+ACK');
|
|
expect(tcpFlagDescription(0)).toBe('');
|
|
});
|
|
|
|
it("closes with Java's FIN states, and TIME_WAIT releases both ends", () => {
|
|
const { engine, clientId, serverId } = pair(0x1761, 0x1762, '10.20.0');
|
|
const listenerId = engine.openTcpServerSocket(serverId, 9090)!;
|
|
const clientSocketId = engine.openTcpClientSocket(clientId, '10.20.0.2', 9090)!;
|
|
expect(engine.connectTcp(clientSocketId)).toBe(true);
|
|
const serverSocketId = engine.state.tcpAcceptedSocketIdsByListenerId.get(listenerId)![0]!;
|
|
|
|
engine.closeTcpSocket(clientSocketId);
|
|
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('FIN_WAIT_2');
|
|
expect(engine.state.socketsById.get(serverSocketId)?.tcpState).toBe('CLOSE_WAIT');
|
|
engine.closeTcpSocket(serverSocketId);
|
|
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('TIME_WAIT');
|
|
expect(engine.state.socketsById.get(serverSocketId)?.tcpState).toBe('CLOSED');
|
|
|
|
engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + TCP_ROUND_TRIP_TIME_MILLISECONDS });
|
|
expect(engine.state.socketsById.get(clientSocketId)?.tcpState).toBe('CLOSED');
|
|
expect(engine.state.socketsById.get(listenerId)?.tcpState).toBe('LISTEN');
|
|
});
|
|
});
|