importProject turns a PC's or notebook's Java file tree into Swift's virtual file system, as Swift's registerVirtualFileSystem does: images by type or extension from Base64, text by type, unknown types as binary data or text, unsafe names and collisions left out with a warning, and the whole import rejected over the project quota. NSString.pathExtension is measured in the oracle, which now writes every imported file system. On the 13 files Swift reads completely every file matches, images compared by hash. Intended: a host whose file has no file system stays empty, as in Java, where Swift adds the demo files of a new device. Pending the applications step, the comparison reads our /webserver as Swift's /www and leaves out the e-mail and Gnutella state files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
761 lines
32 KiB
TypeScript
761 lines
32 KiB
TypeScript
// 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 { createHash } from 'node:crypto';
|
|
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,
|
|
VirtualFileSystem,
|
|
type Id,
|
|
type NodeKind,
|
|
type VirtualFileEntry,
|
|
} from '@mdg-filius/core';
|
|
import {
|
|
decodeJavaBeans,
|
|
importProject,
|
|
readFlsArchive,
|
|
readTopology,
|
|
type FlsFileTree,
|
|
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<string, SwiftSummary>;
|
|
|
|
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());
|
|
}
|
|
|
|
/** describeFileEntries' shape (tools/swift-oracle). */
|
|
function describeFileEntries(entries: VirtualFileEntry[]): unknown[] {
|
|
return entries.map(({ path, content }) => {
|
|
switch (content.kind) {
|
|
case 'directory':
|
|
return { path, kind: 'directory' };
|
|
case 'text':
|
|
return { path, kind: 'text', text: content.text };
|
|
case 'binary':
|
|
case 'image':
|
|
return {
|
|
path,
|
|
kind: content.kind,
|
|
bytes: content.data.length,
|
|
mediaType: content.mediaType ?? null,
|
|
sha256: createHash('sha256').update(content.data).digest('hex'),
|
|
};
|
|
}
|
|
});
|
|
}
|
|
|
|
function fileEntriesOf(fileSystem: VirtualFileSystem | undefined): unknown {
|
|
return fileSystem === undefined ? null : describeFileEntries(fileSystem.allEntries());
|
|
}
|
|
|
|
const DEFAULT_FILES = describeFileEntries(VirtualFileSystem.defaultForDevice().allEntries());
|
|
const EMPTY_FILES = describeFileEntries(new VirtualFileSystem().allEntries());
|
|
|
|
/**
|
|
* 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<string, unknown> = {};
|
|
const switchConfiguration = project.switchConfigurations.get(nodeId);
|
|
if (switchConfiguration !== undefined) {
|
|
result.switch = {
|
|
ssid: switchConfiguration.ssid === defaultSwitchConfiguration(nodeId).ssid ? '<from node id>' : 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),
|
|
fileSystem: fileEntriesOf(project.virtualFileSystems.get(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<Record<NodeKind, string>> = {
|
|
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; fileSystem: unknown }[] };
|
|
return {
|
|
...rest,
|
|
nodes: nodes.map((node) => {
|
|
const named =
|
|
node.displayName === defaultDisplayName(node.kind) && JAVA_DEFAULT_NAMES[node.kind] !== undefined
|
|
? { ...node, displayName: JAVA_DEFAULT_NAMES[node.kind] }
|
|
: node;
|
|
// A host the file gives no files: empty, as in Java — Swift fills in
|
|
// the demo files of a new device.
|
|
return deepEqual(named.fileSystem, DEFAULT_FILES) ? { ...named, fileSystem: EMPTY_FILES } : named;
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Where the Swift import is wrong and Java (corpus.java.json) is right, per
|
|
* file and node: the corrected fields.
|
|
*/
|
|
const SWIFT_ERRORS: Record<string, Record<number, { displayName: string; hostLabelPolicy: string }>> = {
|
|
// useIPAsName is `<object idref="Boolean0"/>`, 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] })) };
|
|
}
|
|
|
|
/**
|
|
* What applications do to a file system, which the applications step ports:
|
|
* Swift keeps the e-mail and Gnutella state as files (left out on both
|
|
* sides here), and moves a web server's /webserver to /www (compared here
|
|
* by reading our /webserver as /www where Swift has one). Everything else
|
|
* in a file system must match.
|
|
*/
|
|
const APPLICATION_DIRECTORIES = ['/mail', '/mailserver', '/peer2peer'];
|
|
|
|
type FileEntries = { path: string }[] | null;
|
|
|
|
function withoutApplicationDirectories(state: unknown, webRootOf?: (index: number) => boolean): unknown {
|
|
const { nodes, ...rest } = state as { nodes: { fileSystem: FileEntries }[] };
|
|
const outside = (entry: { path: string }) =>
|
|
!APPLICATION_DIRECTORIES.some((directory) => entry.path === directory || entry.path.startsWith(`${directory}/`));
|
|
const asWebRoot = (entry: { path: string }) =>
|
|
entry.path === '/webserver' || entry.path.startsWith('/webserver/')
|
|
? { ...entry, path: `/www${entry.path.slice('/webserver'.length)}` }
|
|
: entry;
|
|
return {
|
|
...rest,
|
|
nodes: nodes.map((node, index) => {
|
|
if (node.fileSystem === null) return node;
|
|
const entries = node.fileSystem.filter(outside).map((entry) => (webRootOf?.(index) ? asWebRoot(entry) : entry));
|
|
return { ...node, fileSystem: entries.sort((lhs, rhs) => (lhs.path < rhs.path ? -1 : lhs.path > rhs.path ? 1 : 0)) };
|
|
}),
|
|
};
|
|
}
|
|
|
|
/** Whether Swift moved node `index`'s web files to /www. */
|
|
function swiftWebRoots(state: unknown): (index: number) => boolean {
|
|
const { nodes } = state as { nodes: { fileSystem: FileEntries }[] };
|
|
return (index) => nodes[index]?.fileSystem?.some((entry) => entry.path === '/www') ?? false;
|
|
}
|
|
|
|
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);
|
|
|
|
const expected = withSwiftErrorsCorrected(name, withJavaDefaultNames(swiftView[name]!.state));
|
|
expect(withoutApplicationDirectories(asSwiftState(project), swiftWebRoots(expected))).toEqual(
|
|
withoutApplicationDirectories(expected),
|
|
);
|
|
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> = {}): 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>): 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("maps a host's files by type and extension, as Swift does, and says what it had to guess", () => {
|
|
const file = (name: string | null, type: string | null, content: string | null): FlsFileTree => ({
|
|
kind: 'file',
|
|
name,
|
|
type,
|
|
content,
|
|
children: [],
|
|
});
|
|
const directory = (name: string | null, children: FlsFileTree[]): FlsFileTree => ({ kind: 'directory', name, children });
|
|
const project = importProject(
|
|
topology({
|
|
nodes: [
|
|
flsNode('Rechner', {
|
|
interfaces: [nic('10.0.0.1')],
|
|
system: {
|
|
fileSystem: directory('root', [
|
|
directory(' docs ', [
|
|
file('a.txt', 'text/txt', 'hallo'),
|
|
file('pic.JPG', 'txt', 'AAEC'),
|
|
file('bad.png', 'png', 'not base64!'),
|
|
file('blob.bin', 'Bin', 'AAEC'),
|
|
file('odd.xyz', 'xyz', 'plain words'),
|
|
file('empty.dat', 'dat', '!!!'),
|
|
file('icon', 'image', 'AAEC'),
|
|
file(' ', 'txt', 'x'),
|
|
file('A.TXT', 'txt', 'collides'),
|
|
]),
|
|
directory(null, [file('lost.txt', 'txt', 'x')]),
|
|
directory('docs', []),
|
|
]),
|
|
},
|
|
}),
|
|
],
|
|
}),
|
|
counter(),
|
|
);
|
|
const files = project.virtualFileSystems.get(project.graph.nodes[0]!.id)!;
|
|
|
|
expect(files.allEntries().map(({ path, content }) => [path, content.kind, 'mediaType' in content ? content.mediaType : undefined])).toEqual([
|
|
['/', 'directory', undefined],
|
|
['/docs', 'directory', undefined],
|
|
['/docs/a.txt', 'text', undefined],
|
|
['/docs/bad.png', 'binary', 'application/png'],
|
|
['/docs/blob.bin', 'binary', 'application/bin'],
|
|
['/docs/empty.dat', 'text', undefined],
|
|
['/docs/icon', 'image', 'image/png'],
|
|
['/docs/odd.xyz', 'text', undefined],
|
|
['/docs/pic.JPG', 'image', 'image/txt'],
|
|
]);
|
|
expect(project.warnings).toEqual([
|
|
"Java file '/docs/bad.png' declared image type 'png' but was not valid Base64; imported as UTF-8 binary data.",
|
|
"Java file '/docs/blob.bin' used unknown type 'bin'; preserved as binary data.",
|
|
"Java file '/docs/odd.xyz' used unknown type 'xyz'; preserved as UTF-8 text.",
|
|
"Java file '/docs/empty.dat' used unknown type 'dat'; preserved as UTF-8 text.",
|
|
"Ignored Java filesystem file with missing or unsafe name on 'Rechner'.",
|
|
"Ignored Java filesystem file '/docs/A.TXT' on 'Rechner': caseInsensitiveSiblingCollision(/docs/a.txt, /docs/A.TXT)",
|
|
"Ignored Java filesystem directory with missing or unsafe name on 'Rechner'.",
|
|
"Ignored Java filesystem directory '/docs' on 'Rechner': itemAlreadyExists(/docs)",
|
|
]);
|
|
});
|
|
|
|
it('leaves out a file over the per-file quota, and rejects an import over the project quota', () => {
|
|
const tooBig = 'A'.repeat(8 * 1024 * 1024 + 1);
|
|
const host = (files: FlsFileTree[]) =>
|
|
flsNode('Rechner', { interfaces: [nic('10.0.0.1')], system: { fileSystem: { kind: 'directory', name: 'root', children: files } } });
|
|
const single = importProject(topology({ nodes: [host([{ kind: 'file', name: 'big.txt', type: 'txt', content: tooBig, children: [] }])] }), counter());
|
|
|
|
expect(single.virtualFileSystems.get(single.graph.nodes[0]!.id)!.contains('/big.txt')).toBe(false);
|
|
expect(single.warnings).toEqual([
|
|
"Ignored Java filesystem file '/big.txt' on 'Rechner': fileSizeQuotaExceeded(/big.txt, 8388609, 8388608)",
|
|
]);
|
|
|
|
// Three hosts at their 32 MiB limit exceed the 64 MiB project limit:
|
|
// the import fails as a whole (one 8 MiB string, shared).
|
|
const limit = 'A'.repeat(8 * 1024 * 1024);
|
|
const fullHost = () =>
|
|
host([0, 1, 2, 3].map((index): FlsFileTree => ({ kind: 'file', name: `f${index}.txt`, type: 'txt', content: limit, children: [] })));
|
|
expect(() => importProject(topology({ nodes: [fullHost(), fullHost(), fullHost()] }), counter())).toThrow(
|
|
'totalSizeQuotaExceeded(100663296, 67108864)',
|
|
);
|
|
|
|
// Three devices at their 32 MiB limit exceed the 64 MiB project limit.
|
|
const full = new Uint8Array(8 * 1024 * 1024);
|
|
const devices = [0, 1, 2].map(() => {
|
|
const fileSystem = new VirtualFileSystem();
|
|
for (let index = 0; index < 4; index++) fileSystem.writeBinaryFile(`/f${index}`, full);
|
|
return fileSystem;
|
|
});
|
|
expect(() => VirtualFileSystem.validateProjectQuotas(devices)).toThrow('totalSizeQuotaExceeded(100663296, 67108864)');
|
|
});
|
|
|
|
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)',
|
|
]);
|
|
});
|
|
});
|