// The topology of every corpus file must be what Java FILIUS loads // (corpus.java.json, tools/java-oracle): each device with class, name, // position and interfaces, each cable with both ends, each documentation // item — field by field. Then the rules the corpus does not exercise. import { createHash } from 'node:crypto'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { decodeJavaBeans, readFlsArchive, readTopology, type FlsApplication, type FlsDhcpServer, type FlsFileTree, type FlsModem, type FlsNodeClass, type FlsTopology, type JavaEmailAccount, } from '../src/index.js'; import { EXPECTED_WARNINGS } from './expectedWarnings.js'; import { SYSTEM_SOFTWARE_CLASS } from '../src/beanCapture.js'; import type { BeanValue } from '../src/javaBeansWriter.js'; import { hasOwnApplications, OWN_APPLICATION_FILES, withOwnApplicationsByClass } from './ownApplications.js'; import { javaEmail, javaEmailAccount } from './javaEmailNames.js'; const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus'); const javaView = JSON.parse(readFileSync(join(corpusRoot, 'corpus.java.json'), 'utf8')) as Record; function flsFiles(directory: string): string[] { return readdirSync(directory) .flatMap((name) => { const path = join(directory, name); return statSync(path).isDirectory() ? flsFiles(path) : name.endsWith('.fls') ? [path] : []; }) .sort(); } /** * The palette label Java shows: not read from the file but set from the * class while loading (setTyp(holeHardwareTyp())), in German. */ const TYPE_LABELS: Record = { Rechner: 'Rechner', Notebook: 'Notebook', Switch: 'Switch / WLAN', Vermittlungsrechner: 'Vermittlungsrechner', Gateway: 'Heimrouter', Modem: 'Modem', }; /** FlsOracle.fileTree's shape: contents as length and SHA-256 of their UTF-8 bytes. */ function javaFileTree(tree: FlsFileTree): unknown { const children = tree.children.map(javaFileTree); switch (tree.kind) { case 'directory': return { directory: tree.name, children }; case 'file': return { file: tree.name, type: tree.type, length: tree.content?.length ?? null, sha256: tree.content === null ? null : createHash('sha256').update(tree.content, 'utf8').digest('hex'), children, }; case 'other': return { userObject: tree.className, children }; } } /** FlsOracle.applications' shape: Java's property names, sorted by key. */ function isSystemSoftwareMark(value: BeanValue): boolean { return value !== null && typeof value === 'object' && value.kind === 'object' && value.className === SYSTEM_SOFTWARE_CLASS; } function javaApplications(applications: FlsApplication[]): unknown[] { return [...applications] .sort((lhs, rhs) => ((lhs.key ?? '') < (rhs.key ?? '') ? -1 : 1)) .map((application) => ({ key: application.key, class: application.javaClass, // The reader's applications are its node's: each file gives them its system software. onItsSystem: true, ...(application.isActive === undefined ? {} : { aktiv: application.isActive, port: application.port }), ...(application.targetPort === undefined ? {} : { zielIPAdresse: application.targetAddress, zielPort: application.targetPort }), ...(application.recursiveResolutionEnabled === undefined ? {} : { recursiveResolutionEnabled: application.recursiveResolutionEnabled }), ...(application.useVirtualHosts === undefined ? {} : { useVHost: application.useVirtualHosts }), ...(application.maxPeers === undefined ? {} : { maxTeilnehmerZahl: application.maxPeers }), ...(application.firewall === undefined ? {} : { activated: application.firewall.activated, defaultPolicy: application.firewall.defaultPolicy, dropICMP: application.firewall.dropICMP, filterSYNSegmentsOnly: application.firewall.filterSYNSegmentsOnly, filterUdp: application.firewall.filterUdp, rules: application.firewall.rules.map((rule) => [ rule.srcIP, rule.srcMask, rule.destIP, rule.destMask, rule.port, rule.protocol, rule.action, ]), }), ...(application.mailDomain === undefined ? {} : { mailDomain: application.mailDomain, beanAccounts: application.beanAccounts!.map(javaEmailAccount), fileAccounts: javaFileAccounts(application.fileAccounts!), }), ...(application.received === undefined ? {} : { received: application.received.map(javaEmail), sent: application.sent!.map(javaEmail), drafts: application.drafts!.map(javaEmail), addressBook: application.addressBook!.map((contact) => [ contact.name, contact.firstName, contact.street, contact.houseNumber, contact.postalCode, contact.city, contact.email, contact.phone, ]), fileAccount: application.fileAccount === null ? null : javaEmailAccount(application.fileAccount!), }), })); } function javaFileAccounts(accounts: JavaEmailAccount[] | null): unknown { return accounts === null ? null : accounts.map(javaEmailAccount); } /** Java's property names, as FlsOracle.java writes them. */ function javaDhcpServer(server: FlsDhcpServer) { return { aktiv: server.isActive, untergrenze: server.lowerBound, obergrenze: server.upperBound, gatewayip: server.gateway, dnsserverip: server.dnsServer, ownSettings: server.useOwnSettings, staticAssignedAddresses: server.staticAssignedAddresses.map((entry) => `${entry.mac} ${entry.ip}`), }; } function javaModem(modem: FlsModem) { return { mode: modem.mode, port: modem.port, ipAdresse: modem.ipAddress }; } /** The topology in the shape FlsOracle.java writes, less what Swing computes (label width and height). */ function asJavaView(topology: FlsTopology, javaBounds: (number[] | null)[]) { return { success: true, nodes: topology.nodes.map((node, index) => ({ class: node.javaClass, name: node.name, label: TYPE_LABELS[node.javaClass], bounds: [node.x, node.y, ...(javaBounds[index]?.slice(2) ?? [])], ...(node.javaClass === 'Rechner' || node.javaClass === 'Notebook' ? { useIpAsName: node.useIpAsName, useMacAsName: node.useMacAsName } : {}), ports: node.javaClass === 'Switch' || node.javaClass === 'Modem' ? Array.from({ length: node.portCount }, () => ({})) : node.interfaces.map((nic) => ({ ...nic, mac: nic.mac ?? null })), system: { ...node.system, ...('ssid' in node.system ? { ssid: node.system.ssid ?? null } : {}), ...(node.system.dhcpServer === undefined ? {} : { dhcpServer: javaDhcpServer(node.system.dhcpServer), }), ...(node.system.modem === undefined ? {} : { modem: javaModem(node.system.modem) }), ...(node.system.fileSystem === undefined ? {} : { fileSystem: javaFileTree(node.system.fileSystem) }), ...('templateFiles' in node.system ? { templateFiles: undefined } : {}), ...(node.system.applications === undefined ? {} : { applications: [ ...(javaApplications(node.system.applications) as { key: string | null }[]), ...(node.system.ownApplications ?? []).map((application) => ({ key: application.key, class: application.javaClass, onItsSystem: application.bean.statements?.some( (statement) => 'set' in statement && statement.set === 'systemSoftware' && isSystemSoftwareMark(statement.value), ), })), ].sort((lhs, rhs) => ((lhs.key ?? '') < (rhs.key ?? '') ? -1 : 1)), }), ...('ownApplications' in node.system ? { ownApplications: undefined } : {}), ...(node.system.firewall === undefined ? {} : { firewall: { ...node.system.firewall, rules: node.system.firewall.rules.map((rule) => [ rule.srcIP, rule.srcMask, rule.destIP, rule.destMask, rule.port, rule.protocol, rule.action, ]), }, }), }, })), cables: topology.links.map((link) => ({ ends: link.ends.map((end) => [end.node, end.port]), wireless: link.wireless, panel: link.panel, })), documentation: topology.documentation.map((item) => ({ type: item.type, text: item.text, bounds: [item.x, item.y, item.width, item.height], color: item.color, font: item.font === null ? null : [item.font.name, item.font.size, item.font.style], })), }; } type JavaTree = { children: unknown[] }; /** * The files a router's or home router's constructor makes, as Java loaded * each one: they come first in its root, what the file adds follows. Each * router's are cut off here and collected, and must be the same for every * router of a class across the corpus. */ const templateChildren = new Map>(); function withoutTemplateFiles(view: unknown, topology: FlsTopology): unknown { const { nodes, ...rest } = view as { nodes: { class: string; system: Record }[] }; return { ...rest, nodes: nodes.map((node, index) => { const ours = topology.nodes[index]?.system; if ((node.class !== 'Vermittlungsrechner' && node.class !== 'Gateway') || ours?.templateFiles !== true) return node; const tree = node.system.fileSystem as JavaTree; const added = ours.fileSystem?.children.length ?? 0; const template = tree.children.slice(0, tree.children.length - added); const seen = templateChildren.get(node.class) ?? new Set(); seen.add(JSON.stringify(template)); templateChildren.set(node.class, seen); return { ...node, system: { ...node.system, fileSystem: { ...tree, children: tree.children.slice(tree.children.length - added) } }, }; }), }; } describe('corpus: the topology as Java loads it', () => { for (const file of flsFiles(corpusRoot)) { const name = relative(corpusRoot, file); it(name, () => { const values = decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(file))).configurationXml); const topology = readTopology(values); const expected = javaView[name] as { nodes: { bounds: number[] | null }[] }; expect(asJavaView(topology, expected.nodes.map((node) => node.bounds))).toEqual( withoutTemplateFiles(withOwnApplicationsByClass(expected), topology), ); expect(topology.warnings).toEqual(EXPECTED_WARNINGS[name] ?? []); }); } it('leaves out own applications only where Java installs some', () => { const files = flsFiles(corpusRoot).map((file) => relative(corpusRoot, file)); expect(files.filter((name) => hasOwnApplications(javaView[name]))).toEqual(OWN_APPLICATION_FILES); }); it("a router's constructor makes the same files in every corpus file", () => { for (const javaClass of ['Vermittlungsrechner', 'Gateway']) { const templates = [...(templateChildren.get(javaClass) ?? [])].map((text) => JSON.parse(text) as { directory?: string }[]); expect(templates, javaClass).toHaveLength(1); expect(templates[0]!.map((child) => child.directory), javaClass).toEqual(['webserver']); } }); }); // ── Rules the corpus does not exercise ───────────────────────────────────── function doc(body: string): string { return `\n\n${body}\n\n`; } function topologyOf(body: string): FlsTopology { return readTopology(decodeJavaBeans(doc(body))); } function nodeItem(device: string, extra = ''): string { return ` ${device}${extra}`; } describe('readTopology rules', () => { it('passes addresses through Java setIp: canonical, null when invalid, unchanged when null', () => { const { nodes } = topologyOf(`Filius version: 2.9.4 (1.1.2026) ${nodeItem(` 010.0..0.1 255.255.255.256 0a:0b:0c:0d:0e:0f `)}`); expect(nodes[0]!.interfaces).toEqual([ { ip: '10.0.0.1', mask: null, mac: undefined, gateway: '', dns: '', wireless: false }, { ip: '192.168.0.10', mask: '255.255.255.0', mac: '0A:0B:0C:0D:0E:0F', gateway: '', dns: '', wireless: false }, ]); }); it("gives only a Gateway's own first interface the WAN address, not one the file creates", () => { const own = topologyOf(`${nodeItem( '', )}`); const replaced = topologyOf(`${nodeItem(` `)}`); expect(own.nodes[0]!.interfaces.map((nic) => [nic.ip, nic.mask])).toEqual([ ['42.0.0.10', '255.0.0.0'], ['192.168.0.10', '255.255.255.0'], ]); expect(own.nodes[0]!.name).toBe('Heimrouter'); expect(replaced.nodes[0]!.interfaces.map((nic) => nic.ip)).toEqual(['192.168.0.10']); }); it('places a device without bounds at 0,0 instead of leaving it out', () => { const { nodes, warnings } = topologyOf( `${nodeItem('')}`, ); expect(nodes.map((node) => [node.javaClass, node.x, node.y, node.portCount])).toEqual([['Notebook', 0, 0, 1]]); expect(warnings).toEqual([]); }); it('leaves out an unknown device and a cable to a port no device owns, and says so', () => { const { nodes, links, warnings } = topologyOf(` ${nodeItem('')} ${nodeItem(` `)} `); expect(nodes.map((node) => node.javaClass)).toEqual(['Switch']); expect(links).toEqual([{ ends: [{ node: 0, port: 0 }, { node: 0, port: 1 }], wireless: false, panel: [null, null] }]); expect(warnings).toEqual([ 'node 1: unknown device class filius.hardware.knoten.Drucker, left out', 'cable 2: an end is not a port of a device, left out', ]); }); it('reads the lists by position and only when the first element fits, as netzwerkLaden does', () => { // No version line (files before 1.3.0); an empty cable list still takes its place. const topology = topologyOf(`${nodeItem( '', )} 1`); const wrongFirst = topologyOf(` not a node ${nodeItem('')}`); expect(topology.versionLine).toBeNull(); expect(topology.nodes).toHaveLength(1); expect(topology.documentation.map((item) => item.type)).toEqual([1]); expect(wrongFirst.nodes).toEqual([]); }); it("reads a router's firewall wherever the file reaches it, with Java's defaults for what it leaves out", () => { // Settings under the installed application and under the web server's // plug-in belong to the one firewall the constructor made. const { nodes } = topologyOf(`${nodeItem(` filius.software.firewall.Firewall false srcIP 10.0.0.0 1.1.1.1255.255.255.255 53171 filius.software.www.WebServer konfig true `)}`); expect(nodes[0]!.system.firewall).toEqual({ activated: true, defaultPolicy: 0, dropICMP: false, filterSYNSegmentsOnly: false, filterUdp: true, rules: [ { srcIP: '10.0.0.0', srcMask: '', destIP: '', destMask: '', port: -1, protocol: 6, action: 0 }, { srcIP: '1.1.1.1', srcMask: '255.255.255.255', destIP: '', destMask: '', port: 53, protocol: 17, action: 1 }, ], }); }); it('splits static DHCP assignments at Java whitespace and leaves out only a broken one', () => { // U+3000 separates, U+00A0 does not; the third entry has no IP. Java // throws there and never reads the fourth — an error not copied. const { nodes, warnings } = topologyOf(`${nodeItem(` 0A:00:00:00:00:0110.0.0.1extra 0A:00:00:00:00:02 10.0.0.2x 0A:00:00:00:00:03 0A:00:00:00:00:04 10.0.0.4 `)}`); expect(nodes[0]!.system.dhcpServer).toEqual({ isActive: false, lowerBound: '0.0.0.0', upperBound: '0.0.0.0', gateway: '0.0.0.0', dnsServer: '0.0.0.0', useOwnSettings: false, staticAssignedAddresses: [ { mac: '0A:00:00:00:00:01', ip: '10.0.0.1' }, { mac: '0A:00:00:00:00:02', ip: '10.0.0.2\u00A0x' }, { mac: '0A:00:00:00:00:04', ip: '10.0.0.4' }, ], }); expect(warnings).toEqual([ 'node 1: DHCPServer: static assignment "0A:00:00:00:00:03" has no MAC and IP, left out', ]); }); it('reads a file system from `root` or `arbeitsVerzeichnis`, with files by setters or constructor', () => { const { nodes, warnings } = topologyOf(` ${nodeItem('')} ${nodeItem(` / a.txttexthallo txt 0 `)}`); expect(nodes.map((node) => node.system.fileSystem)).toEqual([ { kind: 'directory', name: 'root', children: [] }, { kind: 'directory', name: '/', children: [ { kind: 'directory', name: null, children: [{ kind: 'file', name: 'a.txt', type: 'txt', content: 'hallo', children: [] }], }, ], }, ]); expect(warnings).toEqual(['node 2: file system: insert on a node is not read']); }); it("reads what a router's file adds to the constructor's files, or a root of its own", () => { const folder = (name: string) => `${name}`; const { nodes } = topologyOf(` ${nodeItem('')} ${nodeItem(` ${folder('www.conf')} `)} ${nodeItem(` root${folder('own')} `)} ${nodeItem(` `)}`); const directory = (name: string, children: FlsFileTree[] = []): FlsFileTree => ({ kind: 'directory', name, children }); expect(nodes.map((node) => [node.system.fileSystem, node.system.templateFiles])).toEqual([ [directory('root'), true], [directory('root', [directory('www.conf')]), true], [directory('root', [directory('own')]), false], [directory('root'), false], ]); }); it("gives installed applications Java's measured defaults for what the file leaves out", () => { const put = (className: string, body = '') => `${className}${body}`; const { nodes } = topologyOf(`${nodeItem(` ${put('filius.software.www.WebServer')} ${put('filius.software.clientserver.ClientBaustein')} ${put('filius.software.dateiaustausch.PeerToPeerAnwendung')} ${put('filius.software.firewall.Firewall')} ${put('filius.software.dns.DNSServer', 'true5353')} ${put('filius.software.lokal.Terminal')} `)}`); const byClass = new Map(nodes[0]!.system.applications!.map((application) => [application.javaClass, application])); expect(byClass.get('filius.software.www.WebServer')).toMatchObject({ isActive: false, port: 80, useVirtualHosts: false }); expect(byClass.get('filius.software.clientserver.ClientBaustein')).toMatchObject({ targetAddress: null, targetPort: 55_555 }); // Java rolls it: no default. expect(byClass.get('filius.software.dateiaustausch.PeerToPeerAnwendung')).toMatchObject({ maxPeers: null }); expect(byClass.get('filius.software.firewall.Firewall')?.firewall).toMatchObject({ activated: true, defaultPolicy: 0 }); expect(byClass.get('filius.software.dns.DNSServer')).toMatchObject({ isActive: true, port: 5353, recursiveResolutionEnabled: false }); expect(Object.keys(byClass.get('filius.software.lokal.Terminal')!).sort()).toEqual(['javaClass', 'key', 'source']); }); it('reads the e-mail beans through their setters: null names, string senders, a checked domain', () => { const put = (className: string, body = '') => `${className}${body}`; const { nodes } = topologyOf(`${nodeItem(` ${put('filius.software.email.EmailServer', ` a_b.de bob Alice <alice@senior.de> `)} `)}`); const server = nodes[0]!.system.applications![0]!; // setMailDomain keeps "filius.de" for a domain that is not a domain name. expect(server).toMatchObject({ isActive: false, mailDomain: 'filius.de' }); expect(server.beanAccounts![0]).toMatchObject({ username: 'bob', lastName: '', firstName: '' }); expect(server.beanAccounts![0]!.messages[0]!.sender).toEqual({ name: 'Alice', mailAddress: 'alice@senior.de' }); }); it("reads an older file's kontoListe, which FILIUS 2.14 ignores, in the order of its entries", () => { const legacyAccount = (username: string) => `${username}@senior.de ${username} 110 `; const { nodes } = topologyOf(`${nodeItem(` filius.software.email.EmailAnwendung ${legacyAccount('rechner3')}${legacyAccount('rechner4')} `)}`); const client = nodes[0]!.system.applications![0]!; expect(client.fileAccount).toBeNull(); expect(client.legacyAccounts!.map((account) => [account.username, account.pop3Port])).toEqual([ ['rechner3', '110'], ['rechner4', '110'], ]); }); it("gives a modem Java's defaults and keeps an address the file sets to null", () => { const { nodes } = topologyOf(` ${nodeItem('')} ${nodeItem(` 1 `)}`); expect(nodes.map((node) => node.system.modem)).toEqual([ { mode: 2, port: 12_345, ipAddress: 'localhost' }, { mode: 1, port: 12_345, ipAddress: null }, ]); }); it("reads colors as Java's constructors do and keeps an invalid one unset", () => { const colors = ['16711935', '123', '123004']; const { documentation, warnings } = topologyOf(` ${colors .map( (args) => ` ${args} Dialog514 `, ) .join('')}`); expect(documentation.map((item) => item.color)).toEqual([[255, 0, 255, 255], [1, 2, 3, 255], null]); expect(documentation[0]!.font).toEqual({ name: 'Dialog', size: 14, style: 0 }); expect(warnings).toEqual(['documentation item: color component outside 0…255, left unset (as in Java)']); }); });