feat(fls): topology of a .fls file as Java loads it

readTopology maps the decoded JavaBeans graph to devices, cables and
documentation items the way SzenarioVerwaltung.netzwerkLaden does: the
kind from the Java class (the palette label is not file data), devices
without bounds at 0,0, interface values through Java's setters. All 23
corpus files match Java field by field.

Found on the way:
- listItems mixed a constructor's elements with added ones; a router's
  second interface was lost. Lists now replay add/set in order after the
  elements the constructor made.
- The first 0 of a file may be SwingConstants.CENTER; measured like the
  getter defaults.
- IP.ipCheck (javaIp.ts) measured on 46 cases: empty tokens skipped,
  signs, leading zeros and BMP digits of any script accepted.
- The Java oracle took lower-case MACs in a file for random ones (8
  interfaces); it now compares case-insensitively and reports the host
  name flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 20:33:02 +00:00
co-authored by Claude Opus 5.5
parent a02b78b4cb
commit a02bb62413
18 changed files with 1446 additions and 34 deletions
+5 -1
View File
@@ -30,8 +30,12 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| --- | --- | --- | | --- | --- | --- |
| `src/zip.ts` | `Persistence/TopologyFLSArchive.swift` (Fehler, Grenzen, Pfadregeln, ZIP-Leser/-Schreiber, CRC-32) | übertragen; DEFLATE über `fflate`, mit Apples Abschneide-Verhalten nachgebildet; Pfadfaltung (NFC, Breite, volles Case-Folding) an 41 Paaren gegen das Swift-Orakel gemessen | | `src/zip.ts` | `Persistence/TopologyFLSArchive.swift` (Fehler, Grenzen, Pfadregeln, ZIP-Leser/-Schreiber, CRC-32) | übertragen; DEFLATE über `fflate`, mit Apples Abschneide-Verhalten nachgebildet; Pfadfaltung (NFC, Breite, volles Case-Folding) an 41 Paaren gegen das Swift-Orakel gemessen |
| `src/archive.ts` | ebenda, Archiv-Teil von Import und Export | übertragen, ohne die XML-Ebene | | `src/archive.ts` | ebenda, Archiv-Teil von Import und Export | übertragen, ohne die XML-Ebene |
| `src/javabeans.ts`, `src/javaDefaults.json` | — | neu: XMLEncoder-Leser nach den Regeln von `java.beans.XMLDecoder` (Swift liest elementweise und verliert geteilte Werte); Getter-Standardwerte mit `tools/java-oracle` gemessen | | `src/javabeans.ts`, `src/javaDefaults.json` | — | neu: XMLEncoder-Leser nach den Regeln von `java.beans.XMLDecoder` (Swift liest elementweise und verliert geteilte Werte); Getter-Standardwerte und statische Konstanten mit `tools/java-oracle` gemessen |
| `src/javaIp.ts` | Java `IP.inetAton`/`ipCheck` | neu nach Java; an 46 Fällen mit Java gemessen |
| `src/topology.ts` | Java `SzenarioVerwaltung.netzwerkLaden`, Konstruktoren und Setter der Geräte; Swift `Persistence/TopologyProjectStore.swift` (Parser) als Vergleich | neu nach Java statt Übertragung: Geräteart aus der Klasse statt aus der Beschriftung, Geräte ohne Koordinaten bei 0,0 statt verworfen, Werte durch Javas Setter |
| `test/javabeans.test.ts` | — | neu: je XMLDecoder-Regel ein Test; alle 23 Korpus-Dateien, Klasse und Position jedes Geräts wie Java | | `test/javabeans.test.ts` | — | neu: je XMLDecoder-Regel ein Test; alle 23 Korpus-Dateien, Klasse und Position jedes Geräts wie Java |
| `test/topology.test.ts` | — | neu: alle 23 Korpus-Dateien Feld für Feld wie Java (Geräte, Schnittstellen, Kabel, Doku-Elemente), dazu die Regeln, die der Korpus nicht berührt |
| `test/javaIp.test.ts`, `test/ip-check.*` | — | neu: `IP.ipCheck`-Fälle, Ergebnis aus Java |
| `test/zip.test.ts` | `TopologyProjectPersistenceTests.swift` (Archiv-Ablehnungstests, `makeMinimalZIP`, `makePayloadZIP`) | übertragen, ergänzt um DEFLATE-Grenzfälle | | `test/zip.test.ts` | `TopologyProjectPersistenceTests.swift` (Archiv-Ablehnungstests, `makeMinimalZIP`, `makePayloadZIP`) | übertragen, ergänzt um DEFLATE-Grenzfälle |
| `test/corpus.test.ts` | — | neu: 23 Korpus-Dateien, gegengeprüft mit `fflate.unzipSync` | | `test/corpus.test.ts` | — | neu: 23 Korpus-Dateien, gegengeprüft mit `fflate.unzipSync` |
| `test/pathFolding.test.ts`, `test/path-folding.*.json` | — | neu: welche Pfade dieselbe Datei meinen, Ergebnis aus dem Swift-Kern | | `test/pathFolding.test.ts`, `test/path-folding.*.json` | — | neu: welche Pfade dieselbe Datei meinen, Ergebnis aus dem Swift-Kern |
+252 -8
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,4 +1,6 @@
// Public surface of the .fls codec. // Public surface of the .fls codec.
export * from './archive.js'; export * from './archive.js';
export * from './javaIp.js';
export * from './javabeans.js'; export * from './javabeans.js';
export * from './topology.js';
export * from './zip.js'; export * from './zip.js';
+1
View File
@@ -11,6 +11,7 @@
"RIPServer#port": 520, "RIPServer#port": 520,
"Rechner#maxLayerOfOperation": 4, "Rechner#maxLayerOfOperation": 4,
"ServerBaustein#port": 55555, "ServerBaustein#port": 55555,
"SwingConstants.CENTER": 0,
"ThreadGroup#maxPriority": 10, "ThreadGroup#maxPriority": 10,
"Vermittlungsrechner#maxLayerOfOperation": 2, "Vermittlungsrechner#maxLayerOfOperation": 2,
"WebServer#port": 80 "WebServer#port": 80
+65
View File
@@ -0,0 +1,65 @@
// What Java FILIUS stores when a file sets an interface's IP address or mask:
// NetzwerkInterface.setIp(ip) keeps IP.ipCheck(ip), which is null for text
// that is not an address and the canonical dotted quad otherwise.
// Java counterpart: filius.software.vermittlungsschicht.IP.inetAton/ipCheck.
//
// Measured against Java (test/ip-check.cases.txt, tools/java-oracle), since
// the rules are wider than they look:
// - StringTokenizer(".") skips empty tokens: "10..0.0.1" is 10.0.0.1;
// - Integer.parseInt takes a sign and leading zeros: "+010" is 10, "-0" is 0;
// - and any Unicode decimal digit in the BMP: Arabic-Indic "١٠" is 10 —
// parseInt reads UTF-16 units, so "𝟏" (a surrogate pair) is no digit.
/**
* `Integer.parseInt(text)`: an optional sign, then one or more decimal digits
* of any script; undefined where Java throws NumberFormatException.
*/
export function javaParseInt(text: string): number | undefined {
const characters = text.split('');
let index = 0;
let negative = false;
if (characters[0] === '-' || characters[0] === '+') {
negative = characters[0] === '-';
index = 1;
}
if (index >= characters.length) return undefined;
let value = 0;
for (; index < characters.length; index++) {
const digit = decimalDigitValue(characters[index]!);
if (digit === undefined) return undefined;
value = value * 10 + digit;
// Past the int range Java throws; the octet check would reject it anyway.
if (value > 2 ** 31) return undefined;
}
if (!negative && value > 2 ** 31 - 1) return undefined;
return negative ? -value : value;
}
/**
* `Character.digit(c, 10)` for one UTF-16 unit: Unicode lays out every
* script's digits as a run of ten from zero to nine, so the value is the
* distance from the start of the run, modulo ten (in case two runs touch).
*/
function decimalDigitValue(unit: string): number | undefined {
if (!/^\p{Nd}$/u.test(unit)) return undefined;
let code = unit.charCodeAt(0);
let offset = 0;
while (/^\p{Nd}$/u.test(String.fromCharCode(code - 1))) {
code -= 1;
offset += 1;
}
return offset % 10;
}
/** `IP.ipCheck(text)`: four octets 0…255 as `a.b.c.d`, or null. */
export function javaIpCheck(text: string): string | null {
const tokens = text.split('.').filter((token) => token !== '');
if (tokens.length !== 4) return null;
const octets: number[] = [];
for (const token of tokens) {
const octet = javaParseInt(token);
if (octet === undefined || octet < 0 || octet > 255) return null;
octets.push(octet);
}
return octets.join('.');
}
+62 -18
View File
@@ -9,11 +9,12 @@
// cannot follow values XMLEncoder shares by reference (plan §13, M2): // cannot follow values XMLEncoder shares by reference (plan §13, M2):
// - `id`/`idref` resolve anywhere, including the results of getters; // - `id`/`idref` resolve anywhere, including the results of getters;
// - a getter on a property nobody set answers the value Java would, from // - a getter on a property nobody set answers the value Java would, from
// measured defaults (javaDefaults.json), or an explicit "unresolved". // measured defaults (javaDefaults.json), or an explicit "unresolved";
// - static constants (`SwingConstants.CENTER`) are their measured values.
// //
// XMLDecoder's rules, as far as XMLEncoder uses them: // XMLDecoder's rules, as far as XMLEncoder uses them:
// <object class="C">args… statements…</object> new C(args), then the statements // <object class="C">args… statements…</object> new C(args), then the statements
// <object class="C" field="F"/> the static field C.F // <object class="C" field="F"/> the static field C.F (measured value if known)
// <object class="C" method="m">args</object> the static factory C.m(args) // <object class="C" method="m">args</object> the static factory C.m(args)
// <object idref="X"/> the value stored under id X // <object idref="X"/> the value stored under id X
// <void property="p"/> target.getP() (a value) // <void property="p"/> target.getP() (a value)
@@ -46,8 +47,12 @@ export interface JavaObject {
readonly properties: Map<string, JavaValue>; readonly properties: Map<string, JavaValue>;
/** Method calls in order (`add`, `put`, …), with their arguments. */ /** Method calls in order (`add`, `put`, …), with their arguments. */
readonly calls: { method: string; args: JavaValue[] }[]; readonly calls: { method: string; args: JavaValue[] }[];
/** `set(i, v)` on the object. */ /**
readonly indexed: Map<number, JavaValue>; * Elements the object already held before the file's statements — a list
* its constructor filled, reached by `<void index="i">` on a getter's
* result. `add`/`set` go to `calls`, in order.
*/
readonly presetElements: Map<number, JavaValue>;
/** True when the object was not in the file but returned by a getter. */ /** True when the object was not in the file but returned by a getter. */
readonly fromGetter: boolean; readonly fromGetter: boolean;
} }
@@ -97,14 +102,40 @@ export function simpleClassName(className: string): string {
return className.slice(className.lastIndexOf('.') + 1); return className.slice(className.lastIndexOf('.') + 1);
} }
/** The elements of a list: `add` calls, then `set(i, v)` replacing by index. */ /**
export function listItems(value: JavaValue | undefined): JavaValue[] { * The elements of a list: the ones it held before the file's statements,
* then `add`, `add(i, v)` and `set(i, v)` replayed in order.
*
* `constructed` is how many elements the list's constructor made — a Java
* `Switch` starts with 24 ports, a `Gateway` with two interfaces. The file
* only mentions those it changed; the rest come back as untouched objects.
*/
export function listItems(value: JavaValue | undefined, constructed = 0): JavaValue[] {
if (typeof value === 'object' && value !== null && (value as JavaArray).kind === 'array') { if (typeof value === 'object' && value !== null && (value as JavaArray).kind === 'array') {
return [...(value as JavaArray).items]; return [...(value as JavaArray).items];
} }
if (!isJavaObject(value)) return []; if (!isJavaObject(value)) return [];
const items = value.calls.filter((call) => call.method === 'add').map((call) => call.args.at(-1) ?? null); const items = replayList(value, constructed);
for (const [index, item] of value.indexed) items[index] = item; return Array.from(items, (item) => item ?? newObject({ className: 'unknown', fromGetter: true }));
}
/** Like listItems, with `undefined` for a constructed element nobody touched. */
function replayList(value: JavaObject, constructed: number): (JavaValue | undefined)[] {
const presetLength = Math.max(constructed, ...Array.from(value.presetElements.keys(), (index) => index + 1));
const items: (JavaValue | undefined)[] = Array.from({ length: presetLength }, (_, index) =>
value.presetElements.get(index),
);
for (const { method, args } of value.calls) {
if (method === 'add' && args.length === 1) {
items.push(args[0]!);
} else if (method === 'add' && args.length === 2 && typeof args[0] === 'number') {
items.splice(args[0], 0, args[1]!);
} else if (method === 'set' && args.length === 2 && typeof args[0] === 'number') {
items[args[0]] = args[1]!;
} else if (method === 'clear' && args.length === 0) {
items.length = 0;
}
}
return items; return items;
} }
@@ -194,7 +225,7 @@ function newObject(fields: Partial<JavaObject> & { className: string }): JavaObj
factoryMethod: fields.factoryMethod, factoryMethod: fields.factoryMethod,
properties: new Map(), properties: new Map(),
calls: [], calls: [],
indexed: new Map(), presetElements: new Map(),
fromGetter: fields.fromGetter ?? false, fromGetter: fields.fromGetter ?? false,
}; };
} }
@@ -297,6 +328,12 @@ class Decoder {
} }
const className = attributes.class; const className = attributes.class;
if (className === undefined) throw new JavaBeansError('<object> without class or idref.'); if (className === undefined) throw new JavaBeansError('<object> without class or idref.');
if (attributes.field !== undefined) {
// A static constant: XMLEncoder writes a shared Integer 0 as
// SwingConstants.CENTER when that was the first 0 it met.
const constantKey = `${simpleClassName(className)}.${attributes.field}`;
if (Object.hasOwn(DEFAULTS, constantKey)) return this.primitive(element, DEFAULTS[constantKey]!);
}
const args = element.children.filter((child) => child.name !== 'void'); const args = element.children.filter((child) => child.name !== 'void');
const object = newObject({ const object = newObject({
className, className,
@@ -342,7 +379,7 @@ class Decoder {
: this.setProperty(target, attributes.property, args[0]!); : this.setProperty(target, attributes.property, args[0]!);
} else if (attributes.index !== undefined) { } else if (attributes.index !== undefined) {
const index = Number.parseInt(attributes.index, 10); const index = Number.parseInt(attributes.index, 10);
value = args.length === 0 ? this.getIndex(target, index) : this.setIndex(target, index, args[0]!); value = args.length === 0 ? this.getIndex(target, index, attributes.id) : this.setIndex(target, index, args[0]!);
} else if (attributes.method !== undefined) { } else if (attributes.method !== undefined) {
value = this.call(target, attributes.method, args, attributes.id); value = this.call(target, attributes.method, args, attributes.id);
} else if (attributes.idref !== undefined) { } else if (attributes.idref !== undefined) {
@@ -374,7 +411,7 @@ class Decoder {
} }
if (isJavaObject(target)) { if (isJavaObject(target)) {
target.calls.push({ method, args }); target.calls.push({ method, args });
if (method === 'get' && args.length === 1 && typeof args[0] === 'number') return this.getIndex(target, args[0]); if (method === 'get' && args.length === 1 && typeof args[0] === 'number') return this.getIndex(target, args[0], id);
} }
return null; return null;
} }
@@ -405,17 +442,24 @@ class Decoder {
return placeholder; return placeholder;
} }
private getIndex(target: JavaValue | undefined, index: number): JavaValue { private getIndex(target: JavaValue | undefined, index: number, id: string | undefined): JavaValue {
if (typeof target === 'object' && target !== null && (target as JavaArray).kind === 'array') { if (typeof target === 'object' && target !== null && (target as JavaArray).kind === 'array') {
return (target as JavaArray).items[index] ?? null; return (target as JavaArray).items[index] ?? null;
} }
if (!isJavaObject(target)) return { kind: 'unresolved', reason: `index ${index} on a value that is not an object` }; if (!isJavaObject(target)) return { kind: 'unresolved', reason: `index ${index} on a value that is not an object` };
const existing = listItems(target)[index]; const items = replayList(target, 0);
const existing = items[index];
if (existing !== undefined) return existing; if (existing !== undefined) return existing;
// A list the file never filled, like a node's interface list created by // An element the list held before the file touched it, like a node's
// its constructor: the element is a placeholder that later reads return. // interface list created by its constructor: a placeholder that later
const placeholder = newObject({ className: 'unknown', fromGetter: true }); // reads return. XMLEncoder writes these before any `add`; after one, the
target.indexed.set(index, placeholder); // index could mean either, and this reader does not guess.
const changed = target.calls.some((call) => call.method === 'add' || call.method === 'set' || call.method === 'clear');
if (changed && index >= items.length) {
return { kind: 'unresolved', reason: `index ${index} after the list was changed` };
}
const placeholder = newObject({ className: id?.replace(/\d+$/, '') ?? 'unknown', id, fromGetter: true });
target.presetElements.set(index, placeholder);
return placeholder; return placeholder;
} }
@@ -423,7 +467,7 @@ class Decoder {
if (typeof target === 'object' && target !== null && (target as JavaArray).kind === 'array') { if (typeof target === 'object' && target !== null && (target as JavaArray).kind === 'array') {
(target as JavaArray).items[index] = value; (target as JavaArray).items[index] = value;
} else if (isJavaObject(target)) { } else if (isJavaObject(target)) {
target.indexed.set(index, value); target.calls.push({ method: 'set', args: [index, value] });
} }
return null; return null;
} }
+408
View File
@@ -0,0 +1,408 @@
// 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`.
//
// 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,
// ports as Swift's TopologyPortMetadata, runtime configurations) is built
// from it in a separate step.
//
// Where this departs from filius-ipad's reader, Java is right (plan §13, M2):
// - the device kind comes from the Java class; the palette label ("Heimrouter")
// is not file data — Java overwrites it with the class's type name;
// - a device without coordinates stands at 0,0, as in Java, instead of
// being dropped;
// - interface values go through Java's setters (IP.ipCheck for address and
// mask, upper-cased MAC).
import { isJavaObject, isUnresolved, listItems, simpleClassName, type JavaObject, type JavaValue } from './javabeans.js';
import { javaIpCheck } from './javaIp.js';
export type FlsNodeClass = 'Rechner' | 'Notebook' | 'Switch' | 'Vermittlungsrechner' | 'Gateway' | 'Modem';
export interface FlsInterface {
/** After IP.ipCheck: a canonical dotted quad, or null for text that is not one. */
ip: string | null;
mask: string | null;
/** Upper-cased, as getMac() returns it; undefined when the file has none (Java rolls a random one). */
mac: string | undefined;
gateway: string | null;
dns: string | null;
}
export interface FlsNode {
javaClass: FlsNodeClass;
name: string | null;
x: number;
y: number;
/** Hosts only (Rechner, Notebook); false for the others. */
useIpAsName: boolean;
useMacAsName: boolean;
/** The interfaces of a Rechner, Notebook, Vermittlungsrechner or Gateway; empty for the others. */
interfaces: FlsInterface[];
/** Interfaces, or the ports of a Switch or Modem. */
portCount: number;
/** The node's JavaBean (`filius.hardware.knoten.*`), for the software layers. */
source: JavaObject;
}
/** A cable end: the node's index and the port's index on it, as in Java's lists. */
export interface FlsPortReference {
node: number;
port: number;
}
export interface FlsLink {
ends: [FlsPortReference, FlsPortReference];
wireless: boolean;
}
export type FlsColor = [red: number, green: number, blue: number, alpha: number];
export interface FlsFont {
name: string;
size: number;
style: number;
}
export interface FlsDocumentationItem {
/** GUIDocuItem.RECT = 1, TEXT = 2; 0 when the file sets none. */
type: number;
text: string | null;
x: number;
y: number;
width: number;
height: number;
color: FlsColor | null;
font: FlsFont | null;
}
export interface FlsTopology {
/** The version line newer files start with ("Filius version: 1.7.4 (…)"), else null. */
versionLine: string | null;
nodes: FlsNode[];
links: FlsLink[];
documentation: FlsDocumentationItem[];
/** What Java would read and this reader could not, and what it left out. */
warnings: string[];
}
interface NodeClassInfo {
/** Elements the constructor creates: interfaces (InternetKnoten) or ports (LokalerKnoten). */
constructed: number;
local: boolean;
host: boolean;
/**
* The name the constructor gives, in German — the locale Java FILIUS starts
* in (and the one corpus.java.json was measured with). An English FILIUS
* would say "Computer" or "Router" here.
*/
defaultName: string;
}
const NODE_CLASSES: Record<FlsNodeClass, NodeClassInfo> = {
Rechner: { constructed: 1, local: false, host: true, defaultName: 'Rechner' },
Notebook: { constructed: 1, local: false, host: true, defaultName: 'Notebook' },
Vermittlungsrechner: {
constructed: 1,
local: false,
host: false,
defaultName: 'Vermittlungsrechner',
},
Gateway: { constructed: 2, local: false, host: false, defaultName: 'Heimrouter' },
Switch: { constructed: 24, local: true, host: false, defaultName: 'Switch / WLAN' },
Modem: { constructed: 1, local: true, host: false, defaultName: 'Modem' },
};
const DEVICE_PACKAGE = 'filius.hardware.knoten.';
function isFlsNodeClass(name: string): name is FlsNodeClass {
return Object.hasOwn(NODE_CLASSES, name);
}
/** Maps the top-level objects of `konfiguration.xml` to the topology. */
export function readTopology(values: readonly JavaValue[]): FlsTopology {
const warnings: string[] = [];
const context = new Reader(warnings);
// netzwerkLaden: an optional version string, then the node list, the
// cable list and the documentation list, each by position. A list counts
// only if its first element has the expected class.
let position = 0;
let versionLine: string | null = null;
if (typeof values[0] === 'string') {
versionLine = values[0];
position = 1;
}
const nodeItems = listOf(values[position], 'filius.gui.netzwerksicht.GUIKnotenItem');
const cableItems = listOf(values[position + 1], 'filius.gui.netzwerksicht.GUIKabelItem');
const documentationItems = listOf(values[position + 2], 'filius.gui.netzwerksicht.GUIDocuItem');
const nodes: FlsNode[] = [];
for (const [index, item] of nodeItems.entries()) {
const node = context.node(item, index, nodes.length);
if (node !== undefined) nodes.push(node);
}
const links: FlsLink[] = [];
for (const [index, item] of cableItems.entries()) {
const link = context.link(item, index);
if (link !== undefined) links.push(link);
}
const documentation = documentationItems.flatMap((item) => (isJavaObject(item) ? [context.documentation(item)] : []));
return { versionLine, nodes, links, documentation, warnings };
}
/** The elements of `value` if it is a list whose first element is an object of `className`. */
function listOf(value: JavaValue | undefined, className: string): JavaValue[] {
const items = listItems(value);
const first = items[0];
return isJavaObject(first) && first.className === className ? items : [];
}
class Reader {
/** Every port object a node owns, to resolve cable ends by identity as Java does. */
private readonly portOwners = new Map<JavaObject, FlsPortReference>();
constructor(private readonly warnings: string[]) {}
node(item: JavaValue, itemIndex: number, nodeIndex: number): FlsNode | undefined {
const device = isJavaObject(item) ? item.properties.get('knoten') : undefined;
if (!isJavaObject(device)) {
this.warnings.push(`node ${itemIndex + 1}: no device (knoten), left out`);
return undefined;
}
const simpleName = simpleClassName(device.className);
if (!device.className.startsWith(DEVICE_PACKAGE) || !isFlsNodeClass(simpleName)) {
this.warnings.push(`node ${itemIndex + 1}: unknown device class ${device.className}, left out`);
return undefined;
}
const info = NODE_CLASSES[simpleName];
const [x, y] = this.position(item as JavaObject, itemIndex);
const node: FlsNode = {
javaClass: simpleName,
name: this.stringProperty(device, 'name', info.defaultName, `node ${itemIndex + 1}`),
x,
y,
useIpAsName: info.host && this.booleanProperty(device, 'useIPAsName', `node ${itemIndex + 1}`),
useMacAsName: info.host && this.booleanProperty(device, 'useMACAsName', `node ${itemIndex + 1}`),
interfaces: [],
portCount: 0,
source: device,
};
if (info.local) {
const ports = this.constructedList(device, 'anschluesse', info.constructed);
node.portCount = ports.length;
ports.forEach((port, portIndex) => this.ownPort(port, nodeIndex, portIndex));
} else {
const networkInterfaces = this.constructedList(device, 'netzwerkInterfaces', info.constructed);
node.interfaces = networkInterfaces.map((nic, nicIndex) =>
this.networkInterface(nic, simpleName === 'Gateway' && nicIndex === 0, `node ${itemIndex + 1}`),
);
node.portCount = networkInterfaces.length;
networkInterfaces.forEach((nic, nicIndex) => {
if (isJavaObject(nic)) this.ownPort(nic.properties.get('port'), nodeIndex, nicIndex);
});
}
return node;
}
/**
* A list property the constructor filled: a getter's result keeps the
* constructed elements, a list the file sets replaces them.
*/
private constructedList(device: JavaObject, property: string, constructed: number): JavaValue[] {
const list = device.properties.get(property);
// Untouched: the constructed elements, which carry nothing but defaults.
if (list === undefined) return Array.from({ length: constructed }, () => null);
return listItems(list, isJavaObject(list) && list.fromGetter ? constructed : 0);
}
private ownPort(port: JavaValue | undefined, node: number, index: number): void {
if (isJavaObject(port) && !this.portOwners.has(port)) this.portOwners.set(port, { node, port: index });
}
/**
* The imageLabel's bounds: fields set one by one in newer files,
* `new Rectangle(x, y, width, height)` in older ones. Without either, Java's
* label sits at 0,0.
*/
private position(item: JavaObject, itemIndex: number): [number, number] {
const label = item.properties.get('imageLabel');
const bounds = isJavaObject(label) ? label.properties.get('bounds') : undefined;
if (!isJavaObject(bounds)) return [0, 0];
return (['x', 'y'] as const).map((field, index) => {
const value = bounds.properties.get(field) ?? bounds.args[index] ?? 0;
if (typeof value === 'number') return value;
this.warnings.push(`node ${itemIndex + 1}: position ${field} is ${describe(value)}, used 0`);
return 0;
}) as [number, number];
}
/**
* NetzwerkInterface as its constructor leaves it, then the file's setters.
* A Gateway's constructor gives its first interface (WAN) its own address.
*/
private networkInterface(nic: JavaValue, gatewayWan: boolean, where: string): FlsInterface {
const result: FlsInterface = {
ip: gatewayWan ? '42.0.0.10' : '192.168.0.10',
mask: gatewayWan ? '255.0.0.0' : '255.255.255.0',
mac: undefined,
gateway: '',
dns: '',
};
if (!isJavaObject(nic)) return result;
// Only the constructor's own interface gets the WAN address; one the file
// creates with `new NetzwerkInterface()` starts like any other.
if (gatewayWan && !nic.fromGetter) {
result.ip = '192.168.0.10';
result.mask = '255.255.255.0';
}
const at = `${where}, interface`;
for (const [property, field] of [
['ip', 'ip'],
['subnetzMaske', 'mask'],
] as const) {
const value = nic.properties.get(property);
if (value === undefined) continue;
// setIp(null) throws inside Java's setter; XMLDecoder swallows that and
// the constructor's value stays.
if (typeof value === 'string') result[field] = javaIpCheck(value);
else this.unexpected(at, property, value);
}
const mac = nic.properties.get('mac');
if (typeof mac === 'string') result.mac = mac.toUpperCase();
else if (mac !== undefined && mac !== null) this.unexpected(at, 'mac', mac);
for (const [property, field] of [
['gateway', 'gateway'],
['dns', 'dns'],
] as const) {
const value = nic.properties.get(property);
if (value === undefined) continue;
if (typeof value === 'string' || value === null) result[field] = value;
else this.unexpected(at, property, value);
}
return result;
}
link(item: JavaValue, itemIndex: number): FlsLink | undefined {
const cable = isJavaObject(item) ? item.properties.get('dasKabel') : undefined;
if (!isJavaObject(cable)) {
this.warnings.push(`cable ${itemIndex + 1}: no cable (dasKabel), left out`);
return undefined;
}
// Kabel.setAnschluesse(Port[]) in newer files, new Kabel(port, port) in older ones.
const declared = cable.properties.get('anschluesse');
const ports = declared === undefined ? cable.args : listItems(declared);
const ends = ports.map((port) => (isJavaObject(port) ? this.portOwners.get(port) : undefined));
const [first, second] = ends;
if (ends.length !== 2 || first === undefined || second === undefined) {
this.warnings.push(`cable ${itemIndex + 1}: an end is not a port of a device, left out`);
return undefined;
}
return {
ends: [first, second],
wireless: this.booleanProperty(cable, 'wireless', `cable ${itemIndex + 1}`),
};
}
documentation(item: JavaObject): FlsDocumentationItem {
const where = 'documentation item';
const integer = (property: string): number => {
const value = item.properties.get(property);
if (value === undefined) return 0;
if (typeof value === 'number' && Number.isInteger(value)) return value;
this.unexpected(where, property, value);
return 0;
};
const text = item.properties.get('text');
return {
type: integer('type'),
text: typeof text === 'string' ? text : null,
x: integer('x'),
y: integer('y'),
width: integer('width'),
height: integer('height'),
color: this.color(item.properties.get('color'), where),
font: this.font(item.properties.get('font'), where),
};
}
/**
* `java.awt.Color` as XMLEncoder writes it: `new Color(r, g, b, a)`. Also
* `new Color(rgb)` and `new Color(r, g, b)` (ColorUIResource). A component
* outside 0…255 makes Java's constructor throw — the color stays unset.
*/
private color(value: JavaValue | undefined, where: string): FlsColor | null {
if (value === undefined || value === null) return null;
if (!isJavaObject(value)) {
this.unexpected(where, 'color', value);
return null;
}
const args = value.args;
if (!args.every((arg) => typeof arg === 'number' && Number.isInteger(arg))) {
this.warnings.push(`${where}: color ${value.className} with arguments this reader does not know, left unset`);
return null;
}
const numbers = args as number[];
if (numbers.length === 1) {
const rgb = numbers[0]!;
return [(rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, 255];
}
if (numbers.length === 3 || numbers.length === 4) {
const rgba = numbers.length === 3 ? [...numbers, 255] : numbers;
if (rgba.every((component) => component >= 0 && component <= 255)) return rgba as FlsColor;
this.warnings.push(`${where}: color component outside 0…255, left unset (as in Java)`);
return null;
}
this.warnings.push(`${where}: color with ${numbers.length} arguments, left unset`);
return null;
}
/** `new Font(name, style, size)`, also as FontUIResource. Java keeps style 0…3 and makes others 0. */
private font(value: JavaValue | undefined, where: string): FlsFont | null {
if (value === undefined || value === null) return null;
const [name, style, size] = isJavaObject(value) ? value.args : [];
if (
!isJavaObject(value) ||
value.args.length !== 3 ||
(typeof name !== 'string' && name !== null) ||
typeof style !== 'number' ||
typeof size !== 'number'
) {
this.unexpected(where, 'font', value);
return null;
}
return { name: name ?? 'Default', size, style: (style & ~0x03) === 0 ? style : 0 };
}
private stringProperty(target: JavaObject, property: string, fallback: string, where: string): string | null {
const value = target.properties.get(property);
if (value === undefined) return fallback;
if (typeof value === 'string' || value === null) return value;
this.unexpected(where, property, value);
return fallback;
}
private booleanProperty(target: JavaObject, property: string, where: string): boolean {
const value = target.properties.get(property);
if (value === undefined) return false;
if (typeof value === 'boolean') return value;
this.unexpected(where, property, value);
return false;
}
private unexpected(where: string, property: string, value: JavaValue): void {
this.warnings.push(`${where}: ${property} is ${describe(value)}, ignored`);
}
}
function describe(value: JavaValue): string {
if (isUnresolved(value)) return `unresolved (${value.reason})`;
if (isJavaObject(value)) return `an object of ${value.className}`;
return JSON.stringify(typeof value === 'bigint' ? String(value) : value) ?? String(value);
}
+46
View File
@@ -0,0 +1,46 @@
10.0.0.1
0.0.0.0
255.255.255.255
010.000.000.001
+10.0.0.1
-0.0.0.0
-1.0.0.0
10..0.0.1
.10.0.0.1
10.0.0.1.
...10.0.0.1...
10.0.0
10.0.0.1.5
256.0.0.1
10.0.0.1
10.0.0.1
.
....
1e1.0.0.1
0x10.0.0.1
10.0.0.1\u0000
2147483648.0.0.1
-2147483648.0.0.1
99999999999999999999.0.0.1
+.0.0.1
-.0.0.1
++1.0.0.1
1 .0.0.1
\u0661\u0660.0.0.1
\u0967.\u0968.\u0969.\u096a
\uff11\uff10.0.0.1
\u0e51\u0e59\u0e52.168.0.1
\u1040.\u1049.0.1
\u07c1.0.0.1
\u194f.0.0.1
\u19d9.0.0.1
\u19da.0.0.1
\uabf9.0.0.1
\u0de7.0.0.1
\ud835\udfcf.0.0.1
\u00b2.0.0.1
\u2460.0.0.1
\u1369.0.0.1
\uff0e10.0.0.1
10\u30020.0.1
+186
View File
@@ -0,0 +1,186 @@
[
[
"10.0.0.1",
"10.0.0.1"
],
[
"0.0.0.0",
"0.0.0.0"
],
[
"255.255.255.255",
"255.255.255.255"
],
[
"010.000.000.001",
"10.0.0.1"
],
[
"+10.0.0.1",
"10.0.0.1"
],
[
"-0.0.0.0",
"0.0.0.0"
],
[
"-1.0.0.0",
null
],
[
"10..0.0.1",
"10.0.0.1"
],
[
".10.0.0.1",
"10.0.0.1"
],
[
"10.0.0.1.",
"10.0.0.1"
],
[
"...10.0.0.1...",
"10.0.0.1"
],
[
"10.0.0",
null
],
[
"10.0.0.1.5",
null
],
[
"256.0.0.1",
null
],
[
" 10.0.0.1",
null
],
[
"10.0.0.1 ",
null
],
[
"",
null
],
[
".",
null
],
[
"....",
null
],
[
"1e1.0.0.1",
null
],
[
"0x10.0.0.1",
null
],
[
"10.0.0.1\\u0000",
null
],
[
"2147483648.0.0.1",
null
],
[
"-2147483648.0.0.1",
null
],
[
"99999999999999999999.0.0.1",
null
],
[
"+.0.0.1",
null
],
[
"-.0.0.1",
null
],
[
"++1.0.0.1",
null
],
[
"1 .0.0.1",
null
],
[
"\\u0661\\u0660.0.0.1",
"10.0.0.1"
],
[
"\\u0967.\\u0968.\\u0969.\\u096a",
"1.2.3.4"
],
[
"\\uff11\\uff10.0.0.1",
"10.0.0.1"
],
[
"\\u0e51\\u0e59\\u0e52.168.0.1",
"192.168.0.1"
],
[
"\\u1040.\\u1049.0.1",
"0.9.0.1"
],
[
"\\u07c1.0.0.1",
"1.0.0.1"
],
[
"\\u194f.0.0.1",
"9.0.0.1"
],
[
"\\u19d9.0.0.1",
"9.0.0.1"
],
[
"\\u19da.0.0.1",
null
],
[
"\\uabf9.0.0.1",
"9.0.0.1"
],
[
"\\u0de7.0.0.1",
"1.0.0.1"
],
[
"\\ud835\\udfcf.0.0.1",
null
],
[
"\\u00b2.0.0.1",
null
],
[
"\\u2460.0.0.1",
null
],
[
"\\u1369.0.0.1",
null
],
[
"\\uff0e10.0.0.1",
null
],
[
"10\\u30020.0.1",
null
]
]
+39
View File
@@ -0,0 +1,39 @@
// IP.ipCheck as Java FILIUS runs it: every case in ip-check.cases.txt was run
// through Java (ip-check.java.json, tools/java-oracle) and must come out the
// same here.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { javaIpCheck, javaParseInt } from '../src/javaIp.js';
const here = dirname(fileURLToPath(import.meta.url));
const measured = JSON.parse(readFileSync(join(here, 'ip-check.java.json'), 'utf8')) as [string, string | null][];
/** The cases file's escapes: a backslash, `u` and four hex digits for one UTF-16 unit. */
function unescape(line: string): string {
return line.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)));
}
describe('javaIpCheck against Java', () => {
it('covers every line of the cases file', () => {
const lines = readFileSync(join(here, 'ip-check.cases.txt'), 'ascii').split('\n').slice(0, -1);
expect(measured.map(([line]) => line)).toEqual(lines);
});
for (const [line, expected] of measured) {
it(JSON.stringify(line), () => {
expect(javaIpCheck(unescape(line))).toBe(expected);
});
}
});
describe('javaParseInt', () => {
it('keeps to the int range', () => {
expect(javaParseInt('2147483647')).toBe(2147483647);
expect(javaParseInt('-2147483648')).toBe(-2147483648);
expect(javaParseInt('2147483648')).toBeUndefined();
expect(javaParseInt('-2147483649')).toBeUndefined();
});
});
+77 -2
View File
@@ -126,18 +126,93 @@ describe('XMLDecoder rules', () => {
it('reads static fields, static factories, maps and arrays', () => { it('reads static fields, static factories, maps and arrays', () => {
const [constant, manager, map, array] = decodeJavaBeans( const [constant, manager, map, array] = decodeJavaBeans(
doc(`<object class="javax.swing.SwingConstants" field="CENTER"/> doc(`<object class="javax.swing.SwingConstants" field="LEFT"/>
<object class="javax.swing.ToolTipManager" method="sharedInstance"/> <object class="javax.swing.ToolTipManager" method="sharedInstance"/>
<object class="java.util.HashMap"><void method="put"><string>k</string><int>1</int></void></object> <object class="java.util.HashMap"><void method="put"><string>k</string><int>1</int></void></object>
<array class="filius.hardware.Port" length="2"><void index="1"><string>b</string></void></array>`), <array class="filius.hardware.Port" length="2"><void index="1"><string>b</string></void></array>`),
); );
expect(object(constant)).toMatchObject({ className: 'javax.swing.SwingConstants', staticField: 'CENTER' }); expect(object(constant)).toMatchObject({ className: 'javax.swing.SwingConstants', staticField: 'LEFT' });
expect(object(manager).factoryMethod).toBe('sharedInstance'); expect(object(manager).factoryMethod).toBe('sharedInstance');
expect(mapEntries(map)).toEqual([['k', 1]]); expect(mapEntries(map)).toEqual([['k', 1]]);
expect(listItems(array)).toEqual([null, 'b']); expect(listItems(array)).toEqual([null, 'b']);
}); });
it('answers a measured static constant with its value, which later references share', () => {
const [color, holder] = decodeJavaBeans(
doc(`<object class="java.awt.Color" id="Color0">
<object class="javax.swing.SwingConstants" field="CENTER" id="Integer0"/>
<int>128</int><int>255</int>
</object>
<object class="java.util.ArrayList"><void method="add"><object idref="Integer0"/></void></object>`),
);
expect(object(color).args).toEqual([0, 128, 255]);
expect(listItems(holder)).toEqual([0]);
});
it('keeps the elements a constructor made ahead of the ones the file adds', () => {
// A Java router starts with one interface; the file changes it by index
// and adds two more — three interfaces, in this order.
const [node] = decodeJavaBeans(
doc(`<object class="filius.hardware.knoten.Vermittlungsrechner">
<void id="LinkedList0" property="netzwerkInterfaces">
<void id="NetzwerkInterface0" index="0"><void property="ip"><string>10.0.0.1</string></void></void>
<void method="add"><object class="filius.hardware.NetzwerkInterface" id="NetzwerkInterface1"/></void>
<void method="add"><object class="filius.hardware.NetzwerkInterface" id="NetzwerkInterface2"/></void>
</void>
</object>`),
);
const interfaces = listItems(object(node).properties.get('netzwerkInterfaces'));
expect(interfaces.map((nic) => object(nic).id)).toEqual(['NetzwerkInterface0', 'NetzwerkInterface1', 'NetzwerkInterface2']);
expect(object(interfaces[0]).properties.get('ip')).toBe('10.0.0.1');
});
it('fills the constructed elements the file never mentions, in index order', () => {
// A switch has 24 ports; the file names the three that carry a cable,
// in any order.
const [ports] = decodeJavaBeans(
doc(`<object class="filius.hardware.knoten.Switch"><void id="LinkedList0" property="anschluesse">
<void id="Port9" index="1"/><void id="Port10" index="0"/><void id="Port11" index="2"/>
</void></object>`),
);
const list = object(ports).properties.get('anschluesse');
const items = listItems(list, 24);
expect(items).toHaveLength(24);
expect(items.slice(0, 3).map((port) => object(port).id)).toEqual(['Port10', 'Port9', 'Port11']);
expect(object(items[3])).toMatchObject({ className: 'unknown', fromGetter: true });
expect(listItems(list)).toHaveLength(3);
});
it('replays add at an index and set by index in order', () => {
const [list] = decodeJavaBeans(
doc(`<object class="java.util.ArrayList">
<void method="add"><string>a</string></void>
<void method="add"><string>c</string></void>
<void method="add"><int>1</int><string>b</string></void>
<void index="2"><string>C</string></void>
</object>`),
);
expect(listItems(list)).toEqual(['a', 'b', 'C']);
});
it('does not guess what an index means once the list was changed', () => {
const [, holder] = decodeJavaBeans(
doc(`<object class="filius.hardware.knoten.Rechner">
<void id="LinkedList0" property="netzwerkInterfaces">
<void method="add"><object class="filius.hardware.NetzwerkInterface"/></void>
<void id="NetzwerkInterface5" index="3"/>
</void>
</object>
<object class="java.util.ArrayList"><void method="add"><object idref="NetzwerkInterface5"/></void></object>`),
);
expect(isUnresolved(listItems(holder)[0])).toBe(true);
});
it('reports an idref to an id that was never defined', () => { it('reports an idref to an id that was never defined', () => {
const [value] = decodeJavaBeans(doc('<object idref="Missing0"/>')); const [value] = decodeJavaBeans(doc('<object idref="Missing0"/>'));
+196
View File
@@ -0,0 +1,196 @@
// The topology of every corpus file must be what Java FILIUS loads
// (corpus.java.json, tools/java-oracle): each device with class, name,
// position and interfaces, each cable with both ends, each documentation
// item — field by field. Then the rules the corpus does not exercise.
import { readdirSync, readFileSync, statSync } 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, type FlsNodeClass, type FlsTopology } from '../src/index.js';
const corpusRoot = join(dirname(fileURLToPath(import.meta.url)), '../../../corpus');
const javaView = JSON.parse(readFileSync(join(corpusRoot, 'corpus.java.json'), 'utf8')) as Record<string, unknown>;
function flsFiles(directory: string): string[] {
return readdirSync(directory)
.flatMap((name) => {
const path = join(directory, name);
return statSync(path).isDirectory() ? flsFiles(path) : name.endsWith('.fls') ? [path] : [];
})
.sort();
}
/**
* The palette label Java shows: not read from the file but set from the
* class while loading (setTyp(holeHardwareTyp())), in German.
*/
const TYPE_LABELS: Record<FlsNodeClass, string> = {
Rechner: 'Rechner',
Notebook: 'Notebook',
Switch: 'Switch / WLAN',
Vermittlungsrechner: 'Vermittlungsrechner',
Gateway: 'Heimrouter',
Modem: 'Modem',
};
/** The topology in the shape FlsOracle.java writes, less what Swing computes (label width and height). */
function asJavaView(topology: FlsTopology, javaBounds: (number[] | null)[]) {
return {
success: true,
nodes: topology.nodes.map((node, index) => ({
class: node.javaClass,
name: node.name,
label: TYPE_LABELS[node.javaClass],
bounds: [node.x, node.y, ...(javaBounds[index]?.slice(2) ?? [])],
...(node.javaClass === 'Rechner' || node.javaClass === 'Notebook'
? { useIpAsName: node.useIpAsName, useMacAsName: node.useMacAsName }
: {}),
ports:
node.javaClass === 'Switch' || node.javaClass === 'Modem'
? Array.from({ length: node.portCount }, () => ({}))
: node.interfaces.map((nic) => ({ ...nic, mac: nic.mac ?? null })),
})),
cables: topology.links.map((link) => ({
ends: link.ends.map((end) => [end.node, end.port]),
wireless: link.wireless,
})),
documentation: topology.documentation.map((item) => ({
type: item.type,
text: item.text,
bounds: [item.x, item.y, item.width, item.height],
color: item.color,
font: item.font === null ? null : [item.font.name, item.font.size, item.font.style],
})),
};
}
describe('corpus: the topology as Java loads it', () => {
for (const file of flsFiles(corpusRoot)) {
const name = relative(corpusRoot, file);
it(name, () => {
const values = decodeJavaBeans(readFlsArchive(new Uint8Array(readFileSync(file))).configurationXml);
const topology = readTopology(values);
const expected = javaView[name] as { nodes: { bounds: number[] | null }[] };
expect(asJavaView(topology, expected.nodes.map((node) => node.bounds))).toEqual(expected);
expect(topology.warnings).toEqual([]);
});
}
});
// ── Rules the corpus does not exercise ─────────────────────────────────────
function doc(body: string): string {
return `<?xml version="1.0" encoding="UTF-8"?>\n<java version="17" class="java.beans.XMLDecoder">\n${body}\n</java>\n`;
}
function topologyOf(body: string): FlsTopology {
return readTopology(decodeJavaBeans(doc(body)));
}
function nodeItem(device: string, extra = ''): string {
return `<void method="add"><object class="filius.gui.netzwerksicht.GUIKnotenItem">
<void property="knoten">${device}</void>${extra}</object></void>`;
}
describe('readTopology rules', () => {
it('passes addresses through Java setIp: canonical, null when invalid, unchanged when null', () => {
const { nodes } = topologyOf(`<string>Filius version: 2.9.4 (1.1.2026)</string>
<object class="java.util.LinkedList">${nodeItem(`<object class="filius.hardware.knoten.Vermittlungsrechner">
<void property="netzwerkInterfaces">
<void index="0"><void property="ip"><string>010.0..0.1</string></void>
<void property="subnetzMaske"><string>255.255.255.256</string></void></void>
<void method="add"><object class="filius.hardware.NetzwerkInterface">
<void property="ip"><null/></void><void property="mac"><string>0a:0b:0c:0d:0e:0f</string></void>
</object></void>
</void></object>`)}</object>`);
expect(nodes[0]!.interfaces).toEqual([
{ ip: '10.0.0.1', mask: null, mac: undefined, gateway: '', dns: '' },
{ ip: '192.168.0.10', mask: '255.255.255.0', mac: '0A:0B:0C:0D:0E:0F', gateway: '', dns: '' },
]);
});
it("gives only a Gateway's own first interface the WAN address, not one the file creates", () => {
const own = topologyOf(`<object class="java.util.LinkedList">${nodeItem(
'<object class="filius.hardware.knoten.Gateway"/>',
)}</object>`);
const replaced = topologyOf(`<object class="java.util.LinkedList">${nodeItem(`<object class="filius.hardware.knoten.Gateway">
<void property="netzwerkInterfaces"><object class="java.util.LinkedList">
<void method="add"><object class="filius.hardware.NetzwerkInterface"/></void>
</object></void></object>`)}</object>`);
expect(own.nodes[0]!.interfaces.map((nic) => [nic.ip, nic.mask])).toEqual([
['42.0.0.10', '255.0.0.0'],
['192.168.0.10', '255.255.255.0'],
]);
expect(own.nodes[0]!.name).toBe('Heimrouter');
expect(replaced.nodes[0]!.interfaces.map((nic) => nic.ip)).toEqual(['192.168.0.10']);
});
it('places a device without bounds at 0,0 instead of leaving it out', () => {
const { nodes, warnings } = topologyOf(
`<object class="java.util.LinkedList">${nodeItem('<object class="filius.hardware.knoten.Notebook"/>')}</object>`,
);
expect(nodes.map((node) => [node.javaClass, node.x, node.y, node.portCount])).toEqual([['Notebook', 0, 0, 1]]);
expect(warnings).toEqual([]);
});
it('leaves out an unknown device and a cable to a port no device owns, and says so', () => {
const { nodes, links, warnings } = topologyOf(`<object class="java.util.LinkedList">
${nodeItem('<object class="filius.hardware.knoten.Drucker"/>')}
${nodeItem(`<object class="filius.hardware.knoten.Switch"><void property="anschluesse">
<void id="Port0" index="0"/><void id="Port1" index="1"/></void></object>`)}
</object>
<object class="java.util.LinkedList">
<void method="add"><object class="filius.gui.netzwerksicht.GUIKabelItem"><void property="dasKabel">
<object class="filius.hardware.Kabel"><object idref="Port0"/><object idref="Port1"/></object></void></object></void>
<void method="add"><object class="filius.gui.netzwerksicht.GUIKabelItem"><void property="dasKabel">
<object class="filius.hardware.Kabel"><object idref="Port0"/><object class="filius.hardware.Port"/></object></void></object></void>
</object>`);
expect(nodes.map((node) => node.javaClass)).toEqual(['Switch']);
expect(links).toEqual([{ ends: [{ node: 0, port: 0 }, { node: 0, port: 1 }], wireless: false }]);
expect(warnings).toEqual([
'node 1: unknown device class filius.hardware.knoten.Drucker, left out',
'cable 2: an end is not a port of a device, left out',
]);
});
it('reads the lists by position and only when the first element fits, as netzwerkLaden does', () => {
// No version line (files before 1.3.0); an empty cable list still takes its place.
const topology = topologyOf(`<object class="java.util.LinkedList">${nodeItem(
'<object class="filius.hardware.knoten.Rechner"/>',
)}</object>
<object class="java.util.LinkedList"/>
<object class="java.util.LinkedList"><void method="add"><object class="filius.gui.netzwerksicht.GUIDocuItem">
<void property="type"><int>1</int></void></object></void></object>`);
const wrongFirst = topologyOf(`<object class="java.util.LinkedList">
<void method="add"><string>not a node</string></void>
${nodeItem('<object class="filius.hardware.knoten.Rechner"/>')}</object>`);
expect(topology.versionLine).toBeNull();
expect(topology.nodes).toHaveLength(1);
expect(topology.documentation.map((item) => item.type)).toEqual([1]);
expect(wrongFirst.nodes).toEqual([]);
});
it("reads colors as Java's constructors do and keeps an invalid one unset", () => {
const colors = ['<int>16711935</int>', '<int>1</int><int>2</int><int>3</int>', '<int>1</int><int>2</int><int>300</int><int>4</int>'];
const { documentation, warnings } = topologyOf(`<object class="java.util.LinkedList"/><object class="java.util.LinkedList"/>
<object class="java.util.LinkedList">${colors
.map(
(args) => `<void method="add"><object class="filius.gui.netzwerksicht.GUIDocuItem">
<void property="color"><object class="java.awt.Color">${args}</object></void>
<void property="font"><object class="java.awt.Font"><string>Dialog</string><int>5</int><int>14</int></object></void>
</object></void>`,
)
.join('')}</object>`);
expect(documentation.map((item) => item.color)).toEqual([[255, 0, 255, 255], [1, 2, 3, 255], null]);
expect(documentation[0]!.font).toEqual({ name: 'Dialog', size: 14, style: 0 });
expect(warnings).toEqual(['documentation item: color component outside 0…255, left unset (as in Java)']);
});
});
+3
View File
@@ -13,3 +13,6 @@ vermittlungsrechner
verbindung verbindung
netzzugangsschicht netzzugangsschicht
vermittlungsschicht vermittlungsschicht
# The class name filius.hardware.knoten.Rechner, as the device classes of
# the .fls format are keyed in packages/fls (topology.ts and its test).
packages/fls/::rechner
+21 -1
View File
@@ -19,8 +19,12 @@ virtuellen Bildschirm statt „headless".
Lauf nach `.upstream/` geholt und gebaut. Lauf nach `.upstream/` geholt und gebaut.
* **MAC-Adressen**, die nicht in der Datei stehen, würfelt Filius beim Anlegen * **MAC-Adressen**, die nicht in der Datei stehen, würfelt Filius beim Anlegen
der Netzwerkkarte. Sie erscheinen als `null`; zwei Läufe sind byte-gleich. der Netzwerkkarte. Sie erscheinen als `null`; zwei Läufe sind byte-gleich.
* Ob eine MAC in der Datei steht, wird **ohne Rücksicht auf Groß-/Kleinschreibung**
geprüft: `getMac()` schreibt groß, acht Korpus-Dateien speichern klein (bis
29.09.2026 erschienen sie fälschlich als `null`).
* Bisher ausgelesen: Geräte (Klasse, Name, Beschriftung, Position, * Bisher ausgelesen: Geräte (Klasse, Name, Beschriftung, Position,
Schnittstellen), Kabel (Enden als Gerät/Schnittstelle), Doku-Elemente. Schnittstellen, bei Rechner/Notebook `useIPAsName`/`useMACAsName`), Kabel
(Enden als Gerät/Schnittstelle), Doku-Elemente.
Systemsoftware, Dateisysteme und Anwendungen folgen schrittweise. Systemsoftware, Dateisysteme und Anwendungen folgen schrittweise.
## Getter-Standardwerte ## Getter-Standardwerte
@@ -34,3 +38,19 @@ x-Koordinate eines Geräts. Daran scheitert der Swift-Leser (Plan §13, M2).
Argumente) und ruft den Getter auf; das Ergebnis steht in Argumente) und ruft den Getter auf; das Ergebnis steht in
`packages/fls/src/javaDefaults.json`. Gemessen, nicht geraten: Filius' Knopf `packages/fls/src/javaDefaults.json`. Gemessen, nicht geraten: Filius' Knopf
setzt `verticalTextPosition` auf 3 (BOTTOM), nicht auf Swings 0 (CENTER). setzt `verticalTextPosition` auf 3 (BOTTOM), nicht auf Swings 0 (CENTER).
Dasselbe gilt für **statische Konstanten**: Die erste `0` einer Datei kann als
`<object class="javax.swing.SwingConstants" field="CENTER"/>` stehen, und jede
spätere `0` — etwa die Rot-, Grün- und Blauwerte einer schwarzen Doku-Farbe —
verweist darauf. Schreibweise im Aufruf: `klasse::FELD`, Ergebnis
`"SwingConstants.CENTER": 0`.
## `IP.ipCheck`
Java-Filius schickt jede IP-Adresse und Maske beim Laden durch
`IP.ipCheck`. `IpCheckCases` ruft es für jede Zeile von
`packages/fls/test/ip-check.cases.txt` auf (ASCII, Nicht-ASCII als
`\uXXXX`) und schreibt `ip-check.java.json`. Gemessen: leere Abschnitte
zählen nicht (`10..0.0.1`), Vorzeichen und führende Nullen gehen, ebenso
Ziffern jeder Schrift in der BMP (Thai, Devanagari, Vollbreite) — aber kein
Leerzeichen und kein 𝟏 (Ersatzpaar).
+10 -2
View File
@@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then
fi fi
classpath="$upstream/target/filius.jar:$upstream/target/lib/*" classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
mkdir -p "$here/.build" mkdir -p "$here/.build"
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java"
# FILIUS writes its settings below $HOME; keep them out of the real one. # FILIUS writes its settings below $HOME; keep them out of the real one.
mkdir -p "$here/.build/home" mkdir -p "$here/.build/home"
# Removed first: a crashed run must not leave the previous result standing. # Removed first: a crashed run must not leave the previous result standing.
@@ -30,7 +30,7 @@ HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" FlsOracl
"$root/corpus" "$root/corpus/corpus.java.json" 2>&1 | grep -v 'logback\|^$' || true "$root/corpus" "$root/corpus/corpus.java.json" 2>&1 | grep -v 'logback\|^$' || true
test -s "$root/corpus/corpus.java.json" test -s "$root/corpus/corpus.java.json"
# Getter defaults for the XMLEncoder references (packages/fls/src/javaDefaults.json). # Getter defaults and static constants for the XMLEncoder references (packages/fls/src/javaDefaults.json).
defaults="$root/packages/fls/src/javaDefaults.json" defaults="$root/packages/fls/src/javaDefaults.json"
rm -f "$defaults" rm -f "$defaults"
HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" GetterDefaults "$defaults" \ HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" GetterDefaults "$defaults" \
@@ -49,5 +49,13 @@ HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" GetterDe
filius.software.dns.DNSServer#port \ filius.software.dns.DNSServer#port \
filius.software.rip.RIPServer#port \ filius.software.rip.RIPServer#port \
filius.software.www.WebServer#port \ filius.software.www.WebServer#port \
javax.swing.SwingConstants::CENTER \
2>&1 | grep -v 'logback\|^$' || true 2>&1 | grep -v 'logback\|^$' || true
test -s "$defaults" test -s "$defaults"
# IP.ipCheck on hand-picked cases (packages/fls/src/javaIp.ts).
cases="$root/packages/fls/test/ip-check.cases.txt"
ipcheck="$root/packages/fls/test/ip-check.java.json"
rm -f "$ipcheck"
HOME="$here/.build/home" java -cp "$classpath:$here/.build" IpCheckCases "$cases" "$ipcheck" 2>&1 | grep -v 'logback\|^$' || true
test -s "$ipcheck"
+10 -1
View File
@@ -10,6 +10,7 @@ import filius.gui.netzwerksicht.GUIKnotenItem;
import filius.hardware.Kabel; import filius.hardware.Kabel;
import filius.hardware.NetzwerkInterface; import filius.hardware.NetzwerkInterface;
import filius.hardware.Port; import filius.hardware.Port;
import filius.hardware.knoten.Host;
import filius.hardware.knoten.InternetKnoten; import filius.hardware.knoten.InternetKnoten;
import filius.hardware.knoten.Knoten; import filius.hardware.knoten.Knoten;
import filius.hardware.knoten.LokalerKnoten; import filius.hardware.knoten.LokalerKnoten;
@@ -31,6 +32,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.TreeMap; import java.util.TreeMap;
import java.util.regex.Pattern;
import java.util.stream.Stream; import java.util.stream.Stream;
import java.util.zip.ZipEntry; import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream; import java.util.zip.ZipInputStream;
@@ -114,6 +116,10 @@ public class FlsOracle {
json.put("label", item.getImageLabel() == null ? null : item.getImageLabel().getTyp()); json.put("label", item.getImageLabel() == null ? null : item.getImageLabel().getTyp());
Rectangle bounds = item.getImageLabel() == null ? null : item.getImageLabel().getBounds(); Rectangle bounds = item.getImageLabel() == null ? null : item.getImageLabel().getBounds();
json.put("bounds", bounds == null ? null : List.of(bounds.x, bounds.y, bounds.width, bounds.height)); json.put("bounds", bounds == null ? null : List.of(bounds.x, bounds.y, bounds.width, bounds.height));
if (node instanceof Host host) {
json.put("useIpAsName", host.isUseIPAsName());
json.put("useMacAsName", host.isUseMACAsName());
}
List<Object> ports = new ArrayList<>(); List<Object> ports = new ArrayList<>();
if (node instanceof InternetKnoten internet) { if (node instanceof InternetKnoten internet) {
List<NetzwerkInterface> nics = internet.getNetzwerkInterfaces(); List<NetzwerkInterface> nics = internet.getNetzwerkInterfaces();
@@ -125,7 +131,10 @@ public class FlsOracle {
// Without a MAC address in the file, FILIUS rolls a random one when it // Without a MAC address in the file, FILIUS rolls a random one when it
// creates the interface; only a MAC that stands in the file is stable. // creates the interface; only a MAC that stands in the file is stable.
String mac = nic.getMac(); String mac = nic.getMac();
port.put("mac", mac != null && xmlText.contains(">" + mac + "<") ? mac : null); // getMac() upper-cases; the file may not.
boolean macInFile = mac != null && Pattern
.compile(">" + Pattern.quote(mac) + "<", Pattern.CASE_INSENSITIVE).matcher(xmlText).find();
port.put("mac", macInFile ? mac : null);
port.put("gateway", nic.getGateway()); port.put("gateway", nic.getGateway());
port.put("dns", nic.getDns()); port.put("dns", nic.getDns());
ports.add(port); ports.add(port);
+11 -1
View File
@@ -4,7 +4,10 @@
// later referenced by id — e.g. as a node's x coordinate. Decoding such a file // later referenced by id — e.g. as a node's x coordinate. Decoding such a file
// needs these defaults; they are measured here, not guessed. // needs these defaults; they are measured here, not guessed.
// //
// Usage: java -cp filius.jar:lib/*:classes GetterDefaults <output json> <fqcn#property>... // The same holds for static constants: the first Integer 0 in a file may be
// written as `SwingConstants.CENTER`, and every later 0 refers to it.
//
// Usage: java -cp filius.jar:lib/*:classes GetterDefaults <output json> <fqcn#property | fqcn::FIELD>...
import java.beans.Introspector; import java.beans.Introspector;
import java.beans.PropertyDescriptor; import java.beans.PropertyDescriptor;
@@ -20,6 +23,13 @@ public class GetterDefaults {
Map<String, Object> result = new TreeMap<>(); Map<String, Object> result = new TreeMap<>();
for (int index = 1; index < args.length; index++) { for (int index = 1; index < args.length; index++) {
String key = args[index]; String key = args[index];
if (key.contains("::")) {
String className = key.substring(0, key.indexOf("::"));
String field = key.substring(key.indexOf("::") + 2);
String simpleName = className.substring(className.lastIndexOf('.') + 1);
result.put(simpleName + "." + field, Class.forName(className).getField(field).get(null));
continue;
}
String className = key.substring(0, key.indexOf('#')); String className = key.substring(0, key.indexOf('#'));
String property = key.substring(key.indexOf('#') + 1); String property = key.substring(key.indexOf('#') + 1);
String simpleName = className.substring(className.lastIndexOf('.') + 1); String simpleName = className.substring(className.lastIndexOf('.') + 1);
+52
View File
@@ -0,0 +1,52 @@
// Runs FILIUS's own IP.ipCheck on every case of a list and writes what Java
// returns — the reference for packages/fls/src/javaIp.ts. The cases file is
// ASCII, one case per line, with Java-style unicode escapes (backslash, u,
// four hex digits) for anything else (so no editor or
// hook can change a case by trimming or normalising it).
//
// Usage: java -cp filius.jar:lib/*:classes IpCheckCases <cases txt> <output json>
import filius.software.vermittlungsschicht.IP;
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class IpCheckCases {
public static void main(String[] args) throws Exception {
List<Object> result = new ArrayList<>();
for (String line : Files.readAllLines(Path.of(args[0]), StandardCharsets.US_ASCII)) {
String input = unescape(line);
String output;
try {
output = IP.ipCheck(input);
} catch (RuntimeException error) {
output = "!" + error.getClass().getSimpleName();
}
result.add(Arrays.asList(line, output));
}
try (FileOutputStream out = new FileOutputStream(args[1])) {
out.write((FlsOracle.Json.write(result, "") + "\n").getBytes(StandardCharsets.UTF_8));
}
System.out.println("ip check cases: " + result.size());
System.exit(0);
}
static String unescape(String line) {
StringBuilder out = new StringBuilder();
for (int index = 0; index < line.length(); index++) {
if (line.startsWith("\\u", index) && index + 6 <= line.length()) {
out.append((char) Integer.parseInt(line.substring(index + 2, index + 6), 16));
index += 5;
} else {
out.append(line.charAt(index));
}
}
return out.toString();
}
}