Files
mdg-filius/packages/fls/test/topologyWriter.test.ts
julian.kiedaischandClaude Opus 5.5 0abce60aef test: F2 acceptance — five of FILIUS' examples run as Java FILIUS runs them
ExampleRun opens an example as FILIUS opens it (SzenarioVerwaltung.laden
into the window's lists), starts it as the action mode does, plays a
story and writes every card's frames and tables, the calls, the
addresses and the RIP tables' changes. examplesJava.test.ts reads the
same file with this package's reader and runs it in the core
(projectSnapshot, new): routing (internetwork_komplett), DHCP
(einfaches_rechnernetz_komplett), DNS (dns_server), NAT
(nat_with_mapped_port) and firewall (Internet_Komplett_..._DE) match
card by card.

Found and fixed: a host's ipForwardingEnabled, which Java saves and
obeys, was unknown to the reader, the model, the core, the writer, the
export and FlsOracle; and Java's ARP learns only from a request for its
own address or a reply. Named Java bugs: a ping without a gateway throws
a NullPointerException; a home router drops the answer of a second
remote end. Open: Java picks between equally long RIP routes by thread
timing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:57:10 +00:00

581 lines
23 KiB
TypeScript

// Every corpus file, read and written again: Java FILIUS loads the written
// file (tools/java-oracle, written.java.json) to the same topology as the
// original (corpus.java.json) — devices, names, positions, interfaces,
// system software, files, applications with their e-mail accounts,
// messages and address books, cables and documentation — up to one Java
// error the writer does not copy (RECIPIENTS_AS_STRINGS). Java against Java: no mapping of this package stands
// between the two.
//
// UPDATE_WRITTEN=1 writes the files to test/written/ (not checked in); the
// Java oracle then loads them and records each file's SHA-256, which must
// match what the writer 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 { describe, expect, it } from 'vitest';
import { decodeJavaBeans, readFlsArchive, readTopology, writeFlsArchive } from '../src/index.js';
import type { JavaEmail } from '../src/javaEmail.js';
import { writeTopologyXml, type TopologyToWrite } from '../src/topologyWriter.js';
const here = dirname(fileURLToPath(import.meta.url));
const corpusRoot = join(here, '../../../corpus');
const writtenRoot = join(here, 'written');
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] : [];
});
}
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 written = JSON.parse(readFileSync(join(here, 'written.java.json'), 'utf8')) as Record<string, JavaFile>;
/**
* The Java error the writer does not copy: older files keep e-mail
* recipients as strings, which Java puts into its typed lists unchecked (its
* e-mail window then fails on them). The writer writes the AddressEntry
* Java's own AddressEntry(String) makes of each — the oracle reports it
* next to the string — and leaves a null recipient out.
*/
const RECIPIENTS_AS_STRINGS = ['java-examples/man-in-the-middle.fls'];
/** Java's view of the original as the writer means it: 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;
}
/**
* The topology of Java's view: the label's position, not its size, which
* Java works out itself.
*/
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: node.system,
})),
cables: view.cables,
documentation: view.documentation,
};
}
describe('the topology written again, as Java loads it', () => {
for (const file of flsFiles(corpusRoot)) {
const name = relative(corpusRoot, file);
it(name, () => {
const contents = readFlsArchive(new Uint8Array(readFileSync(file)));
const topology = readTopology(decodeJavaBeans(contents.configurationXml));
// With the archive's other entries: the classes of the project's own applications.
const archive = writeFlsArchive(new TextEncoder().encode(writeTopologyXml(topology)), contents.supplementalEntries);
if (process.env.UPDATE_WRITTEN === '1') {
mkdirSync(dirname(join(writtenRoot, name)), { recursive: true });
writeFileSync(join(writtenRoot, name), archive);
}
const java = written[name];
expect(java?.sha256, 'written.java.json is not about this output: UPDATE_WRITTEN=1, then npm run oracle:java').toBe(
createHash('sha256').update(archive).digest('hex'),
);
expect(topologyOf(java!)).toEqual(topologyOf(withRecipientsRead(original[name]!) as JavaFile));
});
}
it('departs from Java only where it keeps recipients as strings', () => {
const changed = flsFiles(corpusRoot)
.map((file) => relative(corpusRoot, file))
.filter((name) => JSON.stringify(withRecipientsRead(original[name])) !== JSON.stringify(original[name]));
expect(changed).toEqual(RECIPIENTS_AS_STRINGS);
});
});
/**
* An e-mail with a sender without address, recipients as entries, as the
* string an older file keeps (read as AddressEntry(String) reads it) and
* null (left out), a text with CR, and a date.
*/
const SYNTHETIC_MAIL: JavaEmail = {
sender: { name: 'Anna', mailAddress: null },
to: [{ name: null, mailAddress: 'ben@schule.de' }, 'Carla <carla@schule.de>', null],
cc: [{ name: 'Dora', mailAddress: 'dora@schule.de' }],
bcc: ['eva@schule.de'],
subject: 'Hallo',
text: 'Zeile 1\r\nZeile 2',
dateReceived: '1727690000000',
isNew: true,
isDeleted: true,
isSent: false,
};
/** SYNTHETIC_MAIL as the oracle describes it after Java loaded it. */
function javaMail(changes: Record<string, unknown> = {}) {
return {
absender: ['Anna', null],
empfaenger: [[null, 'ben@schule.de'], ['Carla', 'carla@schule.de']],
cc: [['Dora', 'dora@schule.de']],
bcc: [[null, 'eva@schule.de']],
betreff: 'Hallo',
text: 'Zeile 1\r\nZeile 2',
dateReceived: '1727690000000',
neu: true,
delete: true,
versendet: false,
...changes,
};
}
/** Text a file's content may hold: markup, CR and CRLF, a tab, beyond the BMP. */
const FILE_CONTENT = '<p>a & b</p>\r\nline\rend\ttab Grüße 😀\n';
/**
* What the corpus does not have: a name set to null, useMACAsName, fewer
* ports than the constructor makes, more interfaces; a PC's SSID, routes and
* DHCP server with static assignments; a router's firewall switched on, with
* every flag away from its default and rules of every protocol, one with a
* null address; a modem's own port and address.
*/
const SYNTHETIC: TopologyToWrite = {
nodes: [
{
javaClass: 'Rechner',
name: null,
x: 10,
y: 20,
useIpAsName: false,
useMacAsName: true,
interfaces: [{ ip: '10.0.0.1', mask: '255.0.0.0', mac: 'AA:BB:CC:DD:EE:01', gateway: null, dns: '10.0.0.53', wireless: true }],
portCount: 1,
system: {
ssid: 'Lab',
manualRoutes: [['10.1.0.0', '255.255.0.0', '10.0.0.254', '10.0.0.1']],
dhcpClient: true,
ipForwardingEnabled: true,
dhcpServer: {
isActive: true,
lowerBound: '10.0.0.100',
upperBound: '10.0.0.199',
gateway: '10.0.0.254',
dnsServer: '10.0.0.53',
useOwnSettings: true,
staticAssignedAddresses: [{ mac: 'AA:BB:CC:DD:EE:77', ip: '10.0.0.77' }],
},
fileSystem: {
kind: 'directory',
name: '/',
children: [
{
kind: 'directory',
name: 'docs',
children: [
{ kind: 'file', name: 'a.txt', type: 'text', content: FILE_CONTENT, children: [] },
{ kind: 'directory', name: null, children: [] },
],
},
{ kind: 'file', name: 'untyped', type: null, content: null, children: [] },
{ kind: 'directory', name: 'webserver', children: [{ kind: 'file', name: 'index.html', type: 'html', content: '<p>own</p>', children: [] }] },
{ kind: 'directory', name: 'dns', children: [{ kind: 'file', name: 'hosts', type: null, content: 'a.de. A 3600 10.0.0.1\n', children: [] }] },
],
},
applications: [
{
key: 'filius.software.firewall.Firewall',
javaClass: 'filius.software.firewall.Firewall',
firewall: {
activated: false,
defaultPolicy: 1,
dropICMP: true,
filterSYNSegmentsOnly: false,
filterUdp: false,
rules: [{ srcIP: '10.0.0.0', srcMask: '255.0.0.0', destIP: '', destMask: '', port: 22, protocol: 6, action: 1 }],
},
},
{ key: 'filius.software.www.WebServer', javaClass: 'filius.software.www.WebServer', isActive: true, port: 8080, useVirtualHosts: true },
{
key: 'filius.software.dns.DNSServer',
javaClass: 'filius.software.dns.DNSServer',
isActive: true,
port: 5353,
recursiveResolutionEnabled: true,
},
{ key: 'filius.software.clientserver.ServerBaustein', javaClass: 'filius.software.clientserver.ServerBaustein', isActive: true, port: 7777 },
{
key: 'filius.software.clientserver.ClientBaustein',
javaClass: 'filius.software.clientserver.ClientBaustein',
targetAddress: '10.0.0.9',
targetPort: 7777,
},
{ key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', javaClass: 'filius.software.dateiaustausch.PeerToPeerAnwendung', maxPeers: 7 },
{ key: 'filius.software.lokal.Terminal', javaClass: 'filius.software.lokal.Terminal' },
{ key: 'filius.software.lokal.TextEditor', javaClass: 'filius.software.lokal.TextEditor' },
{ key: 'filius.software.lokal.FileExplorer', javaClass: 'filius.software.lokal.FileExplorer' },
{ key: 'filius.software.lokal.ImageViewer', javaClass: 'filius.software.lokal.ImageViewer' },
{ key: 'filius.software.www.WebBrowser', javaClass: 'filius.software.www.WebBrowser' },
{
key: 'filius.software.email.EmailServer',
javaClass: 'filius.software.email.EmailServer',
isActive: true,
mailDomain: 'schule.de',
beanAccounts: [
{
username: 'anna',
password: 'geheim',
lastName: 'Muster',
firstName: '',
emailAddress: 'anna@schule.de',
pop3Server: null,
pop3Port: null,
smtpServer: null,
smtpPort: null,
messages: [SYNTHETIC_MAIL],
},
],
},
{
key: 'filius.software.email.EmailAnwendung',
javaClass: 'filius.software.email.EmailAnwendung',
received: [SYNTHETIC_MAIL],
sent: [{ ...SYNTHETIC_MAIL, isSent: true, isNew: false }],
drafts: [{ ...SYNTHETIC_MAIL, subject: null, text: null, dateReceived: null, to: [] }],
addressBook: [
{
name: 'Muster',
firstName: 'Anna',
street: null,
houseNumber: 12,
postalCode: 20095,
city: 'Hamburg',
email: 'anna@schule.de',
phone: null,
},
],
},
],
},
},
{
javaClass: 'Switch',
name: 'S',
x: 100,
y: 20,
useIpAsName: false,
useMacAsName: false,
interfaces: [],
portCount: 3,
system: { ssid: 'S-net', retentionTime: 1234 },
},
{
javaClass: 'Vermittlungsrechner',
name: 'R',
x: 200,
y: 20,
useIpAsName: false,
useMacAsName: false,
interfaces: ['1', '2', '3'].map((octet) => ({
ip: `10.0.${octet}.1`,
mask: '255.255.255.0',
mac: `AA:BB:CC:DD:EE:1${octet}`,
gateway: '',
dns: '',
wireless: false,
})),
portCount: 3,
system: {
ripEnabled: true,
fileSystem: { kind: 'directory', name: 'root', children: [{ kind: 'directory', name: 'www.conf', children: [] }] },
templateFiles: true,
firewall: {
activated: true,
defaultPolicy: 1,
dropICMP: true,
filterSYNSegmentsOnly: false,
filterUdp: false,
rules: [
{ srcIP: '10.0.1.0', srcMask: '255.255.255.0', destIP: '', destMask: '', port: -1, protocol: -1, action: 1 },
{ srcIP: '', srcMask: '', destIP: '10.0.2.5', destMask: '255.255.255.255', port: 80, protocol: 6, action: 0 },
{ srcIP: null, srcMask: '', destIP: '', destMask: '', port: 53, protocol: 17, action: 1 },
{ srcIP: '', srcMask: '', destIP: '', destMask: '', port: -1, protocol: 1, action: 0 },
],
},
},
},
{
javaClass: 'Modem',
name: 'M',
x: 300,
y: 20,
useIpAsName: false,
useMacAsName: false,
interfaces: [],
portCount: 1,
system: { modem: { mode: 1, port: 4242, ipAddress: '192.168.1.5' } },
},
{
javaClass: 'Gateway',
name: 'G',
x: 400,
y: 20,
useIpAsName: false,
useMacAsName: false,
interfaces: ['4', '5'].map((octet) => ({
ip: `10.0.${octet}.1`,
mask: '255.255.255.0',
mac: `AA:BB:CC:DD:EE:1${octet}`,
gateway: '',
dns: '',
wireless: false,
})),
portCount: 2,
system: {
fileSystem: { kind: 'directory', name: 'own', children: [{ kind: 'file', name: 'x', type: 'text', content: 'y', children: [] }] },
templateFiles: false,
},
},
],
links: [
{ ends: [{ node: 0, port: 0 }, { node: 1, port: 2 }], wireless: false, panel: [0, 1] },
{ ends: [{ node: 2, port: 2 }, { node: 1, port: 0 }], wireless: true, panel: [2, 1] },
],
documentation: [{ type: 1, text: null, x: 5, y: 6, width: 70, height: 80, color: null, font: null }],
};
/** 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: [],
};
/** A file as the oracle describes it: its content as UTF-16 length and SHA-256 of its UTF-8 bytes. */
function javaFile(name: string, type: string | null, content: string | null) {
return {
file: name,
type,
length: content === null ? null : content.length,
sha256: content === null ? null : createHash('sha256').update(content, 'utf8').digest('hex'),
children: [],
};
}
/** The files a router's constructor makes, as Java loaded them from the corpus. */
function routerTemplate(): unknown[] {
const router = Object.values(original)
.flatMap((file) => file.nodes ?? [])
.find((node) => node.class === 'Vermittlungsrechner');
return ((router!.system.fileSystem as { children: unknown[] }).children).slice(0, 1);
}
/** A router's firewall as its constructor leaves it. */
const JAVA_ROUTER_FIREWALL = { activated: false, defaultPolicy: 0, dropICMP: false, filterSYNSegmentsOnly: true, filterUdp: true, rules: [] };
describe('a topology beyond the corpus, as Java loads it', () => {
it('keeps what the corpus never sets', () => {
const name = 'synthetic/synthetic.fls';
const archive = writeFlsArchive(new TextEncoder().encode(writeTopologyXml(SYNTHETIC)));
if (process.env.UPDATE_WRITTEN === '1') {
mkdirSync(join(writtenRoot, 'synthetic'), { recursive: true });
writeFileSync(join(writtenRoot, name), archive);
}
const java = written[name];
expect(java?.sha256, 'written.java.json is not about this output: UPDATE_WRITTEN=1, then npm run oracle:java').toBe(
createHash('sha256').update(archive).digest('hex'),
);
const plainInterface = (octet: string) => ({ ip: `10.0.${octet}.1`, mask: '255.255.255.0', mac: `AA:BB:CC:DD:EE:1${octet}`, gateway: '', dns: '', wireless: false });
expect(topologyOf(java!)).toEqual({
success: true,
nodes: [
{
class: 'Rechner',
name: null,
position: [10, 20],
useIpAsName: false,
useMacAsName: true,
ports: [{ ip: '10.0.0.1', mask: '255.0.0.0', mac: 'AA:BB:CC:DD:EE:01', gateway: null, dns: '10.0.0.53', wireless: true }],
system: {
ssid: 'Lab',
manualRoutes: [['10.1.0.0', '255.255.0.0', '10.0.0.254', '10.0.0.1']],
dhcpClient: true,
ipForwardingEnabled: true,
dhcpServer: {
aktiv: true,
untergrenze: '10.0.0.100',
obergrenze: '10.0.0.199',
gatewayip: '10.0.0.254',
dnsserverip: '10.0.0.53',
ownSettings: true,
staticAssignedAddresses: ['AA:BB:CC:DD:EE:77 10.0.0.77'],
},
fileSystem: {
directory: '/',
children: [
{ directory: 'docs', children: [javaFile('a.txt', 'text', FILE_CONTENT), { directory: null, children: [] }] },
javaFile('untyped', null, null),
{ directory: 'webserver', children: [javaFile('index.html', 'html', '<p>own</p>')] },
{ directory: 'dns', children: [javaFile('hosts', null, 'a.de. A 3600 10.0.0.1\n')] },
// Made by the peer-to-peer application and the e-mail server, whose directories the files lack.
{ directory: 'peer2peer', children: [] },
{ directory: 'mailserver', children: [] },
],
},
// 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: 5353, recursiveResolutionEnabled: true },
{
key: 'filius.software.email.EmailAnwendung',
class: 'filius.software.email.EmailAnwendung', onItsSystem: true,
received: [javaMail()],
sent: [javaMail({ versendet: true, neu: false })],
drafts: [javaMail({ betreff: null, text: null, dateReceived: null, empfaenger: [] })],
addressBook: [['Muster', 'Anna', null, 12, 20095, 'Hamburg', 'anna@schule.de', null]],
// No /konten.txt.
fileAccount: null,
},
{
key: 'filius.software.email.EmailServer',
class: 'filius.software.email.EmailServer', onItsSystem: true,
aktiv: true,
mailDomain: 'schule.de',
beanAccounts: [
{
benutzername: 'anna',
passwort: 'geheim',
nachname: 'Muster',
vorname: '',
emailAdresse: 'anna@schule.de',
pop3server: null,
pop3port: null,
smtpserver: null,
smtpport: null,
nachrichten: [javaMail()],
},
],
// No /mailserver/konten.txt.
fileAccounts: null,
},
{
key: 'filius.software.firewall.Firewall',
class: 'filius.software.firewall.Firewall', onItsSystem: true,
activated: false,
defaultPolicy: 1,
dropICMP: true,
filterSYNSegmentsOnly: false,
filterUdp: false,
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: 'S',
position: [100, 20],
useIpAsName: undefined,
useMacAsName: undefined,
ports: [{}, {}, {}],
system: { ssid: 'S-net', retentionTime: 1234 },
},
{
class: 'Vermittlungsrechner',
name: 'R',
position: [200, 20],
useIpAsName: undefined,
useMacAsName: undefined,
ports: ['1', '2', '3'].map(plainInterface),
system: {
ripEnabled: true,
fileSystem: { directory: 'root', children: [...routerTemplate(), { directory: 'www.conf', children: [] }] },
manualRoutes: [],
dhcpClient: false,
ipForwardingEnabled: true,
dhcpServer: JAVA_DHCP_DEFAULTS,
firewall: {
activated: true,
defaultPolicy: 1,
dropICMP: true,
filterSYNSegmentsOnly: false,
filterUdp: false,
rules: [
['10.0.1.0', '255.255.255.0', '', '', -1, -1, 1],
['', '', '10.0.2.5', '255.255.255.255', 80, 6, 0],
[null, '', '', '', 53, 17, 1],
['', '', '', '', -1, 1, 0],
],
},
},
},
{
class: 'Modem',
name: 'M',
position: [300, 20],
useIpAsName: undefined,
useMacAsName: undefined,
ports: [{}],
system: { modem: { mode: 1, port: 4242, ipAdresse: '192.168.1.5' } },
},
{
class: 'Gateway',
name: 'G',
position: [400, 20],
useIpAsName: undefined,
useMacAsName: undefined,
ports: ['4', '5'].map(plainInterface),
system: {
fileSystem: { directory: 'own', children: [javaFile('x', 'text', 'y')] },
manualRoutes: [],
dhcpClient: false,
ipForwardingEnabled: true,
dhcpServer: JAVA_DHCP_DEFAULTS,
staticNAT: [],
firewall: JAVA_ROUTER_FIREWALL,
},
},
],
cables: [
{ ends: [[0, 0], [1, 2]], wireless: false, panel: [0, 1] },
{ ends: [[2, 2], [1, 0]], wireless: true, panel: [2, 1] },
],
documentation: [{ type: 1, text: null, bounds: [5, 6, 70, 80], color: null, font: null }],
});
});
});