feat(fls): system software settings as Java loads them

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>
This commit is contained in:
2026-09-29 20:56:10 +00:00
co-authored by Claude Opus 5.5
parent da2ec1dae2
commit 9a389810f9
10 changed files with 1851 additions and 228 deletions
+32 -4
View File
@@ -53,6 +53,11 @@ export interface JavaObject {
* result. `add`/`set` go to `calls`, in order.
*/
readonly presetElements: Map<number, JavaValue>;
/**
* The same for a map: entries its constructor made (a router's installed
* applications), reached by `<void method="get"><string>key</string>`.
*/
readonly presetEntries: Map<string, JavaValue>;
/** True when the object was not in the file but returned by a getter. */
readonly fromGetter: boolean;
}
@@ -139,12 +144,21 @@ function replayList(value: JavaObject, constructed: number): (JavaValue | undefi
return items;
}
/** The entries of a map: `put` calls in order. */
/**
* The entries of a map: the ones its constructor made, then `put` calls in
* order — a later `put` of the same key replaces the value in place.
*/
export function mapEntries(value: JavaValue | undefined): [JavaValue, JavaValue][] {
if (!isJavaObject(value)) return [];
return value.calls
.filter((call) => call.method === 'put')
.map((call) => [call.args[0] ?? null, call.args[1] ?? null] as [JavaValue, JavaValue]);
const entries: [JavaValue, JavaValue][] = [...value.presetEntries];
for (const call of value.calls) {
if (call.method !== 'put') continue;
const key = call.args[0] ?? null;
const existing = entries.find(([entryKey]) => entryKey === key);
if (existing === undefined) entries.push([key, call.args[1] ?? null]);
else existing[1] = call.args[1] ?? null;
}
return entries;
}
// ── XML tree ───────────────────────────────────────────────────────────────
@@ -226,6 +240,7 @@ function newObject(fields: Partial<JavaObject> & { className: string }): JavaObj
properties: new Map(),
calls: [],
presetElements: new Map(),
presetEntries: new Map(),
fromGetter: fields.fromGetter ?? false,
};
}
@@ -412,6 +427,7 @@ class Decoder {
if (isJavaObject(target)) {
target.calls.push({ method, args });
if (method === 'get' && args.length === 1 && typeof args[0] === 'number') return this.getIndex(target, args[0], id);
if (method === 'get' && args.length === 1 && typeof args[0] === 'string') return this.getEntry(target, args[0], id);
}
return null;
}
@@ -463,6 +479,18 @@ class Decoder {
return placeholder;
}
/**
* `map.get(key)`: the value put before, else an entry the map held before
* the file touched it — a placeholder that later reads return.
*/
private getEntry(target: JavaObject, key: string, id: string | undefined): JavaValue {
const put = mapEntries(target).find(([entryKey]) => entryKey === key);
if (put !== undefined) return put[1];
const placeholder = newObject({ className: id?.replace(/\d+$/, '') ?? 'unknown', id, fromGetter: true });
target.presetEntries.set(key, placeholder);
return placeholder;
}
private setIndex(target: JavaValue | undefined, index: number, value: JavaValue): JavaValue {
if (typeof target === 'object' && target !== null && (target as JavaArray).kind === 'array') {
(target as JavaArray).items[index] = value;
+198
View File
@@ -0,0 +1,198 @@
// The settings of a node's system software that the topology needs, read as
// Java FILIUS holds them after loading: 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.
// Java counterparts: filius.software.system.SwitchFirmware, Betriebssystem,
// VermittlungsrechnerBetriebssystem, GatewayFirmware, InternetKnotenBetriebssystem,
// filius.software.vermittlungsschicht.Weiterleitungstabelle,
// filius.software.firewall.Firewall and FirewallRule.
//
// Checked against Java (corpus.java.json, `system`). DHCP, NAT, the modem,
// file systems and applications are later layers.
//
// A router's firewall is one object its constructor makes and hangs in two
// places: the installed applications and the configuration plug-ins of its
// web server. XMLEncoder writes its settings wherever it meets it first —
// in the corpus, under the web server's `konfig` plug-in. The decoder does
// not know Java's constructors and sees a placeholder per path; they are
// merged here.
import { isJavaObject, isUnresolved, listItems, mapEntries, type JavaObject, type JavaValue } from './javabeans.js';
import type { FlsNodeClass } from './topology.js';
export interface FlsFirewallRule {
srcIP: string | null;
srcMask: string | null;
destIP: string | null;
destMask: string | null;
/** A port, or -1 for all. */
port: number;
/** -1 all, 1 ICMP, 6 TCP, 17 UDP. */
protocol: number;
/** 0 drop, 1 accept. */
action: number;
}
export interface FlsFirewall {
activated: boolean;
/** 0 drop, 1 accept. */
defaultPolicy: number;
dropICMP: boolean;
filterSYNSegmentsOnly: boolean;
filterUdp: boolean;
rules: FlsFirewallRule[];
}
export interface FlsSystemSoftware {
/**
* Switch: its SSID, undefined when the file has none (Java rolls one).
* Rechner, Notebook: the wireless network it joins, null for none.
*/
ssid?: string | null | undefined;
/** Switch: how long it keeps a learned MAC address, in milliseconds. */
retentionTime?: number;
/** Vermittlungsrechner. */
ripEnabled?: boolean;
/** Every node with an IP stack: rows of destination, mask, gateway, interface address — as the file has them. */
manualRoutes?: (string | null)[][];
/** Vermittlungsrechner, Gateway. */
firewall?: FlsFirewall;
}
type Warn = (message: string) => void;
const FIREWALL_CLASS = 'filius.software.firewall.Firewall';
const WEB_SERVER_CLASS = 'filius.software.www.WebServer';
export function readSystemSoftware(device: JavaObject, javaClass: FlsNodeClass, warn: Warn): FlsSystemSoftware {
const system = device.properties.get('systemSoftware');
const software = isJavaObject(system) ? system : undefined;
const value = (property: string) => software?.properties.get(property);
const result: FlsSystemSoftware = {};
switch (javaClass) {
case 'Switch':
result.ssid = optionalString(value('SSID'), 'SSID', warn);
result.retentionTime = integer(value('retentionTime'), 300_000, 'retentionTime', warn);
break;
case 'Rechner':
case 'Notebook':
result.ssid = optionalString(value('ssid'), 'ssid', warn) ?? null;
break;
case 'Vermittlungsrechner':
result.ripEnabled = boolean(value('ripEnabled'), false, 'ripEnabled', warn);
break;
case 'Gateway':
case 'Modem':
break;
}
if (javaClass !== 'Switch' && javaClass !== 'Modem') {
result.manualRoutes = manualRoutes(value('weiterleitungstabelle'), warn);
}
if (javaClass === 'Vermittlungsrechner' || javaClass === 'Gateway') {
result.firewall = firewall(software, warn);
}
return result;
}
function manualRoutes(table: JavaValue | undefined, warn: Warn): (string | null)[][] {
if (table === undefined || table === null) return [];
if (!isJavaObject(table)) {
warn(`weiterleitungstabelle is ${describe(table)}, ignored`);
return [];
}
return listItems(table.properties.get('manuelleTabelle')).map((row) =>
listItems(row).map((cell) => (typeof cell === 'string' || cell === null ? cell : null)),
);
}
/** Every object the file reaches the router's one firewall by. */
function firewallObjects(software: JavaObject | undefined): JavaObject[] {
const found: JavaObject[] = [];
const applications = new Map(mapEntries(software?.properties.get('installierteAnwendungen')));
const installed = applications.get(FIREWALL_CLASS);
if (isJavaObject(installed)) found.push(installed);
const webServer = applications.get(WEB_SERVER_CLASS);
if (isJavaObject(webServer)) {
for (const [, plugin] of mapEntries(webServer.properties.get('plugins'))) {
const pluginFirewall = isJavaObject(plugin) ? plugin.properties.get('firewall') : undefined;
if (isJavaObject(pluginFirewall) && !found.includes(pluginFirewall)) found.push(pluginFirewall);
}
}
return found;
}
/** The firewall as the router's constructor leaves it (off, drop), then the file's settings. */
function firewall(software: JavaObject | undefined, warn: Warn): FlsFirewall {
const properties = new Map<string, JavaValue>();
for (const object of firewallObjects(software)) {
for (const [property, value] of object.properties) {
if (properties.has(property) && properties.get(property) !== value) {
warn(`firewall: ${property} is set in two places, the later one is used`);
}
properties.set(property, value);
}
}
const at = (property: string) => properties.get(property);
// setAllowRelatedPackets is the old name of setFilterSYNSegmentsOnly.
const filterSyn = at('filterSYNSegmentsOnly') ?? at('allowRelatedPackets');
return {
activated: boolean(at('activated'), false, 'firewall activated', warn),
defaultPolicy: integer(at('defaultPolicy'), 0, 'firewall defaultPolicy', warn),
dropICMP: boolean(at('dropICMP'), false, 'firewall dropICMP', warn),
filterSYNSegmentsOnly: boolean(filterSyn, true, 'firewall filterSYNSegmentsOnly', warn),
filterUdp: boolean(at('filterUdp'), true, 'firewall filterUdp', warn),
rules: listItems(at('ruleset')).flatMap((rule) => (isJavaObject(rule) ? [firewallRule(rule, warn)] : [])),
};
}
/**
* FirewallRule: public fields set one by one (`getField(name).set`), or the
* seven-argument constructor. Defaults: all addresses empty, all ports, TCP,
* drop.
*/
function firewallRule(rule: JavaObject, warn: Warn): FlsFirewallRule {
const [srcIP, srcMask, destIP, destMask, port, protocol, action] = rule.args.length === 7 ? rule.args : [];
const field = (name: string, fromConstructor: JavaValue | undefined) => rule.properties.get(name) ?? fromConstructor;
const text = (name: string, fromConstructor: JavaValue | undefined) => {
const value = field(name, fromConstructor) ?? '';
if (typeof value === 'string' || value === null) return value;
warn(`firewall rule: ${name} is ${describe(value)}, used ""`);
return '';
};
return {
srcIP: text('srcIP', srcIP),
srcMask: text('srcMask', srcMask),
destIP: text('destIP', destIP),
destMask: text('destMask', destMask),
port: integer(field('port', port), -1, 'firewall rule port', warn),
protocol: integer(field('protocol', protocol), 6, 'firewall rule protocol', warn),
action: integer(field('action', action), 0, 'firewall rule action', warn),
};
}
function optionalString(value: JavaValue | undefined, name: string, warn: Warn): string | null | undefined {
if (value === undefined || value === null || typeof value === 'string') return value;
warn(`${name} is ${describe(value)}, ignored`);
return undefined;
}
function boolean(value: JavaValue | undefined, fallback: boolean, name: string, warn: Warn): boolean {
if (value === undefined) return fallback;
if (typeof value === 'boolean') return value;
warn(`${name} is ${describe(value)}, used ${fallback}`);
return fallback;
}
function integer(value: JavaValue | undefined, fallback: number, name: string, warn: Warn): number {
if (value === undefined) return fallback;
if (typeof value === 'number' && Number.isInteger(value)) return value;
warn(`${name} is ${describe(value)}, used ${fallback}`);
return fallback;
}
function describe(value: JavaValue): string {
if (isUnresolved(value)) return `unresolved (${value.reason})`;
if (isJavaObject(value)) return `an object of ${value.className}`;
return typeof value === 'bigint' ? `${value}` : JSON.stringify(value);
}
+9 -3
View File
@@ -1,8 +1,8 @@
// The network drawn in a .fls file — devices, cables, documentation items —
// read from the decoded JavaBeans graph (javabeans.ts) the way Java FILIUS
// loads it (SzenarioVerwaltung.netzwerkLaden). Only the topology: system
// software, file systems and applications are later layers, which start
// from `FlsNode.source`.
// loads it (SzenarioVerwaltung.netzwerkLaden), with the system software
// settings the topology needs (systemSoftware.ts). File systems and
// applications are later layers, which start from `FlsNode.source`.
//
// The result is Java's view, indexed like Java's lists, and is checked
// against Java itself (corpus.java.json). The editor model (node kinds, UUIDs,
@@ -19,6 +19,7 @@
import { isJavaObject, isUnresolved, listItems, simpleClassName, type JavaObject, type JavaValue } from './javabeans.js';
import { javaIpCheck } from './javaIp.js';
import { readSystemSoftware, type FlsSystemSoftware } from './systemSoftware.js';
export type FlsNodeClass = 'Rechner' | 'Notebook' | 'Switch' | 'Vermittlungsrechner' | 'Gateway' | 'Modem';
@@ -44,6 +45,8 @@ export interface FlsNode {
interfaces: FlsInterface[];
/** Interfaces, or the ports of a Switch or Modem. */
portCount: number;
/** SSIDs, routes, RIP, firewall (systemSoftware.ts). */
system: FlsSystemSoftware;
/** The node's JavaBean (`filius.hardware.knoten.*`), for the software layers. */
source: JavaObject;
}
@@ -191,6 +194,9 @@ class Reader {
useMacAsName: info.host && this.booleanProperty(device, 'useMACAsName', `node ${itemIndex + 1}`),
interfaces: [],
portCount: 0,
system: readSystemSoftware(device, simpleName, (message) =>
this.warnings.push(`node ${itemIndex + 1}: ${message}`),
),
source: device,
};