Files
mdg-filius/packages/fls/test/exportProject.test.ts
T
julian.kiedaischandClaude Opus 5.5 95c5b7d97b test(fls): the Java oracle opens files as FILIUS does (M8)
FlsOracle now loads through SzenarioVerwaltung.laden, FILIUS's full
loading path: the archive unpacked, the project's own applications copied
where FILIUS's class loader finds them, then the configuration.
--network keeps the configuration-only load (netzwerkLaden).

On the corpus this changes two files: anwendung_komplett.fls and
multicast_server.fls then have their own applications (Accumulator,
Broadcast) installed. Java's views of the written and the exported files
are byte-equal to before: Java-FILIUS 2.14 opens our exports, with the
classes they carry, as it opens a file of its own, and sees what it sees
in the original. Until own applications on hosts are written (B2), the
reader, writer and export tests leave them out (test/ownApplications.ts);
a test checks that only those two files are touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:32:24 +00:00

1029 lines
45 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 EmailMessage,
type Id,
type InstallableProgram,
type TopologyNode,
VirtualFileSystem,
} from '@mdg-filius/core';
import { describe, expect, it } from 'vitest';
import {
decodeJavaBeans,
exportProject,
importProject,
javaBase64,
openProjectArchive,
readFlsArchive,
saveProjectArchive,
readTopology,
writeFlsArchive,
type FlsFileTree,
type ProjectToExport,
} from '../src/index.js';
import { writeTopologyXml } from '../src/topologyWriter.js';
import { withoutOwnApplications } from './ownApplications.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 with its files in any order. */
function systemOf(settings: Record<string, unknown>) {
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);
* - recipientsAsStrings: a recipient an older file keeps as a string is the
* address Java's AddressEntry(String) reads from it (the oracle reports
* it next to the string), as the import reads it — Java puts the string
* into its typed list unchecked and its e-mail window fails on it;
* - sentFolder: the messages of the client's sent folder are sent ones;
* - accountFiles: /konten.txt and /mailserver/konten.txt are written anew
* from the model's accounts as FILIUS 2.14 writes them — with its types
* (text/txt, txt), the client's line without the line break older
* versions ended it with.
*/
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) },
);
},
recipientsAsStrings(view: JavaFile): JavaFile {
return withRecipientsRead(view) as JavaFile;
},
sentFolder(view: JavaFile): JavaFile {
return mapSystems(view, (node) =>
node.system.applications === undefined
? node.system
: {
...node.system,
applications: (node.system.applications as Record<string, unknown>[]).map((application) =>
application.sent === undefined
? application
: { ...application, sent: (application.sent as Record<string, unknown>[]).map((message) => ({ ...message, versendet: true })) },
),
},
);
},
accountFiles(view: JavaFile, contents: (Map<string, string> | undefined)[]): JavaFile {
/** The tree at a path ("" for the root), with each account file as the export writes it. */
const rewrite = (tree: JavaTree, path: string, files: Map<string, string>): JavaTree => {
const children = tree.children.map((child) => rewrite(child, `${path}/${String(child.file ?? child.directory)}`, files));
const content = 'file' in tree ? files.get(path) : undefined;
if (content === undefined) return { ...tree, children };
const client = path.toLowerCase() === '/konten.txt';
return { ...tree, ...javaFile(tree.file as string, client ? 'text/txt' : 'txt', client ? content.replace(/\n$/, '') : content), children };
};
return mapSystems(view, (node, index) => {
const tree = node.system.fileSystem as JavaTree | null | undefined;
const files = contents[index];
return tree === undefined || tree === null || files === undefined ? node.system : { ...node.system, fileSystem: rewrite(tree, '', files) };
});
},
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; directory?: string | null; 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;
}),
);
}
/**
* The contents of /konten.txt (with the client's account) and
* /mailserver/konten.txt (with the server) in a host's files, as the file
* has them: the ones the export writes anew.
*/
function accountFileContents(root: FlsFileTree | undefined, project: ReturnType<typeof importProject>, id: Id): Map<string, string> | undefined {
if (root === undefined) return undefined;
const result = new Map<string, string>();
const programs = project.installedPrograms.get(id);
const child = (tree: FlsFileTree, name: string) => tree.children.find((each) => each.kind !== 'other' && each.name?.toLowerCase() === name);
const client = child(root, 'konten.txt');
if (programs?.has('emailClient') && project.emailClientConfigurations.get(id)?.account != null && client?.kind === 'file') {
result.set(`/${client.name}`, client.content ?? '');
}
const directory = child(root, 'mailserver');
const server = directory?.kind === 'directory' ? child(directory, 'konten.txt') : undefined;
if (programs?.has('emailServer') && directory?.kind === 'directory' && server?.kind === 'file') {
result.set(`/${directory.name}/${server.name}`, server.content ?? '');
}
return result;
}
/** Each recipient as Java reads its string, none null. */
function withRecipientsRead(value: unknown, key = ''): unknown {
if (Array.isArray(value)) {
if (key === 'empfaenger' || key === 'cc' || key === 'bcc') {
return value.filter((entry) => entry !== null).map((entry) => (entry[0] === 'string' ? entry[2] : entry));
}
return value.map((item) => withRecipientsRead(item));
}
if (value !== null && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, withRecipientsRead(item, name)]));
}
return value;
}
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>(),
recipientsAsStrings: new Set<string>(),
sentFolder: new Set<string>(),
accountFiles: 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 bytes = new Uint8Array(readFileSync(file));
const topology = readTopology(decodeJavaBeans(readFlsArchive(bytes).configurationXml));
const project = openProjectArchive(bytes, counter());
const { topology: toWrite } = exportProject(project);
const { archive } = saveProjectArchive(project);
// The project's own applications, and every other entry, byte for byte.
expect(readFlsArchive(archive).supplementalEntries).toEqual(readFlsArchive(bytes).supplementalEntries);
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;
}),
);
// The model does not install own applications yet (B2).
const source = withoutOwnApplications(original[name]!);
const nodeIds = project.graph.nodes.map((node) => node.id);
const pairs = nodeIds.map((id) => project.remoteLinkConfigurations.get(id)?.pairIdentifier);
// The account files of each host the export writes anew, by path, as the file has them.
const accountFiles = topology.nodes.map((node, index) => accountFileContents(node.system.fileSystem, project, nodeIds[index]!));
// 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)],
['recipientsAsStrings', (view) => DEPARTURES.recipientsAsStrings(view)],
['sentFolder', (view) => DEPARTURES.sentFolder(view)],
['accountFiles', (view) => DEPARTURES.accountFiles(view, accountFiles)],
['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(),
emailClientConfigurations: new Map(),
emailServerConfigurations: new Map(),
documentationItems: [],
};
}
const port = (id: string) => ({ id, label: id, isOccupied: false });
/**
* A message with every character Java escapes in a text, a separator in its
* subject (a space in the account file), a sender with name and address,
* recipients without a name.
*/
const SYNTHETIC_MAIL: EmailMessage = {
id: 1,
from: { name: 'Anna', mailAddress: 'anna@schule.de' },
to: [
{ name: null, mailAddress: 'ben@schule.de' },
{ name: 'Carla', mailAddress: 'carla@schule.de' },
],
cc: [],
bcc: [{ name: null, mailAddress: 'eva@schule.de' }],
subject: 'Hallo; Welt',
body: 'a$b;c|d#e\nzweite Zeile',
receivedAtMilliseconds: 1_727_690_000_000,
isNew: false,
isMarkedForDeletion: true,
isSent: false,
};
/** SYNTHETIC_MAIL as the oracle describes it after Java loaded it. */
function javaMail(changes: Record<string, unknown> = {}) {
return {
absender: ['Anna', 'anna@schule.de'],
empfaenger: [[null, 'ben@schule.de'], ['Carla', 'carla@schule.de']],
cc: [],
bcc: [[null, 'eva@schule.de']],
betreff: 'Hallo; Welt',
text: 'a$b;c|d#e\nzweite Zeile',
dateReceived: '1727690000000',
neu: false,
delete: true,
versendet: false,
...changes,
};
}
const SERVER_ACCOUNTS_FILE =
'anna;schule.de;geheim;Muster;Anna;#Anna <anna@schule.de>$<ben@schule.de>,Carla <carla@schule.de>$$<eva@schule.de>$1727690000000$Hallo Welt$' +
'a&&00036&&b&&00059&&c&&00124&&d&&00035&&e|zweite Zeile\n';
const CLIENT_ACCOUNT_FILE = '10.0.0.1;10.0.0.1;110;25;anna;geheim;Muster;Anna;anna@schule.de';
/** 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',
'emailServer',
'emailClient',
]),
],
['nb', new Set<InstallableProgram>(['gnutella', 'simpleClient', 'firewall'])],
]),
emailServerConfigurations: new Map([
[
'pc',
{
domain: 'schule.de',
pop3Port: 110,
accounts: [{ username: 'anna', password: 'geheim', firstName: 'Anna', lastName: 'Muster', mailbox: [SYNTHETIC_MAIL] }],
nextMessageID: 2,
},
],
]),
emailClientConfigurations: new Map([
[
'pc',
{
account: {
pop3Host: '10.0.0.1',
pop3Port: 110,
smtpHost: '10.0.0.1',
smtpPort: 25,
username: 'anna',
password: 'geheim',
firstName: 'Anna',
lastName: 'Muster',
email: 'anna@schule.de',
},
inbox: [SYNTHETIC_MAIL],
sent: [{ ...SYNTHETIC_MAIL, id: 2, isSent: true }],
drafts: [{ ...SYNTHETIC_MAIL, id: 3, subject: '', body: '', to: [] }],
addressBook: [
{ name: 'Muster', firstName: 'Anna', street: 'Weg 1', houseNumber: 12, postalCode: 20095, city: 'Hamburg', email: 'anna@schule.de', phone: '040' },
],
nextMessageID: 4,
},
],
]),
activePrograms: new Map([['pc', new Set<InstallableProgram>(['webServer', 'dnsServer', 'echoServer', 'emailServer'])]]),
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(["node 'PC': /mailserver/konten.txt: a subject holds a character Java's account file cannot carry, written there with a space"]);
const serverAccount = (nachrichten: unknown[]) => ({
benutzername: 'anna',
passwort: 'geheim',
nachname: 'Muster',
vorname: 'Anna',
emailAdresse: null,
pop3server: null,
pop3port: null,
smtpserver: null,
smtpport: null,
nachrichten,
});
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: [] },
javaFile('konten.txt', 'text/txt', CLIENT_ACCOUNT_FILE),
{ directory: 'mailserver', children: [javaFile('konten.txt', 'txt', SERVER_ACCOUNTS_FILE)] },
],
}),
// 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.email.EmailAnwendung',
class: 'filius.software.email.EmailAnwendung',
received: [javaMail()],
sent: [javaMail({ versendet: true })],
drafts: [javaMail({ betreff: '', text: '', empfaenger: [] })],
addressBook: [['Muster', 'Anna', 'Weg 1', 12, 20095, 'Hamburg', 'anna@schule.de', '040']],
// /konten.txt as Java's laden() reads it.
fileAccount: {
benutzername: 'anna',
passwort: 'geheim',
nachname: 'Muster',
vorname: 'Anna',
emailAdresse: 'anna@schule.de',
pop3server: '10.0.0.1',
pop3port: '110',
smtpserver: '10.0.0.1',
smtpport: '25',
nachrichten: [],
},
},
{
key: 'filius.software.email.EmailServer',
class: 'filius.software.email.EmailServer',
aktiv: true,
mailDomain: 'schule.de',
beanAccounts: [serverAccount([javaMail()])],
// /mailserver/konten.txt as Java's kontenLaden() reads it: the subject with a space, a message's flags as new.
fileAccounts: [serverAccount([javaMail({ betreff: 'Hallo Welt', neu: true, delete: false })])],
},
{
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'));
});
});