Files
mdg-filius/packages/fls/test/topologyWriter.test.ts
T
julian.kiedaischandClaude Opus 5.5 34d3788cd2 feat(fls): write the e-mail programs of a .fls, loaded by Java as the original
topologyWriter.ts writes the e-mail server's domain and bean accounts with
their mailboxes, and the e-mail client's three folders and address book.
The client's account is no bean (FILIUS 2.14 has no kontoListe property):
it lives in /konten.txt and is written with the files.

A Java error is not copied: older files keep recipients as strings, which
Java puts into its typed lists unchecked, and its e-mail window fails on
them. The writer writes the AddressEntry Java's own AddressEntry(String)
makes of each; a null recipient is left out. The Java oracle now reports
that reading next to each such string, and our addressEntry matches it on
all 9 in the corpus. The test names the departure (RECIPIENTS_AS_STRINGS)
and checks that it touches man-in-the-middle.fls alone.

Checked by Java against Java: every corpus file, read and written again,
loads with the same e-mail programs as the original, up to that departure.
The synthetic topology adds an e-mail server and client with an account, a
mailbox, three folders, an address book and recipients of every form. 15
mutation probes, each loaded by Java, were all caught. The writer now
writes everything the reader reads of Java's view.

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

573 lines
22 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 topology = readTopology(decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(file))).configurationXml));
const archive = writeFlsArchive(new TextEncoder().encode(writeTopologyXml(topology)));
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' }],
portCount: 1,
system: {
ssid: 'Lab',
manualRoutes: [['10.1.0.0', '255.255.0.0', '10.0.0.254', '10.0.0.1']],
dhcpClient: 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: '',
})),
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: '',
})),
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: '' });
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' }],
system: {
ssid: 'Lab',
manualRoutes: [['10.1.0.0', '255.255.0.0', '10.0.0.254', '10.0.0.1']],
dhcpClient: 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', zielIPAdresse: '10.0.0.9', zielPort: 7777 },
{ key: 'filius.software.clientserver.ServerBaustein', class: 'filius.software.clientserver.ServerBaustein', aktiv: true, port: 7777 },
{ key: 'filius.software.dateiaustausch.PeerToPeerAnwendung', class: 'filius.software.dateiaustausch.PeerToPeerAnwendung', maxTeilnehmerZahl: 7 },
{ key: 'filius.software.dns.DNSServer', class: 'filius.software.dns.DNSServer', aktiv: true, port: 5353, recursiveResolutionEnabled: true },
{
key: 'filius.software.email.EmailAnwendung',
class: 'filius.software.email.EmailAnwendung',
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',
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',
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' },
{ key: 'filius.software.lokal.ImageViewer', class: 'filius.software.lokal.ImageViewer' },
{ key: 'filius.software.lokal.Terminal', class: 'filius.software.lokal.Terminal' },
{ key: 'filius.software.lokal.TextEditor', class: 'filius.software.lokal.TextEditor' },
{ key: 'filius.software.www.WebBrowser', class: 'filius.software.www.WebBrowser' },
{ key: 'filius.software.www.WebServer', class: 'filius.software.www.WebServer', aktiv: true, port: 8080, useVHost: true },
],
},
},
{
class: 'Switch',
name: '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,
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,
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 }],
});
});
});