feat(fls): file systems of PCs and notebooks as Java loads them

readSystemSoftware reads a host's file system: Java's tree of
DefaultMutableTreeNodes, directories named by their user object, files
as Datei (by setters or the three-argument constructor), rooted at
arbeitsVerzeichnis (up to 1.10) or root. All 23 corpus files match Java,
every file compared by name, type and the SHA-256 of its content.

Routers and home routers have files in Java too, but their constructor
makes them from templates (the start page of the firewall's web
interface); they are not in the file and not claimed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 21:14:01 +00:00
co-authored by Claude Opus 5.5
parent cf47cf8f9d
commit 51a83ea68d
6 changed files with 2936 additions and 5 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `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/importProject.ts` | `Persistence/TopologyProjectStore.swift` (Knoten, Ports, Kabel, IP-Konfiguration, Switch/WLAN, Routen, RIP, Firewall, DHCP, Portweiterleitung, Modem, Doku-Elemente des Imports) | übertragen, Eingabe ist Javas Sicht: Name und Port-Anzahl wie Java, abgelehnte Adresse → Swift-Standard, Router-Standardgateway wie Java (Swift verliert es), jede Router-Firewall eingetragen, Konfigurationstexte wie in Swift getrimmt; gegen Swifts importiertes Modell auf 13 Dateien geprüft |
| `test/importProject.test.ts` | — | neu: Swifts importiertes Modell (13 Dateien, zwei begründete Abweichungen), Vollständigkeit aller 23, Pings im Kern (über einen Router; über drei Router mit manuellen Routen und Router-Gateways) |
| `src/systemSoftware.ts` | Java `SwitchFirmware`, `Betriebssystem`, `VermittlungsrechnerBetriebssystem`, `GatewayFirmware`, `Weiterleitungstabelle`, `Firewall`, `FirewallRule`, `DHCPServer`, `ModemFirmware` | neu nach Java: SSID, Lernzeit, RIP, manuelle Routen, Router-Firewall (an allen Stellen, an denen die Datei sie erreicht), DHCP, statische NAT, Modem; `Character.isWhitespace` am JDK gemessen; gegen Java geprüft |
| `src/systemSoftware.ts` | Java `SwitchFirmware`, `Betriebssystem`, `VermittlungsrechnerBetriebssystem`, `GatewayFirmware`, `Weiterleitungstabelle`, `Firewall`, `FirewallRule`, `DHCPServer`, `ModemFirmware`, `Dateisystem`, `Datei` | neu nach Java: SSID, Lernzeit, RIP, manuelle Routen, Router-Firewall (an allen Stellen, an denen die Datei sie erreicht), DHCP, statische NAT, Modem, Dateisystem von Rechner und Notebook; `Character.isWhitespace` am JDK gemessen; gegen Java geprüft |
| `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 |
File diff suppressed because it is too large Load Diff
+64 -2
View File
@@ -11,8 +11,14 @@
// table (port forwarding) and a modem's connection settings.
// Java counterparts as well: filius.software.dhcp.DHCPServer, ModemFirmware.
//
// Checked against Java (corpus.java.json, `system`). File systems and
// applications are later layers.
// And the file system of a PC or notebook: Java's tree of
// DefaultMutableTreeNodes, a directory's user object its name, a file's a
// filius.software.system.Datei. Routers and home routers have files too, but
// Java's constructor makes them from its own templates (the start page of
// the firewall's web interface); they are not in the file.
//
// Checked against Java (corpus.java.json, `system`). Applications are a
// later layer.
//
// 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
@@ -67,6 +73,12 @@ export interface FlsModem {
ipAddress: string | null;
}
export type FlsFileTree =
| { kind: 'directory'; name: string | null; children: FlsFileTree[] }
| { kind: 'file'; name: string | null; type: string | null; content: string | null; children: FlsFileTree[] }
/** A node whose user object is neither a name nor a file. */
| { kind: 'other'; className: string; children: FlsFileTree[] };
export interface FlsSystemSoftware {
/**
* Switch: its SSID, undefined when the file has none (Java rolls one).
@@ -89,6 +101,8 @@ export interface FlsSystemSoftware {
staticNAT?: (string | null)[][];
/** Modem. */
modem?: FlsModem;
/** Rechner, Notebook: the root directory (named "root" by Java's constructor). */
fileSystem?: FlsFileTree;
}
type Warn = (message: string) => void;
@@ -110,6 +124,7 @@ export function readSystemSoftware(device: JavaObject, javaClass: FlsNodeClass,
case 'Rechner':
case 'Notebook':
result.ssid = optionalString(value('ssid'), 'ssid', warn) ?? null;
result.fileSystem = fileSystem(value('dateisystem'), warn);
break;
case 'Vermittlungsrechner':
result.ripEnabled = boolean(value('ripEnabled'), false, 'ripEnabled', warn);
@@ -150,6 +165,53 @@ function modemAddress(value: string | null | undefined): string | null {
return value === undefined ? 'localhost' : value;
}
/**
* Dateisystem: its root is `arbeitsVerzeichnis` in files up to 1.10 and
* `root` after. The constructor's root is a node named "root"; a node the
* file makes with `new DefaultMutableTreeNode()` has no user object.
*/
function fileSystem(system: JavaValue | undefined, warn: Warn): FlsFileTree {
const at = (property: string) => (isJavaObject(system) ? system.properties.get(property) : undefined);
const root = at('root') ?? at('arbeitsVerzeichnis');
if (root === undefined) return { kind: 'directory', name: 'root', children: [] };
return fileTreeNode(root, true, warn);
}
function fileTreeNode(node: JavaValue, constructorRoot: boolean, warn: Warn): FlsFileTree {
if (!isJavaObject(node)) {
warn(`file system: a node is ${describe(node)}, left empty`);
return { kind: 'other', className: 'unknown', children: [] };
}
for (const call of node.calls) {
if (call.method !== 'add') warn(`file system: ${call.method} on a node is not read`);
}
const children = listItems(node).map((child) => fileTreeNode(child, false, warn));
const userObject = node.properties.has('userObject')
? node.properties.get('userObject')!
: node.fromGetter && constructorRoot
? 'root'
: (node.args[0] ?? null);
if (userObject === null || typeof userObject === 'string') return { kind: 'directory', name: userObject, children };
if (isJavaObject(userObject) && userObject.className === 'filius.software.system.Datei') {
// new Datei(name, typ, inhalt), then the setters.
const [name, type, content] = userObject.args.length === 3 ? userObject.args : [];
const text = (property: string, fromConstructor: JavaValue | undefined) => {
const value = userObject.properties.get(property) ?? fromConstructor ?? null;
if (typeof value === 'string' || value === null) return value;
warn(`file system: file ${property} is ${describe(value)}, ignored`);
return null;
};
return {
kind: 'file',
name: text('name', name),
type: text('dateiTyp', type),
content: text('dateiInhalt', content),
children,
};
}
return { kind: 'other', className: isJavaObject(userObject) ? userObject.className : typeof userObject, children };
}
/** A list of String arrays (routes, static NAT); anything but a string is null. */
function rowsOfStrings(list: JavaValue | undefined): (string | null)[][] {
return listItems(list).map((row) => listItems(row).map((cell) => (typeof cell === 'string' ? cell : null)));
+73 -1
View File
@@ -3,6 +3,7 @@
// position and interfaces, each cable with both ends, each documentation
// item — field by field. Then the rules the corpus does not exercise.
import { createHash } from 'node:crypto';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -12,6 +13,7 @@ import {
readFlsArchive,
readTopology,
type FlsDhcpServer,
type FlsFileTree,
type FlsModem,
type FlsNodeClass,
type FlsTopology,
@@ -42,6 +44,25 @@ const TYPE_LABELS: Record<FlsNodeClass, string> = {
Modem: 'Modem',
};
/** FlsOracle.fileTree's shape: contents as length and SHA-256 of their UTF-8 bytes. */
function javaFileTree(tree: FlsFileTree): unknown {
const children = tree.children.map(javaFileTree);
switch (tree.kind) {
case 'directory':
return { directory: tree.name, children };
case 'file':
return {
file: tree.name,
type: tree.type,
length: tree.content?.length ?? null,
sha256: tree.content === null ? null : createHash('sha256').update(tree.content, 'utf8').digest('hex'),
children,
};
case 'other':
return { userObject: tree.className, children };
}
}
/** Java's property names, as FlsOracle.java writes them. */
function javaDhcpServer(server: FlsDhcpServer) {
return {
@@ -84,6 +105,7 @@ function asJavaView(topology: FlsTopology, javaBounds: (number[] | null)[]) {
dhcpServer: javaDhcpServer(node.system.dhcpServer),
}),
...(node.system.modem === undefined ? {} : { modem: javaModem(node.system.modem) }),
...(node.system.fileSystem === undefined ? {} : { fileSystem: javaFileTree(node.system.fileSystem) }),
...(node.system.firewall === undefined
? {}
: {
@@ -116,6 +138,23 @@ function asJavaView(topology: FlsTopology, javaBounds: (number[] | null)[]) {
};
}
/**
* Java's view without the file systems of routers and home routers: their
* files come from Java's constructor (templates of the firewall's web
* interface), not from the .fls file, and the reader does not claim them.
*/
function withoutTemplateFiles(view: unknown): unknown {
const { nodes, ...rest } = view as { nodes: { class: string; system: Record<string, unknown> }[] };
return {
...rest,
nodes: nodes.map((node) => {
if (node.class !== 'Vermittlungsrechner' && node.class !== 'Gateway') return node;
const { fileSystem: _templateFiles, ...system } = node.system;
return { ...node, system };
}),
};
}
describe('corpus: the topology as Java loads it', () => {
for (const file of flsFiles(corpusRoot)) {
const name = relative(corpusRoot, file);
@@ -124,7 +163,7 @@ describe('corpus: the topology as Java loads it', () => {
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(asJavaView(topology, expected.nodes.map((node) => node.bounds))).toEqual(withoutTemplateFiles(expected));
expect(topology.warnings).toEqual([]);
});
}
@@ -296,6 +335,39 @@ describe('readTopology rules', () => {
]);
});
it('reads a file system from `root` or `arbeitsVerzeichnis`, with files by setters or constructor', () => {
const { nodes, warnings } = topologyOf(`<object class="java.util.LinkedList">
${nodeItem('<object class="filius.hardware.knoten.Notebook"/>')}
${nodeItem(`<object class="filius.hardware.knoten.Rechner"><void property="systemSoftware"><void property="dateisystem">
<void property="root"><object class="javax.swing.tree.DefaultMutableTreeNode"><string>/</string>
<void method="add"><object class="javax.swing.tree.DefaultMutableTreeNode">
<void method="add"><object class="javax.swing.tree.DefaultMutableTreeNode">
<object class="filius.software.system.Datei"><string>a.txt</string><string>text</string><string>hallo</string>
<void property="dateiTyp"><string>txt</string></void>
</object>
</object></void>
</object></void>
<void method="insert"><object class="javax.swing.tree.DefaultMutableTreeNode"/><int>0</int></void>
</object></void>
</void></void></object>`)}</object>`);
expect(nodes.map((node) => node.system.fileSystem)).toEqual([
{ kind: 'directory', name: 'root', children: [] },
{
kind: 'directory',
name: '/',
children: [
{
kind: 'directory',
name: null,
children: [{ kind: 'file', name: 'a.txt', type: 'txt', content: 'hallo', children: [] }],
},
],
},
]);
expect(warnings).toEqual(['node 2: file system: insert on a node is not read']);
});
it("gives a modem Java's defaults and keeps an address the file sets to null", () => {
const { nodes } = topologyOf(`<object class="java.util.LinkedList">
${nodeItem('<object class="filius.hardware.knoten.Modem"/>')}
+4 -1
View File
@@ -27,7 +27,10 @@ virtuellen Bildschirm statt „headless".
Schnittstellen, bei Rechner/Notebook `useIPAsName`/`useMACAsName`), unter
`system` SSID, Lernzeit des Switches, RIP, manuelle Routen, die Firewall
von Router und Heimrouter, DHCP-Client und -Server, die statische
NAT-Tabelle des Heimrouters und die Verbindung eines Modems; Kabel (Enden als Gerät/Schnittstelle),
NAT-Tabelle des Heimrouters, die Verbindung eines Modems und das
Dateisystem (Inhalte als Länge und SHA-256, damit die Referenz klein
bleibt; die Dateien von Router und Heimrouter erzeugt Javas Konstruktor aus
eigenen Vorlagen, sie stehen nicht in der Datei); Kabel (Enden als Gerät/Schnittstelle),
Doku-Elemente.
Systemsoftware, Dateisysteme und Anwendungen folgen schrittweise.
+40
View File
@@ -19,6 +19,7 @@ import filius.software.dhcp.DHCPServer;
import filius.software.firewall.Firewall;
import filius.software.firewall.FirewallRule;
import filius.software.system.Betriebssystem;
import filius.software.system.Datei;
import filius.software.system.GatewayFirmware;
import filius.software.system.InternetKnotenBetriebssystem;
import filius.software.system.ModemFirmware;
@@ -237,6 +238,7 @@ public class FlsOracle {
}
}
json.put("manualRoutes", routes);
json.put("fileSystem", system.getDateisystem() == null ? null : fileTree(system.getDateisystem().getRoot()));
json.put("dhcpClient", system.isDHCPKonfiguration());
DHCPServer server = system.getDHCPServer();
if (server != null) {
@@ -285,6 +287,44 @@ public class FlsOracle {
return json;
}
/**
* A file system tree: a directory is a node whose user object is its name,
* a file one whose user object is a Datei. Contents as SHA-256 and length
* of their UTF-8 bytes, to keep the reference small.
*/
static Map<String, Object> fileTree(javax.swing.tree.DefaultMutableTreeNode node) {
Map<String, Object> json = new LinkedHashMap<>();
Object user = node.getUserObject();
if (user instanceof Datei file) {
json.put("file", file.getName());
json.put("type", file.getDateiTyp());
String content = file.getDateiInhalt();
json.put("length", content == null ? null : content.length());
json.put("sha256", content == null ? null : sha256(content));
} else if (user == null || user instanceof String) {
json.put("directory", user);
} else {
json.put("userObject", user.getClass().getName());
}
List<Object> children = new ArrayList<>();
for (int index = 0; index < node.getChildCount(); index++) {
children.add(fileTree((javax.swing.tree.DefaultMutableTreeNode) node.getChildAt(index)));
}
json.put("children", children);
return json;
}
static String sha256(String text) {
try {
byte[] digest = java.security.MessageDigest.getInstance("SHA-256").digest(text.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte value : digest) hex.append(String.format("%02x", value));
return hex.toString();
} catch (java.security.NoSuchAlgorithmException error) {
throw new IllegalStateException(error);
}
}
/** Just enough JSON: maps, lists, strings, numbers, booleans, null. */
static final class Json {
static String write(Object value, String indent) {