exportProject.ts, step X4a: each installed program of a PC or notebook as the FILIUS application of its class, keyed by that class name as Java keys them, with its settings: servers switched on as the model has them, a web server's port and virtual hosts, the DNS server's recursion on port 53, the echo server's port, the generic client's target (none as null, as its constructor leaves it), the peer count, the personal firewall. E-mail contents are the next step. The model lost a web server's virtual hosts switch (useVHost, on for 3 web servers in the corpus); Swift sets it off on import. The core now has WebServerConfiguration.virtualHosts (Swift's name), the import carries Java's value, and the Swift comparison names Swift's error. Checked by Java: every corpus file, imported, exported and written, loads with the programs of the original, with no new departure. In the synthetic model the PC has every program with settings away from the defaults, the notebook programs without settings (Java's defaults, 3 peers), where Java makes /peer2peer itself. 12 mutation probes, each loaded by Java, were all caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
820 lines
35 KiB
TypeScript
820 lines
35 KiB
TypeScript
// Every corpus file, imported into the editor model and exported again:
|
|
// Java FILIUS loads the written file (tools/java-oracle, exported.java.json)
|
|
// to the topology of the original (corpus.java.json) — up to what the model
|
|
// decides differently, named below (DEPARTURES).
|
|
//
|
|
// UPDATE_EXPORTED=1 writes the files to test/exported/ (not checked in); the
|
|
// Java oracle then loads them and records each file's SHA-256, which must
|
|
// match what the export makes now.
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join, relative } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import {
|
|
defaultSwitchConfiguration,
|
|
dhcpServerConfiguration,
|
|
DOCUMENTATION_MINIMUM_SIZE,
|
|
firewallConfiguration,
|
|
firewallRule,
|
|
stableGeneratedMacAddress,
|
|
type Id,
|
|
type InstallableProgram,
|
|
type TopologyNode,
|
|
VirtualFileSystem,
|
|
} from '@mdg-filius/core';
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
decodeJavaBeans,
|
|
exportProject,
|
|
importProject,
|
|
javaBase64,
|
|
readFlsArchive,
|
|
readTopology,
|
|
writeFlsArchive,
|
|
type ProjectToExport,
|
|
} from '../src/index.js';
|
|
import { writeTopologyXml } from '../src/topologyWriter.js';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const corpusRoot = join(here, '../../../corpus');
|
|
const exportedRoot = join(here, 'exported');
|
|
|
|
function flsFiles(directory: string): string[] {
|
|
return readdirSync(directory).flatMap((name) => {
|
|
const path = join(directory, name);
|
|
if (statSync(path).isDirectory()) return flsFiles(path);
|
|
return name.endsWith('.fls') ? [path] : [];
|
|
});
|
|
}
|
|
|
|
/** Ids a test can read, in the order they are made. */
|
|
function counter(): () => Id {
|
|
let next = 0;
|
|
return () => `00000000-0000-0000-0000-${String(++next).padStart(12, '0')}`;
|
|
}
|
|
|
|
type JavaNode = {
|
|
class: string;
|
|
name: string | null;
|
|
bounds: number[];
|
|
useIpAsName?: boolean;
|
|
useMacAsName?: boolean;
|
|
ports: unknown;
|
|
system: Record<string, unknown>;
|
|
};
|
|
type JavaFile = { success: boolean; nodes: JavaNode[]; cables: unknown[]; documentation: unknown[]; sha256?: string };
|
|
|
|
const original = JSON.parse(readFileSync(join(corpusRoot, 'corpus.java.json'), 'utf8')) as Record<string, JavaFile>;
|
|
const exportedPath = join(here, 'exported.java.json');
|
|
const exported = JSON.parse(readFileSync(exportedPath, 'utf8')) as Record<string, JavaFile>;
|
|
|
|
/**
|
|
* The system software without what a later step exports — the e-mail
|
|
* programs' domain, bean accounts, folders and address book; files in any
|
|
* order.
|
|
*/
|
|
function systemOf(system: Record<string, unknown>) {
|
|
const settings =
|
|
system.applications === undefined
|
|
? system
|
|
: {
|
|
...system,
|
|
applications: (system.applications as Record<string, unknown>[]).map(
|
|
({ mailDomain: _domain, beanAccounts: _accounts, received: _received, sent: _sent, drafts: _drafts, addressBook: _book, ...rest }) => rest,
|
|
),
|
|
};
|
|
return settings.fileSystem === undefined || settings.fileSystem === null
|
|
? settings
|
|
: { ...settings, fileSystem: sortedTree(settings.fileSystem as JavaTree) };
|
|
}
|
|
|
|
/** The topology of Java's view: the label's position, not its size. */
|
|
function topologyOf(view: JavaFile) {
|
|
return {
|
|
success: view.success,
|
|
nodes: view.nodes.map((node) => ({
|
|
class: node.class,
|
|
name: node.name,
|
|
position: node.bounds.slice(0, 2),
|
|
useIpAsName: node.useIpAsName,
|
|
useMacAsName: node.useMacAsName,
|
|
ports: node.ports,
|
|
system: systemOf(node.system),
|
|
})),
|
|
cables: view.cables,
|
|
documentation: view.documentation,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* What the model decides differently from the Java file it was imported
|
|
* from, each applied to Java's view of the original:
|
|
* - generatedMac: a card whose MAC the file lacked (Java rolls a new one at
|
|
* each load) has the one the simulation uses, derived from its port id;
|
|
* - documentation: the items in the model's render order (rectangles below
|
|
* text), a missing text empty, a missing font Dialog 12, and at least the
|
|
* editor's smallest size (DOCUMENTATION_MINIMUM_SIZE);
|
|
* - switchSsid: a switch's SSID the file lacked (Java rolls one) is the one
|
|
* the model derives from the node's id;
|
|
* - modemRoles: the model keeps a pair of modems, not which one serves: the
|
|
* first of a pair in node order serves, the others connect. Both ways
|
|
* work within one project, where the client connects to localhost;
|
|
* - routerFiles: the model holds no files for a router or home router; what
|
|
* the file adds to the ones its constructor makes (a web server's
|
|
* www.conf) is not written;
|
|
* - fileTypes: a file's type follows from its kind in the model (fileType
|
|
* below), not the one the file named — Java names them unevenly itself
|
|
* (txt, text/txt, html, none);
|
|
* - fileOrder: directories first, then by name (compared regardless of
|
|
* order here).
|
|
*/
|
|
const DEPARTURES = {
|
|
generatedMac(view: JavaFile, macs: (string | undefined)[][]): JavaFile {
|
|
return {
|
|
...view,
|
|
nodes: view.nodes.map((node, index) => ({
|
|
...node,
|
|
ports: (node.ports as Record<string, unknown>[]).map((port, portIndex) =>
|
|
'mac' in port && port.mac === null ? { ...port, mac: macs[index]![portIndex] } : port,
|
|
),
|
|
})),
|
|
};
|
|
},
|
|
switchSsid(view: JavaFile, nodeIds: Id[]): JavaFile {
|
|
return mapSystems(view, (node, index) =>
|
|
node.class === 'Switch' && node.system.ssid === null ? { ...node.system, ssid: defaultSwitchConfiguration(nodeIds[index]!).ssid } : node.system,
|
|
);
|
|
},
|
|
modemRoles(view: JavaFile, pairs: (string | undefined)[]): JavaFile {
|
|
const seen = new Set<string | undefined>();
|
|
return mapSystems(view, (node, index) => {
|
|
if (node.class !== 'Modem') return node.system;
|
|
const serves = !seen.has(pairs[index]);
|
|
seen.add(pairs[index]);
|
|
return { ...node.system, modem: { ...(node.system.modem as object), mode: serves ? 1 : 2 } };
|
|
});
|
|
},
|
|
routerFiles(view: JavaFile, added: number[]): JavaFile {
|
|
return mapSystems(view, (node, index) => {
|
|
if ((node.class !== 'Vermittlungsrechner' && node.class !== 'Gateway') || added[index] === 0) return node.system;
|
|
const tree = node.system.fileSystem as JavaTree;
|
|
return { ...node.system, fileSystem: { ...tree, children: tree.children.slice(0, tree.children.length - added[index]!) } };
|
|
});
|
|
},
|
|
fileTypes(view: JavaFile): JavaFile {
|
|
const retype = (tree: JavaTree): JavaTree => ({
|
|
...tree,
|
|
...('file' in tree ? { type: fileType(tree.file as string, tree.type as string | null) } : {}),
|
|
children: tree.children.map(retype),
|
|
});
|
|
return mapSystems(view, (node) =>
|
|
node.system.fileSystem === undefined || node.system.fileSystem === null
|
|
? node.system
|
|
: { ...node.system, fileSystem: retype(node.system.fileSystem as JavaTree) },
|
|
);
|
|
},
|
|
documentation(view: JavaFile): JavaFile {
|
|
const items = (view.documentation as Record<string, unknown>[]).map((item) => {
|
|
const [x, y, width, height] = item.bounds as number[];
|
|
return {
|
|
...item,
|
|
text: item.text ?? '',
|
|
bounds: [x, y, Math.max(width!, DOCUMENTATION_MINIMUM_SIZE.width), Math.max(height!, DOCUMENTATION_MINIMUM_SIZE.height)],
|
|
font: item.font ?? ['Dialog', 12, 0],
|
|
};
|
|
});
|
|
return { ...view, documentation: sortedDocumentation(items) };
|
|
},
|
|
};
|
|
|
|
type JavaTree = { file?: string; type?: string | null; children: JavaTree[] } & Record<string, unknown>;
|
|
|
|
const IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'image']);
|
|
const TEXT_TYPES = new Set(['', 'text', 'txt', 'text/txt', 'html', 'htm', 'css', 'xml', 'csv', 'conf', 'log']);
|
|
const TEXT_EXTENSIONS = new Set(['txt', 'html', 'htm', 'css', 'xml', 'csv', 'conf', 'log']);
|
|
|
|
/**
|
|
* The type of a file in the model's kind: an image (by its type or
|
|
* extension) by its media type, "jpg" for JPEG; text by a known extension,
|
|
* else "text"; other data by its type.
|
|
*/
|
|
function fileType(name: string, type: string | null): string {
|
|
const declared = (type ?? '').trim().toLowerCase();
|
|
const dot = name.lastIndexOf('.');
|
|
const extension = dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
|
|
if (IMAGE_TYPES.has(declared) || IMAGE_TYPES.has(extension)) {
|
|
const subtype = declared === 'image' || declared === '' ? (extension === '' ? 'png' : extension) : declared;
|
|
return subtype === 'jpeg' ? 'jpg' : subtype;
|
|
}
|
|
if (TEXT_TYPES.has(declared)) return TEXT_EXTENSIONS.has(extension) ? extension : 'text';
|
|
return declared;
|
|
}
|
|
|
|
/** A file tree with every directory's children in an order that does not depend on the list's. */
|
|
function sortedTree(tree: JavaTree): JavaTree {
|
|
const children = tree.children.map(sortedTree);
|
|
return { ...tree, children: children.map((child) => JSON.stringify(child)).sort().map((text) => JSON.parse(text) as JavaTree) };
|
|
}
|
|
|
|
/** Every node's file tree as text, sorted or as it is. */
|
|
function fileTrees(view: JavaFile, sorted: boolean): string {
|
|
return JSON.stringify(
|
|
view.nodes.map((node) => {
|
|
const tree = node.system.fileSystem as JavaTree | null | undefined;
|
|
return tree === undefined || tree === null ? null : sorted ? sortedTree(tree) : tree;
|
|
}),
|
|
);
|
|
}
|
|
|
|
function mapSystems(view: JavaFile, system: (node: JavaNode, index: number) => Record<string, unknown>): JavaFile {
|
|
return { ...view, nodes: view.nodes.map((node, index) => ({ ...node, system: system(node, index) })) };
|
|
}
|
|
|
|
/** Documentation items in an order that does not depend on the list's. */
|
|
function sortedDocumentation(items: unknown[]): unknown[] {
|
|
return items.map((item) => JSON.stringify(item)).sort().map((text) => JSON.parse(text) as unknown);
|
|
}
|
|
|
|
/** Every port id each exported node's MAC could be derived from, by node and port. */
|
|
function portIds(project: ReturnType<typeof importProject>): Id[][] {
|
|
return project.graph.nodes.map((node) => node.ports.map((port) => port.id));
|
|
}
|
|
|
|
const departed = {
|
|
generatedMac: new Set<string>(),
|
|
documentation: new Set<string>(),
|
|
switchSsid: new Set<string>(),
|
|
modemRoles: new Set<string>(),
|
|
routerFiles: new Set<string>(),
|
|
fileTypes: new Set<string>(),
|
|
fileOrder: new Set<string>(),
|
|
};
|
|
|
|
describe('the editor model exported, as Java loads it', () => {
|
|
for (const file of flsFiles(corpusRoot)) {
|
|
const name = relative(corpusRoot, file);
|
|
it(name, () => {
|
|
const topology = readTopology(decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(file))).configurationXml));
|
|
const project = importProject(topology, counter());
|
|
const { topology: toWrite } = exportProject(project);
|
|
const archive = writeFlsArchive(new TextEncoder().encode(writeTopologyXml(toWrite)));
|
|
if (process.env.UPDATE_EXPORTED === '1') {
|
|
mkdirSync(dirname(join(exportedRoot, name)), { recursive: true });
|
|
writeFileSync(join(exportedRoot, name), archive);
|
|
}
|
|
const java = exported[name];
|
|
expect(java?.sha256, 'exported.java.json is not about this output: UPDATE_EXPORTED=1, then npm run oracle:java').toBe(
|
|
createHash('sha256').update(archive).digest('hex'),
|
|
);
|
|
// A MAC the file lacked: the one derived from the port's id.
|
|
const ids = portIds(project);
|
|
const macs = toWrite.nodes.map((node, index) =>
|
|
node.interfaces.map((nic, portIndex) => {
|
|
const derived = stableGeneratedMacAddress(ids[index]![portIndex]!);
|
|
return nic.mac === derived ? derived : undefined;
|
|
}),
|
|
);
|
|
const source = original[name]!;
|
|
const nodeIds = project.graph.nodes.map((node) => node.id);
|
|
const pairs = nodeIds.map((id) => project.remoteLinkConfigurations.get(id)?.pairIdentifier);
|
|
// What each router's file adds to its constructor's files.
|
|
const added = topology.nodes.map((node) => (node.system.templateFiles === true ? (node.system.fileSystem?.children.length ?? 0) : 0));
|
|
const steps: [keyof typeof departed, (view: JavaFile) => JavaFile][] = [
|
|
['generatedMac', (view) => DEPARTURES.generatedMac(view, macs)],
|
|
['switchSsid', (view) => DEPARTURES.switchSsid(view, nodeIds)],
|
|
['modemRoles', (view) => DEPARTURES.modemRoles(view, pairs)],
|
|
['routerFiles', (view) => DEPARTURES.routerFiles(view, added)],
|
|
['fileTypes', (view) => DEPARTURES.fileTypes(view)],
|
|
['documentation', (view) => DEPARTURES.documentation(view)],
|
|
];
|
|
let expected = source;
|
|
for (const [departure, apply] of steps) {
|
|
const next = apply(expected);
|
|
if (departure === 'documentation') {
|
|
if (JSON.stringify(next.documentation) !== JSON.stringify(expected.documentation)) departed[departure].add(name);
|
|
} else if (JSON.stringify(next) !== JSON.stringify(expected)) {
|
|
departed[departure].add(name);
|
|
}
|
|
expected = next;
|
|
}
|
|
if (fileTrees(expected, true) === fileTrees(java!, true) && fileTrees(expected, false) !== fileTrees(java!, false)) {
|
|
departed.fileOrder.add(name);
|
|
}
|
|
expect(topologyOf({ ...java!, documentation: sortedDocumentation(java!.documentation) })).toEqual(topologyOf(expected));
|
|
});
|
|
}
|
|
|
|
it('departs from Java where the model decides differently', () => {
|
|
for (const [departure, files] of Object.entries(departed)) expect(files.size, departure).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ── A model beyond the corpus, loaded by Java ──────────────────────────────
|
|
|
|
/** A model without nodes, settings or documentation. */
|
|
function emptyProject(): ProjectToExport {
|
|
return {
|
|
graph: { nodes: [], links: [] },
|
|
deviceConfigurations: new Map(),
|
|
interfaceConfigurations: new Map(),
|
|
hostWirelessConfigurations: new Map(),
|
|
switchConfigurations: new Map(),
|
|
manualRoutesByNodeId: new Map(),
|
|
ripEnabledByNodeId: new Map(),
|
|
firewallConfigurationsByNodeId: new Map(),
|
|
dhcpClientConfigurations: new Map(),
|
|
dhcpServerConfigurations: new Map(),
|
|
portForwardingRowsByNodeId: new Map(),
|
|
remoteLinkConfigurations: new Map(),
|
|
virtualFileSystems: new Map(),
|
|
installedPrograms: new Map(),
|
|
activePrograms: new Map(),
|
|
webServerConfigurations: new Map(),
|
|
gnutellaConfigurations: new Map(),
|
|
dnsServerConfigurations: new Map(),
|
|
echoServerConfigurations: new Map(),
|
|
simpleClientConfigurations: new Map(),
|
|
documentationItems: [],
|
|
};
|
|
}
|
|
|
|
const port = (id: string) => ({ id, label: id, isOccupied: false });
|
|
|
|
/** Bytes of a length: (index * 37 + 11) mod 256. */
|
|
function bytesOf(length: number): Uint8Array {
|
|
return Uint8Array.from({ length }, (_, index) => (index * 37 + 11) % 256);
|
|
}
|
|
|
|
/**
|
|
* Nested directories, text with a known extension and without one, a PNG and
|
|
* a JPEG, binary data with and without a media type, 57 bytes — one line of
|
|
* Base64 exactly.
|
|
*/
|
|
function syntheticFiles(): VirtualFileSystem {
|
|
const files = new VirtualFileSystem();
|
|
files.createDirectory('/docs/deep', true);
|
|
files.writeTextFile('/docs/readme.txt', 'Zeile 1\r\nZeile 2');
|
|
files.writeTextFile('/docs/deep/notes', 'no extension');
|
|
files.writeTextFile('/index.HTML', '<p>upper case extension</p>');
|
|
files.writeImageFile('/bild.png', bytesOf(57), 'image/png');
|
|
files.writeImageFile('/foto.jpg', bytesOf(100), 'image/jpeg');
|
|
files.writeBinaryFile('/daten.bin', bytesOf(58), 'application/zip');
|
|
files.writeBinaryFile('/roh', bytesOf(3));
|
|
// The web server's and the peer-to-peer application's own: Java makes none.
|
|
files.createDirectory('/webserver');
|
|
files.writeTextFile('/webserver/index.html', '<p>own</p>');
|
|
files.createDirectory('/peer2peer');
|
|
return files;
|
|
}
|
|
|
|
/** The files a router's or home router's constructor makes, as Java loaded them from the corpus. */
|
|
function templateOf(javaClass: string): unknown[] {
|
|
const node = Object.values(original)
|
|
.flatMap((file) => file.nodes)
|
|
.find((each) => each.class === javaClass);
|
|
return (node!.system.fileSystem as JavaTree).children.slice(0, 1);
|
|
}
|
|
|
|
/** A file as the oracle describes it: its content's UTF-16 length and the SHA-256 of its UTF-8 bytes. */
|
|
function javaFile(name: string, type: string | null, content: string) {
|
|
return { file: name, type, length: content.length, sha256: createHash('sha256').update(content, 'utf8').digest('hex'), children: [] };
|
|
}
|
|
|
|
/**
|
|
* What the corpus lacks: a label with IP and MAC, a position between
|
|
* pixels, a switch with five ports, a wireless notebook, a router with three
|
|
* interfaces and a gateway, a half-transparent bold text and a rectangle.
|
|
*/
|
|
const SYNTHETIC: ProjectToExport = {
|
|
...emptyProject(),
|
|
graph: {
|
|
nodes: [
|
|
{ id: 'pc', kind: 'pc', displayName: 'PC', hostLabelPolicy: 'ipAndMAC', position: { x: 10.6, y: 20.2 }, ports: [port('p0')] },
|
|
{ id: 'sw', kind: 'networkSwitch', displayName: 'SW', hostLabelPolicy: 'manual', position: { x: 100, y: 20 }, ports: ['s0', 's1', 's2', 's3', 's4'].map(port) },
|
|
{ id: 'nb', kind: 'notebook', displayName: 'NB', hostLabelPolicy: 'macAddress', position: { x: 100, y: 120 }, ports: [port('n0')] },
|
|
{ id: 'rt', kind: 'router', displayName: 'RT', hostLabelPolicy: 'manual', position: { x: 200, y: 20 }, ports: ['r0', 'r1', 'r2'].map(port) },
|
|
{ id: 'gw', kind: 'gateway', displayName: 'GW', hostLabelPolicy: 'manual', position: { x: 300, y: 20 }, ports: ['g0', 'g1'].map(port) },
|
|
{ id: 'm1', kind: 'remoteLink', displayName: 'M1', hostLabelPolicy: 'manual', position: { x: 400, y: 20 }, ports: [port('m1p')] },
|
|
{ id: 'm2', kind: 'remoteLink', displayName: 'M2', hostLabelPolicy: 'manual', position: { x: 500, y: 20 }, ports: [port('m2p')] },
|
|
{ id: 'm3', kind: 'remoteLink', displayName: 'M3', hostLabelPolicy: 'manual', position: { x: 600, y: 20 }, ports: [port('m3p')] },
|
|
],
|
|
links: [
|
|
{ id: 'l1', sourceNodeId: 'pc', sourcePortId: 'p0', targetNodeId: 'sw', targetPortId: 's4' },
|
|
{ id: 'l2', sourceNodeId: 'nb', sourcePortId: 'n0', targetNodeId: 'sw', targetPortId: 's0' },
|
|
{ id: 'l3', sourceNodeId: 'sw', sourcePortId: 's2', targetNodeId: 'rt', targetPortId: 'r1' },
|
|
],
|
|
},
|
|
deviceConfigurations: new Map([
|
|
['pc', { ipAddress: '10.0.0.1', subnetMask: '255.255.255.0', defaultGateway: '10.0.0.254', dnsServer: '10.0.0.53' }],
|
|
['nb', { ipAddress: '10.0.0.2', subnetMask: '255.255.255.0', defaultGateway: '', dnsServer: '' }],
|
|
['rt', { ipAddress: '10.0.0.254', subnetMask: '255.255.255.0', defaultGateway: '10.9.9.9', dnsServer: '10.0.0.53' }],
|
|
]),
|
|
interfaceConfigurations: new Map([
|
|
['rt/r0', { ipAddress: '10.1.0.1', subnetMask: '255.255.0.0' }],
|
|
['rt/r1', { ipAddress: '10.0.0.254', subnetMask: '255.255.255.0' }],
|
|
['rt/r2', { ipAddress: '10.2.0.1', subnetMask: '255.255.0.0' }],
|
|
['gw/g0', { ipAddress: '42.0.0.7', subnetMask: '255.0.0.0' }],
|
|
['gw/g1', { ipAddress: '192.168.1.1', subnetMask: '255.255.255.0' }],
|
|
]),
|
|
switchConfigurations: new Map([['sw', { ssid: 'S-net', retentionTimeMilliseconds: 1234 }]]),
|
|
manualRoutesByNodeId: new Map([
|
|
['pc', [{ destinationNetwork: '10.5.0.0', subnetMask: '255.255.0.0', gateway: '10.0.0.254', interfaceIPAddress: '10.0.0.1' }]],
|
|
['rt', [{ destinationNetwork: '10.6.0.0', subnetMask: '255.255.0.0', gateway: '10.1.0.2', interfaceIPAddress: '10.1.0.1' }]],
|
|
]),
|
|
ripEnabledByNodeId: new Map([['rt', true]]),
|
|
firewallConfigurationsByNodeId: new Map([
|
|
[
|
|
'pc',
|
|
firewallConfiguration({
|
|
isActive: false,
|
|
defaultPolicy: 'accept',
|
|
rules: [firewallRule({ sourceIPAddress: '10.0.0.0', sourceSubnetMask: '255.0.0.0', port: 22, action: 'accept' })],
|
|
}),
|
|
],
|
|
[
|
|
'rt',
|
|
firewallConfiguration({
|
|
isActive: true,
|
|
defaultPolicy: 'accept',
|
|
dropIcmp: true,
|
|
filterSynSegmentsOnly: false,
|
|
filterUdp: false,
|
|
rules: [
|
|
firewallRule({ sourceIPAddress: '10.0.0.0', sourceSubnetMask: '255.0.0.0', protocol: 'all', action: 'accept' }),
|
|
firewallRule({ destinationIPAddress: '10.2.0.5', destinationSubnetMask: '255.255.255.255', port: 80, protocol: 'tcp' }),
|
|
firewallRule({ port: 53, protocol: 'udp', action: 'accept' }),
|
|
firewallRule({ protocol: 'icmp' }),
|
|
],
|
|
}),
|
|
],
|
|
]),
|
|
dhcpClientConfigurations: new Map([
|
|
['pc', { isEnabled: true }],
|
|
['gw', { isEnabled: true }],
|
|
]),
|
|
dhcpServerConfigurations: new Map([
|
|
[
|
|
'gw',
|
|
dhcpServerConfiguration({
|
|
isActive: true,
|
|
lowerBoundIPAddress: '192.168.1.100',
|
|
upperBoundIPAddress: '192.168.1.199',
|
|
gatewayIPAddress: '192.168.1.1',
|
|
dnsServerIPAddress: '8.8.8.8',
|
|
useOwnSettings: true,
|
|
staticAssignments: [{ id: 'a1', macAddress: 'AA:BB:CC:DD:EE:77', ipAddress: '192.168.1.77' }],
|
|
}),
|
|
],
|
|
]),
|
|
portForwardingRowsByNodeId: new Map([['gw', [{ protocolValue: 'TCP', publicPortValue: '8080', lanIPAddress: '192.168.1.10', lanPortValue: '80' }]]]),
|
|
virtualFileSystems: new Map([['pc', syntheticFiles()]]),
|
|
installedPrograms: new Map<Id, Set<InstallableProgram>>([
|
|
[
|
|
'pc',
|
|
new Set<InstallableProgram>([
|
|
'webServer',
|
|
'dnsServer',
|
|
'echoServer',
|
|
'simpleClient',
|
|
'gnutella',
|
|
'firewall',
|
|
'commandPrompt',
|
|
'textEditor',
|
|
'fileExplorer',
|
|
'imageViewer',
|
|
'webBrowser',
|
|
]),
|
|
],
|
|
['nb', new Set<InstallableProgram>(['gnutella', 'simpleClient', 'firewall'])],
|
|
]),
|
|
activePrograms: new Map([['pc', new Set<InstallableProgram>(['webServer', 'dnsServer', 'echoServer'])]]),
|
|
webServerConfigurations: new Map([['pc', { port: 8080, documentRoot: '/webserver', virtualHosts: true }]]),
|
|
dnsServerConfigurations: new Map([['pc', { recursiveResolutionEnabled: true, records: [] }]]),
|
|
echoServerConfigurations: new Map([['pc', { port: 7777 }]]),
|
|
simpleClientConfigurations: new Map([
|
|
['pc', { targetAddress: '10.0.0.9', targetPort: 7777 }],
|
|
['nb', { targetAddress: '', targetPort: 55_555 }],
|
|
]),
|
|
gnutellaConfigurations: new Map([['pc', { maximumKnownPeers: 7 }]]),
|
|
remoteLinkConfigurations: new Map([
|
|
['m1', { pairIdentifier: 'java-modem-port-4242', latencyMilliseconds: 20, isEnabled: true }],
|
|
['m2', { pairIdentifier: 'java-modem-port-4242', latencyMilliseconds: 20, isEnabled: true }],
|
|
['m3', { pairIdentifier: 'our own pair', latencyMilliseconds: 20, isEnabled: true }],
|
|
]),
|
|
hostWirelessConfigurations: new Map([['nb', { isEnabled: true, ssid: 'net' }]]),
|
|
documentationItems: [
|
|
{
|
|
id: 'd1',
|
|
kind: 'rectangle',
|
|
frame: { x: 5, y: 6, width: 300, height: 200 },
|
|
text: '',
|
|
color: { red: 1, green: 0.5, blue: 0, alpha: 0.5 },
|
|
fontName: 'Dialog',
|
|
fontSize: 12,
|
|
isBold: false,
|
|
order: 0,
|
|
},
|
|
{
|
|
id: 'd2',
|
|
kind: 'text',
|
|
frame: { x: 40.4, y: 50.6, width: 120, height: 30 },
|
|
text: 'Netz A\nzweite Zeile',
|
|
color: { red: 0, green: 0, blue: 1, alpha: 1 },
|
|
fontName: 'SansSerif',
|
|
fontSize: 16,
|
|
isBold: true,
|
|
order: 1,
|
|
},
|
|
],
|
|
};
|
|
|
|
describe('a model beyond the corpus, as Java loads it', () => {
|
|
it('keeps what the corpus never has', () => {
|
|
const name = 'synthetic/model.fls';
|
|
const { topology: toWrite, warnings } = exportProject(SYNTHETIC);
|
|
const archive = writeFlsArchive(new TextEncoder().encode(writeTopologyXml(toWrite)));
|
|
if (process.env.UPDATE_EXPORTED === '1') {
|
|
mkdirSync(join(exportedRoot, 'synthetic'), { recursive: true });
|
|
writeFileSync(join(exportedRoot, name), archive);
|
|
}
|
|
const java = exported[name];
|
|
expect(java?.sha256, 'exported.java.json is not about this output: UPDATE_EXPORTED=1, then npm run oracle:java').toBe(
|
|
createHash('sha256').update(archive).digest('hex'),
|
|
);
|
|
expect(warnings).toEqual([]);
|
|
const card = (ip: string, mask: string, mac: string, gateway: string, dns: string, wireless = false) => ({ ip, mask, mac, gateway, dns, wireless });
|
|
const modem = (name: string, x: number, mode: number, port: number) => ({
|
|
class: 'Modem',
|
|
name,
|
|
position: [x, 20],
|
|
useIpAsName: undefined,
|
|
useMacAsName: undefined,
|
|
ports: [{}],
|
|
system: { modem: { mode, port, ipAdresse: 'localhost' } },
|
|
});
|
|
expect(topologyOf(java!)).toEqual({
|
|
success: true,
|
|
nodes: [
|
|
{
|
|
class: 'Rechner',
|
|
name: 'PC',
|
|
position: [11, 20],
|
|
useIpAsName: true,
|
|
useMacAsName: true,
|
|
ports: [card('10.0.0.1', '255.255.255.0', stableGeneratedMacAddress('p0'), '10.0.0.254', '10.0.0.53')],
|
|
system: {
|
|
ssid: null,
|
|
manualRoutes: [['10.5.0.0', '255.255.0.0', '10.0.0.254', '10.0.0.1']],
|
|
dhcpClient: true,
|
|
dhcpServer: JAVA_DHCP_DEFAULTS,
|
|
fileSystem: sortedTree({
|
|
directory: 'root',
|
|
children: [
|
|
{
|
|
directory: 'docs',
|
|
children: [{ directory: 'deep', children: [javaFile('notes', 'text', 'no extension')] }, javaFile('readme.txt', 'txt', 'Zeile 1\r\nZeile 2')],
|
|
},
|
|
javaFile('bild.png', 'png', javaBase64(bytesOf(57))),
|
|
javaFile('daten.bin', 'zip', javaBase64(bytesOf(58))),
|
|
javaFile('foto.jpg', 'jpg', javaBase64(bytesOf(100))),
|
|
javaFile('index.HTML', 'html', '<p>upper case extension</p>'),
|
|
javaFile('roh', '', javaBase64(bytesOf(3))),
|
|
{ directory: 'webserver', children: [javaFile('index.html', 'html', '<p>own</p>')] },
|
|
{ directory: 'peer2peer', children: [] },
|
|
],
|
|
}),
|
|
// In the order of Java's key.
|
|
applications: [
|
|
{ key: 'filius.software.clientserver.ClientBaustein', class: 'filius.software.clientserver.ClientBaustein', zielIPAdresse: '10.0.0.9', zielPort: 7777 },
|
|
{ key: 'filius.software.clientserver.ServerBaustein', class: 'filius.software.clientserver.ServerBaustein', aktiv: true, port: 7777 },
|
|
{ key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', class: 'filius.software.dateiaustausch.PeerToPeerAnwendung', maxTeilnehmerZahl: 7 },
|
|
{ key: 'filius.software.dns.DNSServer', class: 'filius.software.dns.DNSServer', aktiv: true, port: 53, recursiveResolutionEnabled: true },
|
|
{
|
|
key: 'filius.software.firewall.Firewall',
|
|
class: 'filius.software.firewall.Firewall',
|
|
activated: false,
|
|
defaultPolicy: 1,
|
|
dropICMP: false,
|
|
filterSYNSegmentsOnly: true,
|
|
filterUdp: true,
|
|
rules: [['10.0.0.0', '255.0.0.0', '', '', 22, 6, 1]],
|
|
},
|
|
{ key: 'filius.software.lokal.FileExplorer', class: 'filius.software.lokal.FileExplorer' },
|
|
{ key: 'filius.software.lokal.ImageViewer', class: 'filius.software.lokal.ImageViewer' },
|
|
{ key: 'filius.software.lokal.Terminal', class: 'filius.software.lokal.Terminal' },
|
|
{ key: 'filius.software.lokal.TextEditor', class: 'filius.software.lokal.TextEditor' },
|
|
{ key: 'filius.software.www.WebBrowser', class: 'filius.software.www.WebBrowser' },
|
|
{ key: 'filius.software.www.WebServer', class: 'filius.software.www.WebServer', aktiv: true, port: 8080, useVHost: true },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
class: 'Switch',
|
|
name: 'SW',
|
|
position: [100, 20],
|
|
useIpAsName: undefined,
|
|
useMacAsName: undefined,
|
|
ports: [{}, {}, {}, {}, {}],
|
|
system: { ssid: 'S-net', retentionTime: 1234 },
|
|
},
|
|
{
|
|
class: 'Notebook',
|
|
name: 'NB',
|
|
position: [100, 120],
|
|
useIpAsName: false,
|
|
useMacAsName: true,
|
|
ports: [card('10.0.0.2', '255.255.255.0', stableGeneratedMacAddress('n0'), '', '', true)],
|
|
system: {
|
|
ssid: 'net',
|
|
manualRoutes: [],
|
|
dhcpClient: false,
|
|
dhcpServer: JAVA_DHCP_DEFAULTS,
|
|
// Made by the peer-to-peer application.
|
|
fileSystem: { directory: 'root', children: [{ directory: 'peer2peer', children: [] }] },
|
|
applications: [
|
|
// Without a setting: no target (null), the model's 3 peers, Java's personal firewall.
|
|
{ key: 'filius.software.clientserver.ClientBaustein', class: 'filius.software.clientserver.ClientBaustein', zielIPAdresse: null, zielPort: 55_555 },
|
|
{ key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', class: 'filius.software.dateiaustausch.PeerToPeerAnwendung', maxTeilnehmerZahl: 3 },
|
|
{
|
|
key: 'filius.software.firewall.Firewall',
|
|
class: 'filius.software.firewall.Firewall',
|
|
activated: true,
|
|
defaultPolicy: 0,
|
|
dropICMP: false,
|
|
filterSYNSegmentsOnly: true,
|
|
filterUdp: true,
|
|
rules: [],
|
|
},
|
|
],
|
|
},
|
|
},
|
|
{
|
|
class: 'Vermittlungsrechner',
|
|
name: 'RT',
|
|
position: [200, 20],
|
|
useIpAsName: undefined,
|
|
useMacAsName: undefined,
|
|
ports: [
|
|
card('10.1.0.1', '255.255.0.0', stableGeneratedMacAddress('r0'), '10.9.9.9', '10.0.0.53'),
|
|
card('10.0.0.254', '255.255.255.0', stableGeneratedMacAddress('r1'), '10.9.9.9', '10.0.0.53'),
|
|
card('10.2.0.1', '255.255.0.0', stableGeneratedMacAddress('r2'), '10.9.9.9', '10.0.0.53'),
|
|
],
|
|
system: {
|
|
ripEnabled: true,
|
|
fileSystem: sortedTree({ directory: 'root', children: templateOf('Vermittlungsrechner') as JavaTree[] }),
|
|
manualRoutes: [['10.6.0.0', '255.255.0.0', '10.1.0.2', '10.1.0.1']],
|
|
dhcpClient: false,
|
|
dhcpServer: JAVA_DHCP_DEFAULTS,
|
|
firewall: {
|
|
activated: true,
|
|
defaultPolicy: 1,
|
|
dropICMP: true,
|
|
filterSYNSegmentsOnly: false,
|
|
filterUdp: false,
|
|
rules: [
|
|
['10.0.0.0', '255.0.0.0', '', '', -1, -1, 1],
|
|
['', '', '10.2.0.5', '255.255.255.255', 80, 6, 0],
|
|
['', '', '', '', 53, 17, 1],
|
|
['', '', '', '', -1, 1, 0],
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
class: 'Gateway',
|
|
name: 'GW',
|
|
position: [300, 20],
|
|
useIpAsName: undefined,
|
|
useMacAsName: undefined,
|
|
ports: [
|
|
card('42.0.0.7', '255.0.0.0', stableGeneratedMacAddress('g0'), '', ''),
|
|
card('192.168.1.1', '255.255.255.0', stableGeneratedMacAddress('g1'), '', ''),
|
|
],
|
|
system: {
|
|
fileSystem: sortedTree({ directory: 'root', children: templateOf('Gateway') as JavaTree[] }),
|
|
manualRoutes: [],
|
|
dhcpClient: true,
|
|
dhcpServer: {
|
|
aktiv: true,
|
|
untergrenze: '192.168.1.100',
|
|
obergrenze: '192.168.1.199',
|
|
gatewayip: '192.168.1.1',
|
|
dnsserverip: '8.8.8.8',
|
|
ownSettings: true,
|
|
staticAssignedAddresses: ['AA:BB:CC:DD:EE:77 192.168.1.77'],
|
|
},
|
|
staticNAT: [['TCP', '8080', '192.168.1.10', '80']],
|
|
firewall: { activated: false, defaultPolicy: 0, dropICMP: false, filterSYNSegmentsOnly: true, filterUdp: true, rules: [] },
|
|
},
|
|
},
|
|
// A pair on Java's port: the first serves; a pair of our own: Java's default port.
|
|
modem('M1', 400, 1, 4242),
|
|
modem('M2', 500, 2, 4242),
|
|
modem('M3', 600, 1, 12345),
|
|
],
|
|
cables: [
|
|
{ ends: [[0, 0], [1, 4]], wireless: false, panel: [0, 1] },
|
|
{ ends: [[2, 0], [1, 0]], wireless: true, panel: [2, 1] },
|
|
{ ends: [[1, 2], [3, 1]], wireless: false, panel: [1, 3] },
|
|
],
|
|
documentation: [
|
|
{ type: 1, text: '', bounds: [5, 6, 300, 200], color: [255, 128, 0, 128], font: ['Dialog', 12, 0] },
|
|
{ type: 2, text: 'Netz A\nzweite Zeile', bounds: [40, 51, 120, 30], color: [0, 0, 255, 255], font: ['SansSerif', 16, 1] },
|
|
],
|
|
});
|
|
});
|
|
});
|
|
|
|
/** The DHCP server as every IP stack's constructor leaves it. */
|
|
const JAVA_DHCP_DEFAULTS = {
|
|
aktiv: false,
|
|
untergrenze: '0.0.0.0',
|
|
obergrenze: '0.0.0.0',
|
|
gatewayip: '0.0.0.0',
|
|
dnsserverip: '0.0.0.0',
|
|
ownSettings: false,
|
|
staticAssignedAddresses: [],
|
|
};
|
|
|
|
// ── Rules the corpus does not exercise ─────────────────────────────────────
|
|
|
|
describe('javaBase64', () => {
|
|
// FILIUS's Base64.encodeBytes around its line length (tools/java-oracle Base64Cases).
|
|
const cases = JSON.parse(readFileSync(join(here, 'base64.java.json'), 'utf8')) as [number, string][];
|
|
it.each(cases)('encodes %i bytes as Java does', (length, java) => {
|
|
const bytes = Uint8Array.from({ length }, (_, index) => (index * 37 + 11) % 256);
|
|
expect(javaBase64(bytes)).toBe(java);
|
|
});
|
|
});
|
|
|
|
describe('exportProject rules', () => {
|
|
const node = (id: string, kind: TopologyNode['kind'], ports: string[], extra: Partial<TopologyNode> = {}): TopologyNode => ({
|
|
id,
|
|
kind,
|
|
displayName: id,
|
|
hostLabelPolicy: 'manual',
|
|
position: { x: 0, y: 0 },
|
|
ports: ports.map(port),
|
|
...extra,
|
|
});
|
|
const link = (id: string, source: [string, string], target: [string, string]) => ({
|
|
id,
|
|
sourceNodeId: source[0],
|
|
sourcePortId: source[1],
|
|
targetNodeId: target[0],
|
|
targetPortId: target[1],
|
|
});
|
|
const project = (nodes: TopologyNode[], links: ReturnType<typeof link>[], wireless: [string, boolean][] = []): ProjectToExport => ({
|
|
...emptyProject(),
|
|
graph: { nodes, links },
|
|
hostWirelessConfigurations: new Map(wireless.map(([id, isEnabled]) => [id, { isEnabled, ssid: 'net' }])),
|
|
});
|
|
|
|
it('leaves out a node of a kind Java does not have, and the links to it', () => {
|
|
const { topology, warnings } = exportProject(
|
|
project(
|
|
[node('pc', 'pc', ['p0']), node('odd', 'unsupported', ['o0']), node('sw', 'networkSwitch', ['s0', 's1'])],
|
|
[link('l1', ['pc', 'p0'], ['odd', 'o0']), link('l2', ['pc', 'p0'], ['sw', 's1']), link('l3', ['pc', 'missing'], ['sw', 's0'])],
|
|
),
|
|
);
|
|
expect(topology.nodes.map((each) => each.javaClass)).toEqual(['Rechner', 'Switch']);
|
|
expect(topology.links).toEqual([{ ends: [{ node: 0, port: 0 }, { node: 1, port: 1 }], wireless: false, panel: [0, 1] }]);
|
|
expect(warnings).toEqual([
|
|
"node 'odd': a kind Java FILIUS does not have, left out",
|
|
'link 1: an end is not a port of an exported node, left out',
|
|
'link 3: an end is not a port of an exported node, left out',
|
|
]);
|
|
});
|
|
|
|
it('joins wirelessly only a host with a wireless card and a switch', () => {
|
|
const { topology } = exportProject(
|
|
project(
|
|
[
|
|
node('nb', 'notebook', ['n0']),
|
|
node('sw', 'networkSwitch', ['s0', 's1']),
|
|
node('nb2', 'notebook', ['m0']),
|
|
node('rt', 'router', ['r0']),
|
|
node('pc', 'pc', ['p0']),
|
|
],
|
|
[link('l1', ['sw', 's0'], ['nb', 'n0']), link('l2', ['nb2', 'm0'], ['rt', 'r0']), link('l3', ['pc', 'p0'], ['sw', 's1'])],
|
|
[
|
|
['nb', true],
|
|
['nb2', true],
|
|
['pc', false],
|
|
],
|
|
),
|
|
);
|
|
expect(topology.links.map((each) => each.wireless)).toEqual([true, false, false]);
|
|
expect(topology.nodes.map((each) => each.interfaces.map((nic) => nic.wireless))).toEqual([[true], [], [true], [false], [false]]);
|
|
});
|
|
|
|
it('rounds positions to Java ints and shows IP and MAC as the policy says', () => {
|
|
const { topology } = exportProject(
|
|
project([node('pc', 'pc', ['p0'], { position: { x: 10.5, y: -3.4 }, hostLabelPolicy: 'ipAndMAC' })], []),
|
|
);
|
|
expect(topology.nodes[0]).toMatchObject({ x: 11, y: -3, useIpAsName: true, useMacAsName: true, portCount: 1 });
|
|
expect(topology.nodes[0]!.interfaces[0]!.mac).toBe(stableGeneratedMacAddress('p0'));
|
|
});
|
|
});
|