// The editor model built from a .fls file must be what the Swift import // builds (corpus.swift.json, `state`, tools/swift-oracle) — on every corpus // file Swift reads completely. On the others Swift loses devices (plan §13, // M2); there Java is the reference, checked in topology.test.ts. import { readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { defaultDisplayName, defaultSwitchConfiguration, firewallConfiguration, FIREWALL_PROTOCOL, interfaceKey, deepEqual, NetworkRuntimeEngine, portSnapshot, topologySnapshot, type Id, type NodeKind, } from '@mdg-filius/core'; import { decodeJavaBeans, importProject, readFlsArchive, readTopology, type FlsNode, type FlsTopology, type ImportedProject, } from '../src/index.js'; const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus'); interface SwiftSummary { skipped?: number; warnings?: string[]; state?: unknown; } const swiftView = JSON.parse(readFileSync(join(corpusRoot, 'corpus.swift.json'), 'utf8')) as Record; function flsFiles(directory: string): string[] { return readdirSync(directory) .flatMap((name) => { const path = join(directory, name); return statSync(path).isDirectory() ? flsFiles(path) : name.endsWith('.fls') ? [path] : []; }) .sort(); } /** Ids a test can read: node-1, node-2, … in the order they are made. */ function counter(): () => Id { let next = 0; return () => `00000000-0000-0000-0000-${String(++next).padStart(12, '0')}`; } function importFile(file: string): ImportedProject { const values = decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(file))).configurationXml); return importProject(readTopology(values), counter()); } /** * describeSystem's shape. A firewall as the core has it without one * (inactive, drop, no rules) is left out: Swift keeps a configuration only * when the file sets a field, and the core treats both alike. */ function systemOf(project: ImportedProject, nodeId: Id) { const result: Record = {}; const switchConfiguration = project.switchConfigurations.get(nodeId); if (switchConfiguration !== undefined) { result.switch = { ssid: switchConfiguration.ssid === defaultSwitchConfiguration(nodeId).ssid ? '' : switchConfiguration.ssid, retentionTimeMilliseconds: switchConfiguration.retentionTimeMilliseconds, }; } const wireless = project.hostWirelessConfigurations.get(nodeId); if (wireless !== undefined) result.hostWireless = wireless; const routes = project.manualRoutesByNodeId.get(nodeId); if (routes !== undefined) { result.manualRoutes = routes.map((route) => [route.destinationNetwork, route.subnetMask, route.gateway, route.interfaceIPAddress]); } const rip = project.ripEnabledByNodeId.get(nodeId); if (rip !== undefined) result.ripEnabled = rip; const dhcpClient = project.dhcpClientConfigurations.get(nodeId); if (dhcpClient !== undefined) result.dhcpClient = dhcpClient.isEnabled; const dhcpServer = project.dhcpServerConfigurations.get(nodeId); if (dhcpServer !== undefined) { result.dhcpServer = { ...dhcpServer, staticAssignments: dhcpServer.staticAssignments.map((entry) => [entry.macAddress, entry.ipAddress]), }; } const forwarding = project.portForwardingRowsByNodeId.get(nodeId); if (forwarding !== undefined) { result.portForwarding = forwarding.map((row) => [row.protocolValue, row.publicPortValue, row.lanIPAddress, row.lanPortValue]); } const remoteLink = project.remoteLinkConfigurations.get(nodeId); if (remoteLink !== undefined) result.remoteLink = remoteLink; const firewall = project.firewallConfigurationsByNodeId.get(nodeId); if (firewall !== undefined && !deepEqual(firewall, firewallConfiguration())) { result.firewall = { isActive: firewall.isActive, defaultPolicy: firewall.defaultPolicy === 'accept' ? 1 : 0, dropICMP: firewall.dropIcmp, filterSYNSegmentsOnly: firewall.filterSynSegmentsOnly, filterUDP: firewall.filterUdp, rules: firewall.rules.map((rule) => [ rule.sourceIPAddress, rule.sourceSubnetMask, rule.destinationIPAddress, rule.destinationSubnetMask, rule.port, FIREWALL_PROTOCOL[rule.protocol], rule.action === 'accept' ? 1 : 0, ]), }; } return result; } /** The shape describeImportedState (tools/swift-oracle) writes: indices instead of random ids. */ function asSwiftState(project: ImportedProject) { const { nodes, links } = project.graph; const nodeIndex = new Map(nodes.map((node, index) => [node.id, index])); const portIndex = (nodeId: Id, portId: Id) => nodes[nodeIndex.get(nodeId)!]!.ports.findIndex((port) => port.id === portId); return { nodes: nodes.map((node) => ({ kind: node.kind, displayName: node.displayName, hostLabelPolicy: node.hostLabelPolicy, position: [node.position.x, node.position.y], ports: node.ports.map((port) => ({ label: port.label, mac: port.importedMacAddress ?? null, occupied: port.isOccupied })), device: project.deviceConfigurations.get(node.id) ?? null, interfaces: node.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(node.id, port.id)) ?? null), system: systemOf(project, node.id), })), links: links.map((link) => [ nodeIndex.get(link.sourceNodeId), portIndex(link.sourceNodeId, link.sourcePortId), nodeIndex.get(link.targetNodeId), portIndex(link.targetNodeId, link.targetPortId), ]), documentation: project.documentationItems.map((item) => ({ id: item.id, kind: item.kind, frame: [item.frame.x, item.frame.y, item.frame.width, item.frame.height], text: item.text, color: [item.color.red, item.color.green, item.color.blue, item.color.alpha], fontName: item.fontName, fontSize: item.fontSize, isBold: item.isBold, order: item.order, })), }; } /** Java's name for a device the file leaves unnamed (German FILIUS), where Swift uses its own. */ const JAVA_DEFAULT_NAMES: Partial> = { networkSwitch: 'Switch / WLAN', gateway: 'Heimrouter', }; /** * Swift's state with the one intended difference applied: a node the file * leaves unnamed has Java's default name, not Swift's ("Switch / WLAN", not * "Switch"). A node the file names like Swift's default would fail here, not * pass unnoticed. */ function withJavaDefaultNames(state: unknown): unknown { const { nodes, ...rest } = state as { nodes: { kind: NodeKind; displayName: string }[] }; return { ...rest, nodes: nodes.map((node) => node.displayName === defaultDisplayName(node.kind) && JAVA_DEFAULT_NAMES[node.kind] !== undefined ? { ...node, displayName: JAVA_DEFAULT_NAMES[node.kind] } : node, ), }; } /** * Where the Swift import is wrong and Java (corpus.java.json) is right, per * file and node: the corrected fields. */ const SWIFT_ERRORS: Record> = { // useIPAsName is ``, the shared result of a // button's isEnabled() — true. Swift's reader cannot follow it: it shows // the label text as a manual name instead of the IP address. 'java-examples/multicast_server.fls': { 0: { displayName: 'Rechner', hostLabelPolicy: 'ipAddress' }, 1: { displayName: 'Notebook', hostLabelPolicy: 'ipAddress' }, 2: { displayName: 'Notebook', hostLabelPolicy: 'ipAddress' }, }, }; function withSwiftErrorsCorrected(name: string, state: unknown): unknown { const corrections = SWIFT_ERRORS[name] ?? {}; const { nodes, ...rest } = state as { nodes: object[] }; return { ...rest, nodes: nodes.map((node, index) => ({ ...node, ...corrections[index] })) }; } const complete = (summary: SwiftSummary) => summary.skipped === 0 && !(summary.warnings ?? []).some((warning) => warning.startsWith('Skipped')); describe('corpus: the imported model as Swift builds it', () => { const files = flsFiles(corpusRoot).filter((file) => complete(swiftView[relative(corpusRoot, file)]!)); it('has files to compare', () => { expect(files.length).toBe(13); }); for (const file of files) { const name = relative(corpusRoot, file); it(name, () => { const project = importFile(file); expect(asSwiftState(project)).toEqual(withSwiftErrorsCorrected(name, withJavaDefaultNames(swiftView[name]!.state))); expect(project.warnings).toEqual([]); }); } }); describe('corpus: every file imports completely', () => { const javaView = JSON.parse(readFileSync(join(corpusRoot, 'corpus.java.json'), 'utf8')) as Record< string, { nodes: unknown[]; cables: unknown[]; documentation: unknown[] } >; // Also the files where Swift loses devices: as many devices and cables as // Java has, and nothing left out. for (const file of flsFiles(corpusRoot)) { const name = relative(corpusRoot, file); it(name, () => { const project = importFile(file); expect(project.graph.nodes).toHaveLength(javaView[name]!.nodes.length); expect(project.graph.links).toHaveLength(javaView[name]!.cables.length); expect(project.warnings).toEqual([]); }); } }); /** The core started on an imported project (the editor will derive this snapshot from its state). */ function startedEngine(project: ImportedProject): NetworkRuntimeEngine { const engine = new NetworkRuntimeEngine(14); engine.handle({ type: 'start', snapshot: topologySnapshot({ nodes: project.graph.nodes.map((node) => ({ id: node.id, kind: node.kind, ports: node.ports.map((port) => portSnapshot(port.id, port.label, port.importedMacAddress)), })), links: project.graph.links, deviceConfigurations: project.deviceConfigurations, interfaceConfigurations: project.interfaceConfigurations, manualRoutesByNodeId: project.manualRoutesByNodeId, ripEnabledByNodeId: project.ripEnabledByNodeId, firewallConfigurationsByNodeId: project.firewallConfigurationsByNodeId, switchConfigurationsByNodeId: project.switchConfigurations, }), seed: 14, initialTimeMilliseconds: 0, }); return engine; } describe('an imported file runs in the simulation core', () => { it('pings across the router from the notebook with a gateway, not from the one without', () => { const project = importFile(join(corpusRoot, 'java-examples/zwei_rechnernetze_komplett.fls')); const byName = (displayName: string) => project.graph.nodes.find((node) => node.displayName === displayName)!.id; const engine = startedEngine(project); expect(engine.sendIcmpEcho(byName('Notebook 1.10'), '141.99.2.10').type).toBe('delivered'); expect(engine.sendIcmpEcho(byName('Notebook 1.11'), '141.99.2.10')).toMatchObject({ type: 'icmpError', kind: 'destinationNetworkUnreachable' }); expect(engine.sendIcmpEcho(byName('Notebook 1.11'), '141.99.1.10').type).toBe('delivered'); }); it('pings across three routers on manual routes and the routers\' default gateways', () => { // Router 1 (141.99.1/2) sends everything else to 2.2; router 2 has manual // routes to 1.0 and 5.0; router 3 (141.99.4/5) sends everything else to // 4.1. The way back needs router 3's default gateway — which Swift drops. const project = importFile(join(corpusRoot, 'java-examples/internetwork_komplett.fls')); const notebook = project.graph.nodes.find((node) => node.displayName === 'Notebook 1.10')!.id; const routers = project.graph.nodes.filter((node) => node.kind === 'router').map((node) => node.id); expect(routers.map((router) => project.deviceConfigurations.get(router)?.defaultGateway)).toEqual([ '141.99.2.2', '141.99.4.2', '141.99.4.1', ]); expect(startedEngine(project).sendIcmpEcho(notebook, '141.99.5.10').type).toBe('delivered'); const withoutRouterGateways = { ...project, deviceConfigurations: new Map(project.deviceConfigurations) }; for (const router of routers) withoutRouterGateways.deviceConfigurations.delete(router); expect(startedEngine(withoutRouterGateways).sendIcmpEcho(notebook, '141.99.5.10').type).not.toBe('delivered'); }); }); // ── Rules the corpus does not exercise ───────────────────────────────────── function flsNode(javaClass: FlsNode['javaClass'], fields: Partial = {}): FlsNode { return { javaClass, name: javaClass, x: 0, y: 0, useIpAsName: false, useMacAsName: false, interfaces: [], portCount: 0, system: {}, source: { kind: 'object', className: javaClass, id: undefined, args: [], staticField: undefined, factoryMethod: undefined, properties: new Map(), calls: [], presetElements: new Map(), presetEntries: new Map(), fromGetter: false }, ...fields, }; } function topology(fields: Partial): FlsTopology { return { versionLine: null, nodes: [], links: [], documentation: [], warnings: [], ...fields }; } const nic = (ip: string | null, mask: string | null = '255.255.255.0') => ({ ip, mask, mac: undefined, gateway: '', dns: '' }); describe('importProject rules', () => { it('labels a gateway wan0, lan0, gw2 and gives a rejected address the default', () => { const project = importProject( topology({ nodes: [flsNode('Gateway', { interfaces: [nic(null, null), nic('10.0.0.1'), nic('10.1.0.1')] })] }), counter(), ); const gateway = project.graph.nodes[0]!; expect(gateway.ports.map((port) => port.label)).toEqual(['wan0', 'lan0', 'gw2']); expect(gateway.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(gateway.id, port.id)))).toEqual([ { ipAddress: '42.0.0.10', subnetMask: '255.0.0.0' }, { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }, { ipAddress: '10.1.0.1', subnetMask: '255.255.255.0' }, ]); expect(project.deviceConfigurations.get(gateway.id)).toEqual({ ipAddress: '42.0.0.10', subnetMask: '255.0.0.0', defaultGateway: '', dnsServer: '', }); }); it('keeps an empty name and shows the IP address only on hosts', () => { const project = importProject( topology({ nodes: [ flsNode('Vermittlungsrechner', { name: '', useIpAsName: true }), flsNode('Notebook', { useIpAsName: true, useMacAsName: true }), ], }), counter(), ); expect(project.graph.nodes.map((node) => [node.displayName, node.hostLabelPolicy])).toEqual([ ['', 'manual'], ['Notebook', 'ipAndMAC'], ]); }); it("takes a switch's and a router's port count from Java, and gives a rejected router address the default", () => { const project = importProject( topology({ nodes: [flsNode('Switch', { portCount: 3 }), flsNode('Vermittlungsrechner', { interfaces: [nic('10.0.0.1'), nic(null)] })], }), counter(), ); const [networkSwitch, router] = project.graph.nodes; expect(networkSwitch!.ports.map((port) => port.label)).toEqual(['sw1', 'sw2', 'sw3']); expect(router!.ports.map((port) => project.interfaceConfigurations.get(interfaceKey(router!.id, port.id)))).toEqual([ { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0' }, { ipAddress: '192.168.0.10', subnetMask: '255.255.255.0' }, ]); }); it('reads bold from the font style and alpha from the color', () => { const project = importProject( topology({ documentation: [ { type: 2, text: 'b', x: 0, y: 0, width: 50, height: 30, color: [0, 0, 0, 51], font: { name: 'Dialog', size: 18, style: 1 } }, ], }), counter(), ); expect(project.documentationItems[0]).toMatchObject({ isBold: true, fontSize: 18, color: { alpha: 0.2 } }); }); it("maps a router's firewall, RIP and routes, and says what it cannot map", () => { const project = importProject( topology({ nodes: [ flsNode('Vermittlungsrechner', { interfaces: [nic('10.0.0.1')], system: { ripEnabled: true, manualRoutes: [ ['10.1.0.0', '255.255.0.0', '10.0.0.2', '10.0.0.1'], ['10.2.0.0', '255.255.0.0', null, '10.0.0.1'], ], firewall: { activated: true, defaultPolicy: 1, dropICMP: true, filterSYNSegmentsOnly: false, filterUdp: false, rules: [ { srcIP: '10.0.0.0', srcMask: '255.0.0.0', destIP: null, destMask: '', port: 80, protocol: -1, action: 1 }, { srcIP: '', srcMask: '', destIP: '', destMask: '', port: -1, protocol: 99, action: 0 }, ], }, }, }), ], }), counter(), ); const router = project.graph.nodes[0]!.id; expect(project.ripEnabledByNodeId.get(router)).toBe(true); expect(project.manualRoutesByNodeId.get(router)).toEqual([ { destinationNetwork: '10.1.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.2', interfaceIPAddress: '10.0.0.1' }, ]); expect(project.firewallConfigurationsByNodeId.get(router)).toEqual({ isActive: true, defaultPolicy: 'accept', dropIcmp: true, filterSynSegmentsOnly: false, filterUdp: false, rules: [ { sourceIPAddress: '10.0.0.0', sourceSubnetMask: '255.0.0.0', destinationIPAddress: '', destinationSubnetMask: '', port: 80, protocol: 'all', action: 'accept', }, { sourceIPAddress: '', sourceSubnetMask: '', destinationIPAddress: '', destinationSubnetMask: '', port: -1, protocol: 'tcp', action: 'drop', }, ], }); expect(project.warnings).toEqual([ "node 'Vermittlungsrechner': a manual route without four entries, left out", "node 'Vermittlungsrechner': firewall protocol 99 unknown, used TCP", ]); }); it("gives a switch without SSID one from its id, and joins a host to a wireless network only with an SSID", () => { const project = importProject( topology({ nodes: [ flsNode('Switch', { portCount: 24, system: { ssid: undefined, retentionTime: 2000 } }), flsNode('Switch', { portCount: 24, system: { ssid: ' MyWiFi ', retentionTime: 300_000 } }), flsNode('Notebook', { interfaces: [nic('10.0.0.1')], system: { ssid: 'MyWiFi', manualRoutes: [] } }), flsNode('Rechner', { interfaces: [nic('10.0.0.2')], system: { ssid: ' ', manualRoutes: [] } }), ], }), counter(), ); const [first, second, notebook, pc] = project.graph.nodes.map((node) => node.id); expect(project.switchConfigurations.get(first!)).toEqual({ ssid: defaultSwitchConfiguration(first!).ssid, retentionTimeMilliseconds: 2000 }); expect(project.switchConfigurations.get(second!)).toEqual({ ssid: 'MyWiFi', retentionTimeMilliseconds: 300_000 }); expect(project.hostWirelessConfigurations.get(notebook!)).toEqual({ isEnabled: true, ssid: 'MyWiFi' }); expect(project.hostWirelessConfigurations.has(pc!)).toBe(false); }); it('takes DHCP settings for hosts and home routers, trimmed, and only when they are not the defaults', () => { const defaults = { isActive: false, lowerBound: '0.0.0.0', upperBound: '0.0.0.0', gateway: '0.0.0.0', dnsServer: '0.0.0.0', useOwnSettings: false, staticAssignedAddresses: [] }; const project = importProject( topology({ nodes: [ flsNode('Rechner', { interfaces: [nic('10.0.0.1')], system: { dhcpClient: false, dhcpServer: { ...defaults, isActive: true, lowerBound: ' 10.0.0.100 ', upperBound: '10.0.0.150', staticAssignedAddresses: [{ mac: '0A:00:00:00:00:01', ip: '10.0.0.101' }], }, }, }), flsNode('Notebook', { interfaces: [nic('10.0.0.2')], system: { dhcpClient: true, dhcpServer: defaults } }), flsNode('Vermittlungsrechner', { interfaces: [nic('10.0.0.3')], system: { dhcpClient: true, dhcpServer: { ...defaults, isActive: true } }, }), ], }), counter(), ); const [pc, notebook, router] = project.graph.nodes.map((node) => node.id); expect(project.dhcpServerConfigurations.get(pc!)).toMatchObject({ isActive: true, lowerBoundIPAddress: '10.0.0.100', upperBoundIPAddress: '10.0.0.150', staticAssignments: [{ macAddress: '0A:00:00:00:00:01', ipAddress: '10.0.0.101' }], }); expect([...project.dhcpServerConfigurations.keys()]).toEqual([pc]); expect([...project.dhcpClientConfigurations]).toEqual([[notebook, { isEnabled: true }]]); expect(project.warnings).toEqual([ "node 'Vermittlungsrechner': DHCP client on a router, ignored", "node 'Vermittlungsrechner': DHCP server settings on a router, ignored", ]); expect(router).toBeDefined(); }); it('takes port forwarding rows of a home router and pairs modems by port', () => { const project = importProject( topology({ nodes: [ flsNode('Gateway', { interfaces: [nic('42.0.0.10'), nic('192.168.0.1')], system: { staticNAT: [[' TCP', '80', '192.168.0.10', '8080 '], ['UDP', null, '192.168.0.11', '53']] }, }), flsNode('Modem', { portCount: 1, system: { modem: { mode: 1, port: 4242, ipAddress: 'localhost' } } }), flsNode('Modem', { portCount: 1, system: { modem: { mode: 7, port: 70_000, ipAddress: 'localhost' } } }), ], }), counter(), ); const [gateway, server, broken] = project.graph.nodes.map((node) => node.id); expect(project.portForwardingRowsByNodeId.get(gateway!)).toEqual([ { protocolValue: 'TCP', publicPortValue: '80', lanIPAddress: '192.168.0.10', lanPortValue: '8080' }, ]); expect(project.remoteLinkConfigurations.get(server!)).toEqual({ pairIdentifier: 'java-modem-port-4242', latencyMilliseconds: 20, isEnabled: true }); expect(project.remoteLinkConfigurations.get(broken!)?.pairIdentifier).toBe('java-modem-port-12345'); expect(project.warnings).toEqual([ "node 'Gateway': a port forwarding row without four entries, left out", "node 'Modem': modem port 70000 is invalid, used 12345", "node 'Modem': modem mode 7 unknown, treated as client", ]); }); it('leaves out a cable to itself and one to a port that already has a cable', () => { const project = importProject( topology({ nodes: [flsNode('Switch', { portCount: 24 }), flsNode('Rechner', { interfaces: [nic('10.0.0.1')] })], links: [ { ends: [{ node: 0, port: 0 }, { node: 0, port: 1 }], wireless: false }, { ends: [{ node: 1, port: 0 }, { node: 0, port: 2 }], wireless: false }, { ends: [{ node: 0, port: 3 }, { node: 1, port: 0 }], wireless: false }, ], }), counter(), ); expect(project.graph.links).toHaveLength(1); expect(project.warnings).toEqual([ 'cable 1: connects a node to itself, left out', 'cable 3: a port already has a cable, left out', ]); }); it('leaves out documentation items without size or of an unknown type, and colors the one without color', () => { const item = { text: 'Hallo', x: 10, y: 20, width: 100, height: 30, font: null }; const project = importProject( topology({ documentation: [ { ...item, type: 2, color: null }, { ...item, type: 1, width: 0, color: [1, 2, 3, 255] }, { ...item, type: 3, color: [1, 2, 3, 255] }, ], }), counter(), ); expect(project.documentationItems.map((doc) => [doc.kind, doc.text, doc.color, doc.fontName, doc.fontSize])).toEqual([ ['text', 'Hallo', { red: 0, green: 0, blue: 0, alpha: 1 }, 'Dialog', 12], ]); expect(project.warnings).toEqual([ 'documentation item 1 without a color, given the default', 'documentation item without size, left out', 'documentation item of type 3, left out (known: RECT = 1, TEXT = 2)', ]); }); });