readSystemSoftware adds to each node what the topology needs from its system software: a switch's SSID and learning time, a host's wireless SSID, a router's RIP switch, the manual routes and the firewall of a router or home router. All 23 corpus files match Java. - A router's firewall is one object its constructor hangs in two places; the corpus writes it under the web server's "konfig" plug-in. The places are merged. - The decoder keeps entries a constructor made in a map, reached by get(key) (installed applications), like the elements of a list. - The Java oracle loads every file twice and writes values that differ as null: what FILIUS rolls (MACs, a switch's SSID) without text search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
255 lines
12 KiB
TypeScript
255 lines
12 KiB
TypeScript
// 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 { 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 FlsNodeClass, type FlsTopology } from '../src/index.js';
|
|
|
|
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
|
|
const javaView = JSON.parse(readFileSync(join(corpusRoot, 'corpus.java.json'), 'utf8')) as Record<string, unknown>;
|
|
|
|
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<FlsNodeClass, string> = {
|
|
Rechner: 'Rechner',
|
|
Notebook: 'Notebook',
|
|
Switch: 'Switch / WLAN',
|
|
Vermittlungsrechner: 'Vermittlungsrechner',
|
|
Gateway: 'Heimrouter',
|
|
Modem: 'Modem',
|
|
};
|
|
|
|
/** 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.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,
|
|
})),
|
|
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],
|
|
})),
|
|
};
|
|
}
|
|
|
|
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(expected);
|
|
expect(topology.warnings).toEqual([]);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ── Rules the corpus does not exercise ─────────────────────────────────────
|
|
|
|
function doc(body: string): string {
|
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<java version="17" class="java.beans.XMLDecoder">\n${body}\n</java>\n`;
|
|
}
|
|
|
|
function topologyOf(body: string): FlsTopology {
|
|
return readTopology(decodeJavaBeans(doc(body)));
|
|
}
|
|
|
|
function nodeItem(device: string, extra = ''): string {
|
|
return `<void method="add"><object class="filius.gui.netzwerksicht.GUIKnotenItem">
|
|
<void property="knoten">${device}</void>${extra}</object></void>`;
|
|
}
|
|
|
|
describe('readTopology rules', () => {
|
|
it('passes addresses through Java setIp: canonical, null when invalid, unchanged when null', () => {
|
|
const { nodes } = topologyOf(`<string>Filius version: 2.9.4 (1.1.2026)</string>
|
|
<object class="java.util.LinkedList">${nodeItem(`<object class="filius.hardware.knoten.Vermittlungsrechner">
|
|
<void property="netzwerkInterfaces">
|
|
<void index="0"><void property="ip"><string>010.0..0.1</string></void>
|
|
<void property="subnetzMaske"><string>255.255.255.256</string></void></void>
|
|
<void method="add"><object class="filius.hardware.NetzwerkInterface">
|
|
<void property="ip"><null/></void><void property="mac"><string>0a:0b:0c:0d:0e:0f</string></void>
|
|
</object></void>
|
|
</void></object>`)}</object>`);
|
|
|
|
expect(nodes[0]!.interfaces).toEqual([
|
|
{ ip: '10.0.0.1', mask: null, mac: undefined, gateway: '', dns: '' },
|
|
{ ip: '192.168.0.10', mask: '255.255.255.0', mac: '0A:0B:0C:0D:0E:0F', gateway: '', dns: '' },
|
|
]);
|
|
});
|
|
|
|
it("gives only a Gateway's own first interface the WAN address, not one the file creates", () => {
|
|
const own = topologyOf(`<object class="java.util.LinkedList">${nodeItem(
|
|
'<object class="filius.hardware.knoten.Gateway"/>',
|
|
)}</object>`);
|
|
const replaced = topologyOf(`<object class="java.util.LinkedList">${nodeItem(`<object class="filius.hardware.knoten.Gateway">
|
|
<void property="netzwerkInterfaces"><object class="java.util.LinkedList">
|
|
<void method="add"><object class="filius.hardware.NetzwerkInterface"/></void>
|
|
</object></void></object>`)}</object>`);
|
|
|
|
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(
|
|
`<object class="java.util.LinkedList">${nodeItem('<object class="filius.hardware.knoten.Notebook"/>')}</object>`,
|
|
);
|
|
|
|
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(`<object class="java.util.LinkedList">
|
|
${nodeItem('<object class="filius.hardware.knoten.Drucker"/>')}
|
|
${nodeItem(`<object class="filius.hardware.knoten.Switch"><void property="anschluesse">
|
|
<void id="Port0" index="0"/><void id="Port1" index="1"/></void></object>`)}
|
|
</object>
|
|
<object class="java.util.LinkedList">
|
|
<void method="add"><object class="filius.gui.netzwerksicht.GUIKabelItem"><void property="dasKabel">
|
|
<object class="filius.hardware.Kabel"><object idref="Port0"/><object idref="Port1"/></object></void></object></void>
|
|
<void method="add"><object class="filius.gui.netzwerksicht.GUIKabelItem"><void property="dasKabel">
|
|
<object class="filius.hardware.Kabel"><object idref="Port0"/><object class="filius.hardware.Port"/></object></void></object></void>
|
|
</object>`);
|
|
|
|
expect(nodes.map((node) => node.javaClass)).toEqual(['Switch']);
|
|
expect(links).toEqual([{ ends: [{ node: 0, port: 0 }, { node: 0, port: 1 }], wireless: false }]);
|
|
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(`<object class="java.util.LinkedList">${nodeItem(
|
|
'<object class="filius.hardware.knoten.Rechner"/>',
|
|
)}</object>
|
|
<object class="java.util.LinkedList"/>
|
|
<object class="java.util.LinkedList"><void method="add"><object class="filius.gui.netzwerksicht.GUIDocuItem">
|
|
<void property="type"><int>1</int></void></object></void></object>`);
|
|
const wrongFirst = topologyOf(`<object class="java.util.LinkedList">
|
|
<void method="add"><string>not a node</string></void>
|
|
${nodeItem('<object class="filius.hardware.knoten.Rechner"/>')}</object>`);
|
|
|
|
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(`<object class="java.util.LinkedList">${nodeItem(`<object class="filius.hardware.knoten.Vermittlungsrechner">
|
|
<void property="systemSoftware"><void property="installierteAnwendungen">
|
|
<void method="get"><string>filius.software.firewall.Firewall</string>
|
|
<void property="allowRelatedPackets"><boolean>false</boolean></void>
|
|
<void property="ruleset">
|
|
<void method="add"><object class="filius.software.firewall.FirewallRule" id="FirewallRule0">
|
|
<void class="filius.software.firewall.FirewallRule" method="getField"><string>srcIP</string>
|
|
<void method="set"><object idref="FirewallRule0"/><string>10.0.0.0</string></void></void>
|
|
</object></void>
|
|
<void method="add"><object class="filius.software.firewall.FirewallRule">
|
|
<string>1.1.1.1</string><string>255.255.255.255</string><string></string><string></string>
|
|
<int>53</int><short>17</short><short>1</short>
|
|
</object></void>
|
|
</void>
|
|
</void>
|
|
<void method="get"><string>filius.software.www.WebServer</string>
|
|
<void property="plugins"><void method="get"><string>konfig</string>
|
|
<void property="firewall"><void property="activated"><boolean>true</boolean></void></void>
|
|
</void></void>
|
|
</void>
|
|
</void></void></object>`)}</object>`);
|
|
|
|
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("reads colors as Java's constructors do and keeps an invalid one unset", () => {
|
|
const colors = ['<int>16711935</int>', '<int>1</int><int>2</int><int>3</int>', '<int>1</int><int>2</int><int>300</int><int>4</int>'];
|
|
const { documentation, warnings } = topologyOf(`<object class="java.util.LinkedList"/><object class="java.util.LinkedList"/>
|
|
<object class="java.util.LinkedList">${colors
|
|
.map(
|
|
(args) => `<void method="add"><object class="filius.gui.netzwerksicht.GUIDocuItem">
|
|
<void property="color"><object class="java.awt.Color">${args}</object></void>
|
|
<void property="font"><object class="java.awt.Font"><string>Dialog</string><int>5</int><int>14</int></object></void>
|
|
</object></void>`,
|
|
)
|
|
.join('')}</object>`);
|
|
|
|
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)']);
|
|
});
|
|
});
|