// 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; }; 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; const exportedPath = join(here, 'exported.java.json'); const exported = JSON.parse(readFileSync(exportedPath, 'utf8')) as Record; /** The system software with its files in any order. */ function systemOf(settings: Record) { 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[]).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(); 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[]).map((application) => application.sent === undefined ? application : { ...application, sent: (application.sent as Record[]).map((message) => ({ ...message, versendet: true })) }, ), }, ); }, accountFiles(view: JavaFile, contents: (Map | 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): 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[]).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; 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, id: Id): Map | undefined { if (root === undefined) return undefined; const result = new Map(); 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): 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): Id[][] { return project.graph.nodes.map((node) => node.ports.map((port) => port.id)); } const departed = { generatedMac: new Set(), documentation: new Set(), switchSsid: new Set(), modemRoles: new Set(), routerFiles: new Set(), fileTypes: new Set(), fileOrder: new Set(), recipientsAsStrings: new Set(), sentFolder: new Set(), accountFiles: new Set(), }; 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 = {}) { 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 $,Carla $$$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', '

upper case extension

'); 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', '

own

'); 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>([ [ 'pc', new Set([ 'webServer', 'dnsServer', 'echoServer', 'simpleClient', 'gnutella', 'firewall', 'commandPrompt', 'textEditor', 'fileExplorer', 'imageViewer', 'webBrowser', 'emailServer', 'emailClient', ]), ], ['nb', new Set(['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(['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', '

upper case extension

'), javaFile('roh', '', javaBase64(bytesOf(3))), { directory: 'webserver', children: [javaFile('index.html', 'html', '

own

')] }, { 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', onItsSystem: true, zielIPAdresse: '10.0.0.9', zielPort: 7777 }, { key: 'filius.software.clientserver.ServerBaustein', class: 'filius.software.clientserver.ServerBaustein', onItsSystem: true, aktiv: true, port: 7777 }, { key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', class: 'filius.software.dateiaustausch.PeerToPeerAnwendung', onItsSystem: true, maxTeilnehmerZahl: 7 }, { key: 'filius.software.dns.DNSServer', class: 'filius.software.dns.DNSServer', onItsSystem: true, aktiv: true, port: 53, recursiveResolutionEnabled: true }, { key: 'filius.software.email.EmailAnwendung', class: 'filius.software.email.EmailAnwendung', onItsSystem: true, 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', onItsSystem: true, 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', onItsSystem: true, 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', onItsSystem: true }, { key: 'filius.software.lokal.ImageViewer', class: 'filius.software.lokal.ImageViewer', onItsSystem: true }, { key: 'filius.software.lokal.Terminal', class: 'filius.software.lokal.Terminal', onItsSystem: true }, { key: 'filius.software.lokal.TextEditor', class: 'filius.software.lokal.TextEditor', onItsSystem: true }, { key: 'filius.software.www.WebBrowser', class: 'filius.software.www.WebBrowser', onItsSystem: true }, { key: 'filius.software.www.WebServer', class: 'filius.software.www.WebServer', onItsSystem: true, 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', onItsSystem: true, zielIPAdresse: null, zielPort: 55_555 }, { key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', class: 'filius.software.dateiaustausch.PeerToPeerAnwendung', onItsSystem: true, maxTeilnehmerZahl: 3 }, { key: 'filius.software.firewall.Firewall', class: 'filius.software.firewall.Firewall', onItsSystem: true, 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 => ({ 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[], 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')); }); });