Files
mdg-filius/packages/fls/test/topologyWriter.test.ts
T
julian.kiedaischandClaude Opus 5.5 ccec734143 feat(fls): write the installed applications of a .fls, loaded by Java as the original
topologyWriter.ts puts each installed application of a PC or notebook under
its key and gives it the system software, as installApp does, with its
settings: a server's switch and port, a client's target, DNS recursion,
virtual hosts, the peer count, the personal firewall. The applications
follow the files, as in Java's own files: the web server, the peer-to-peer
application and the e-mail server make their directories only where the
files lack them, and the DNS server reads /dns/hosts when it is installed.
E-mail accounts, messages and address books are the next step.

The Java oracle now reports a personal firewall's rules in full, not their
number (the corpus has none; its result is byte-equal).

Checked by Java against Java: every corpus file, read and written again,
loads with the same applications as the original. The synthetic topology
adds every FILIUS application with settings away from its defaults, a
personal firewall with a rule, an own index.html (Java makes no second) and
a missing /peer2peer directory, which Java makes at the end. 12 mutation
probes, among them files and applications in reverse order, each loaded by
Java, were all caught.

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

444 lines
18 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 and applications (without e-mail accounts,
// messages and address books), cables and documentation. 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 { 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 system software without what a later layer writes: the e-mail
* programs' domain, accounts, messages and address book.
*/
function systemOf(system: Record<string, unknown>) {
if (system.applications === undefined) return system;
const applications = (system.applications as Record<string, unknown>[]).map(({ mailDomain: _domain, beanAccounts: _accounts, received: _received, sent: _sent, drafts: _drafts, addressBook: _book, ...rest }) => rest);
return { ...system, applications };
}
/**
* 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: systemOf(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(original[name]!));
});
}
});
/** 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' },
],
},
},
{
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, whose directory the files lack.
{ directory: 'peer2peer', 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.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 }],
});
});
});