topologyWriter.ts adds each node's file tree to the root its constructor makes, as XMLEncoder does (arbeitsVerzeichnis, files as new Datei(name, typ, inhalt)); a root of its own is set anew. The reader now reads the files of routers and home routers too: their constructor makes files from templates (the web interface), and the file adds to them (a web server's www.conf). systemSoftware.ts keeps what the file adds, and templateFiles says whether the constructor's files stay in front (false when the file sets a root of its own). The topology test finds our part at the end of Java's tree and the template in front, the same for every router of a class across the corpus. Checked by Java against Java: every corpus file, read and written again, loads with the same file systems as the original. The synthetic topology adds a nested tree with a renamed root, a nameless directory, a file without type, content with CR, CRLF and beyond-BMP characters, router files after the template and a home router with a root of its own. 9 mutation probes, each loaded by Java, were all caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
376 lines
13 KiB
TypeScript
376 lines
13 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 and files (without applications), 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 its applications, which are a later layer. */
|
|
function systemOf(system: Record<string, unknown>) {
|
|
const { applications: _applications, ...settings } = system;
|
|
return settings;
|
|
}
|
|
|
|
/**
|
|
* 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: [] },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
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),
|
|
],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
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 }],
|
|
});
|
|
});
|
|
});
|