feat(fls): a writer for the XMLDecoder format, checked by Java

javaBeansWriter.ts writes what java.beans.XMLDecoder reads — the
counterpart of javabeans.ts and the base of the .fls writer. Its input says
explicitly what Java should do: construct a class with typed arguments, call
setters, getters (with statements on their result), methods and indexed
access; an object reached twice is written once with an id and referenced
after. Unlike XMLEncoder it writes what it is given, defaults included.
What XML 1.0 cannot hold (controls, lone surrogates, U+FFFE/F) and a CR,
which XML would read as a line break, are written as <char code>.

Checked by Java itself: nine files of the writer (strings with every kind
of odd unit, all primitive types including NaN, -0.0 and a long beyond
2^53, collections, maps, shared objects, Rectangle, Color, FILIUS beans with
getter chains, arrays, indexed access, a static constant) are read by
XMLDecoder in the Java oracle (BeansDump), which describes what it got and
collects the errors the decoder would only log. Mutation probes regenerate
the files and let Java read each mutant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 07:50:01 +00:00
co-authored by Claude Opus 5.5
parent 956443726b
commit 3795316fae
17 changed files with 1330 additions and 1 deletions
+2
View File
@@ -47,6 +47,8 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
| `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 |
| `src/javaEmail.ts` | Java `EmailServer` (Konten-Bean, `kontenLaden`/`stringZuListeBenutzerkonten`), `EmailAnwendung` (Ordner, Adressbuch, `laden`), `EmailKonto`, `Email`, `AddressEntry`, `EmailUtils` | neu nach Java: E-Mail-Beans über ihre Setter, `/mailserver/konten.txt` und `/konten.txt` im Format von Javas Lesern, ohne ihre Fehler (leere Felder zählen, eine kurze Zeile fällt allein weg, Nachrichten mit leerem Text oder Betreff bleiben, `;` im Betreff bleibt, CRLF, beim Client die letzte vollständige Zeile); Empfänger älterer Dateien als Zeichenketten; gegen Java auf dem Korpus und an 43 Fällen geprüft (13 benannte Java-Fehler) |
| `src/javaBeansWriter.ts` | — | neu: Schreiber für das `XMLDecoder`-Format, Gegenstück zu `javabeans.ts`; ausdrücklich typisierte Eingabe, geteilte Objekte über `id`/`idref`, was XML nicht fassen kann (und CR) als `<char code>`; von Java selbst geprüft (`BeansDump`) |
| `test/javaBeansWriter.test.ts`, `test/beans/` | — | neu: 9 Dateien des Schreibers, von Javas `XMLDecoder` gelesen und beschrieben; Erwartungen von Hand |
| `src/importEmail.ts` | `Persistence/TopologyProjectStore.swift` (`registerJavaEmailConfigurations`, `assignedJavaEmailMessages`) | übertragen, Eingabe ist Javas Sicht: Server-Konten aus `/mailserver/konten.txt`, ohne Datei aus der Bean-Liste (Java verlöre sie); Client-Konto aus `/konten.txt`, ohne Datei aus der älteren `kontoListe` (Filius 2.14 liest sie nicht mehr); Konten und Nachrichten bleiben, wo Swift verwirft, was seine Prüfung nicht besteht; Vor- und Nachname getrennt; Empfangszeit nur aus der Datei (Swift nummeriert); gegen Swifts Modell geprüft |
| `test/importEmail.test.ts`, `test/email-*.swift.json` | `State/TopologyEmail.swift` (`decodeJavaServer`, `decodeJavaClient`) als Vergleich | neu: dieselben 43 Kontodateien mit Swifts Leser; wie Swift bis auf benannte Fälle, in denen Swift prüft und die ganze Datei verwirft, wo Java liest |
| `src/javaFileSystem.ts`, `src/javaString.ts` | Java `Dateisystem.holeDatei`, `String.trim`/`split`/`equalsIgnoreCase` | neu nach Java; die Groß-/Kleinschreibung gegenüber ASCII am JDK über die ganze BMP gemessen |
+292
View File
@@ -0,0 +1,292 @@
// Writes the format java.beans.XMLDecoder reads — the counterpart of
// javabeans.ts. The input says what Java should do, explicitly typed: which
// class to construct with which arguments, which setters, getters and methods
// to call. An object reached twice (the same JS object) is written once with
// an id and referenced after (idref), as XMLEncoder does.
//
// Unlike XMLEncoder this writer does not leave out what equals a
// constructor's default: it writes what it is given. Checked by Java itself
// (tools/java-oracle BeansDump reads what this writes; javaBeansWriter.test.ts).
export type BeanPrimitive =
| { readonly kind: 'int' | 'short' | 'byte'; readonly value: number }
| { readonly kind: 'long'; readonly value: number | bigint }
| { readonly kind: 'float' | 'double'; readonly value: number }
| { readonly kind: 'char'; readonly value: string };
export type BeanValue =
| null
| string
| boolean
| BeanPrimitive
| BeanObject
| BeanArray
| { readonly kind: 'class'; readonly name: string };
export interface BeanObject {
readonly kind: 'object';
readonly className: string;
/** Constructor arguments. */
readonly args?: readonly BeanValue[];
/** `C.F`: a static field instead of a new object. */
readonly staticField?: string;
readonly statements?: readonly BeanStatement[];
}
export interface BeanArray {
readonly kind: 'array';
readonly componentClass: string;
readonly items: readonly BeanValue[];
}
export type BeanStatement =
/** target.setP(value) */
| { readonly set: string; readonly value: BeanValue }
/** target.getP(), then the statements on its result */
| { readonly get: string; readonly statements: readonly BeanStatement[] }
/** target.m(args) */
| { readonly call: string; readonly args: readonly BeanValue[] }
/** target.set(index, value) on a list */
| { readonly setIndex: number; readonly value: BeanValue }
/** target.get(index), then the statements on its result */
| { readonly getIndex: number; readonly statements: readonly BeanStatement[] };
export const int = (value: number): BeanPrimitive => ({ kind: 'int', value });
export const long = (value: number | bigint): BeanPrimitive => ({ kind: 'long', value });
export const double = (value: number): BeanPrimitive => ({ kind: 'double', value });
export const float = (value: number): BeanPrimitive => ({ kind: 'float', value });
export function object(className: string, statements: readonly BeanStatement[] = [], args: readonly BeanValue[] = []): BeanObject {
return { kind: 'object', className, args, statements };
}
/** An XMLDecoder document: the values in order, as `new XMLDecoder(in).readObject()` returns them. */
export function encodeJavaBeans(values: readonly BeanValue[]): string {
const writer = new Writer(sharedObjects(values));
const lines = ['<?xml version="1.0" encoding="UTF-8"?>', '<java version="17" class="java.beans.XMLDecoder">'];
for (const value of values) writer.value(value, 1, lines);
lines.push('</java>', '');
return lines.join('\n');
}
class Writer {
private readonly ids = new Map<BeanObject | BeanArray, string>();
private readonly counters = new Map<string, number>();
/** Objects written more than once need an id; found before writing. */
constructor(private readonly shared: ReadonlySet<BeanObject | BeanArray>) {}
value(value: BeanValue, depth: number, lines: string[]): void {
const indent = ' '.repeat(depth);
if (value === null) {
lines.push(`${indent}<null/>`);
} else if (typeof value === 'string') {
lines.push(`${indent}<string>${stringContent(value)}</string>`);
} else if (typeof value === 'boolean') {
lines.push(`${indent}<boolean>${value}</boolean>`);
} else {
switch (value.kind) {
case 'int':
case 'short':
case 'byte':
lines.push(`${indent}<${value.kind}>${integerText(value.value)}</${value.kind}>`);
break;
case 'long':
lines.push(`${indent}<long>${typeof value.value === 'bigint' ? value.value : integerText(value.value)}</long>`);
break;
case 'float':
case 'double':
lines.push(`${indent}<${value.kind}>${floatingText(value.value)}</${value.kind}>`);
break;
case 'char':
lines.push(`${indent}${charElement(value.value)}`);
break;
case 'class':
lines.push(`${indent}<class>${escapeText(value.name)}</class>`);
break;
case 'array':
this.array(value, depth, lines);
break;
case 'object':
this.object(value, depth, lines);
break;
}
}
}
private object(value: BeanObject, depth: number, lines: string[]): void {
const indent = ' '.repeat(depth);
const existing = this.ids.get(value);
if (existing !== undefined) {
lines.push(`${indent}<object idref="${existing}"/>`);
return;
}
const id = this.idFor(value, simpleName(value.className));
const idAttribute = id === undefined ? '' : ` id="${id}"`;
if (value.staticField !== undefined) {
lines.push(`${indent}<object class="${escapeAttribute(value.className)}"${idAttribute} field="${escapeAttribute(value.staticField)}"/>`);
return;
}
const args = value.args ?? [];
const statements = value.statements ?? [];
if (args.length === 0 && statements.length === 0) {
lines.push(`${indent}<object class="${escapeAttribute(value.className)}"${idAttribute}/>`);
return;
}
lines.push(`${indent}<object class="${escapeAttribute(value.className)}"${idAttribute}>`);
for (const arg of args) this.value(arg, depth + 1, lines);
for (const statement of statements) this.statement(statement, depth + 1, lines);
lines.push(`${indent}</object>`);
}
private array(value: BeanArray, depth: number, lines: string[]): void {
const indent = ' '.repeat(depth);
const existing = this.ids.get(value);
if (existing !== undefined) {
lines.push(`${indent}<object idref="${existing}"/>`);
return;
}
const id = this.idFor(value, `${simpleName(value.componentClass)}Array`);
const idAttribute = id === undefined ? '' : ` id="${id}"`;
const head = `${indent}<array class="${escapeAttribute(value.componentClass)}"${idAttribute} length="${value.items.length}"`;
if (value.items.length === 0) {
lines.push(`${head}/>`);
return;
}
lines.push(`${head}>`);
for (const [index, item] of value.items.entries()) {
lines.push(`${indent} <void index="${index}">`);
this.value(item, depth + 2, lines);
lines.push(`${indent} </void>`);
}
lines.push(`${indent}</array>`);
}
private statement(statement: BeanStatement, depth: number, lines: string[]): void {
const indent = ' '.repeat(depth);
const nested = (open: string, body: () => void) => {
lines.push(`${indent}${open}>`);
body();
lines.push(`${indent}</void>`);
};
if ('set' in statement) {
nested(`<void property="${escapeAttribute(statement.set)}"`, () => this.value(statement.value, depth + 1, lines));
} else if ('get' in statement) {
if (statement.statements.length === 0) return;
nested(`<void property="${escapeAttribute(statement.get)}"`, () => {
for (const inner of statement.statements) this.statement(inner, depth + 1, lines);
});
} else if ('call' in statement) {
if (statement.args.length === 0) {
lines.push(`${indent}<void method="${escapeAttribute(statement.call)}"/>`);
return;
}
nested(`<void method="${escapeAttribute(statement.call)}"`, () => {
for (const arg of statement.args) this.value(arg, depth + 1, lines);
});
} else if ('setIndex' in statement) {
nested(`<void index="${statement.setIndex}"`, () => this.value(statement.value, depth + 1, lines));
} else {
if (statement.statements.length === 0) return;
nested(`<void index="${statement.getIndex}"`, () => {
for (const inner of statement.statements) this.statement(inner, depth + 1, lines);
});
}
}
private idFor(value: BeanObject | BeanArray, name: string): string | undefined {
if (!this.shared.has(value)) return undefined;
const count = this.counters.get(name) ?? 0;
this.counters.set(name, count + 1);
const id = `${name}${count}`;
this.ids.set(value, id);
return id;
}
}
/** The objects and arrays the values reach more than once. */
function sharedObjects(roots: readonly BeanValue[]): Set<BeanObject | BeanArray> {
const seen = new Set<BeanObject | BeanArray>();
const shared = new Set<BeanObject | BeanArray>();
const visitStatement = (statement: BeanStatement): void => {
if ('set' in statement || 'setIndex' in statement) visit(statement.value);
else if ('call' in statement) statement.args.forEach(visit);
else statement.statements.forEach(visitStatement);
};
const visit = (value: BeanValue): void => {
if (value === null || typeof value !== 'object' || (value.kind !== 'object' && value.kind !== 'array')) return;
if (seen.has(value)) {
shared.add(value);
return;
}
seen.add(value);
if (value.kind === 'array') {
value.items.forEach(visit);
} else {
(value.args ?? []).forEach(visit);
(value.statements ?? []).forEach(visitStatement);
}
};
roots.forEach(visit);
return shared;
}
function simpleName(className: string): string {
return className.slice(className.lastIndexOf('.') + 1).replace(/\$/g, '_');
}
function integerText(value: number): string {
if (!Number.isInteger(value)) throw new RangeError(`not an integer: ${value}`);
return String(value);
}
/** Text Double.valueOf reads back to the same value. */
function floatingText(value: number): string {
if (Object.is(value, -0)) return '-0.0';
if (Number.isNaN(value)) return 'NaN';
if (value === Infinity) return 'Infinity';
if (value === -Infinity) return '-Infinity';
return String(value);
}
/**
* A string's content: what XML 1.0 cannot hold, and a CR (which XML would
* read as a line break), as `<char code="#…"/>`, the rest escaped.
*/
function stringContent(value: string): string {
let out = '';
for (let index = 0; index < value.length; index++) {
const unit = value.charCodeAt(index);
if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < value.length) {
const next = value.charCodeAt(index + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
out += value[index]! + value[index + 1]!;
index += 1;
continue;
}
}
out += needsCharElement(unit) ? charCode(unit) : escapeText(value[index]!);
}
return out;
}
function charElement(value: string): string {
const unit = value.charCodeAt(0);
if (value.length !== 1) throw new RangeError('a char is one UTF-16 unit');
return needsCharElement(unit) ? `<char code="#${unit.toString(16).toUpperCase()}"/>` : `<char>${escapeText(value)}</char>`;
}
function needsCharElement(unit: number): boolean {
return (unit < 0x20 && unit !== 0x09 && unit !== 0x0a) || (unit >= 0xd800 && unit <= 0xdfff) || unit === 0xfffe || unit === 0xffff;
}
function charCode(unit: number): string {
return `<char code="#${unit.toString(16).toUpperCase()}"/>`;
}
function escapeText(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function escapeAttribute(text: string): string {
return escapeText(text).replace(/"/g, '&quot;');
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<java version="17" class="java.beans.XMLDecoder">
<array class="java.lang.String" length="3">
<void index="0">
<string>a</string>
</void>
<void index="1">
<null/>
</void>
<void index="2">
<string>c</string>
</void>
</array>
<array class="int" length="2">
<void index="0">
<int>0</int>
</void>
<void index="1">
<int>5</int>
</void>
</array>
</java>
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<java version="17" class="java.beans.XMLDecoder">
<object class="java.awt.Rectangle">
<int>1</int>
<int>2</int>
<int>3</int>
<int>4</int>
</object>
<object class="java.awt.Color">
<int>10</int>
<int>20</int>
<int>30</int>
<int>40</int>
</object>
</java>
+411
View File
@@ -0,0 +1,411 @@
{
"arrays.xml": {
"values": [
{
"id": 0,
"class": "[Ljava.lang.String;",
"items": [
{
"string": "a"
},
null,
{
"string": "c"
}
]
},
{
"id": 1,
"class": "[I",
"items": [
{
"int": 0
},
{
"int": 5
}
]
}
],
"errors": []
},
"awt.xml": {
"values": [
{
"rectangle": [
1,
2,
3,
4
]
},
{
"color": [
10,
20,
30,
40
]
}
],
"errors": []
},
"beans.xml": {
"values": [
{
"id": 0,
"class": "filius.software.system.Datei",
"properties": {
"dateiInhalt": {
"string": "a.de. A 3600 1.2.3.4\\u000A"
},
"dateiTyp": {
"string": "text"
},
"name": {
"string": "hosts"
}
}
},
{
"id": 1,
"class": "filius.software.email.EmailKonto",
"properties": {
"benutzername": {
"string": "bob"
},
"emailAdresse": null,
"nachname": {
"string": ""
},
"nachrichten": {
"id": 2,
"class": "java.util.LinkedList",
"items": [
{
"id": 3,
"class": "filius.software.email.Email",
"properties": {
"absender": {
"id": 4,
"class": "filius.software.email.AddressEntry",
"properties": {
"mailAddress": {
"string": "a@x"
},
"name": {
"string": "Alice"
}
}
},
"bcc": {
"id": 5,
"class": "java.util.ArrayList",
"items": []
},
"betreff": {
"string": "Hi"
},
"cc": {
"id": 6,
"class": "java.util.ArrayList",
"items": []
},
"dateReceived": {
"string": ""
},
"delete": false,
"empfaenger": {
"id": 7,
"class": "java.util.ArrayList",
"items": [
{
"id": 8,
"class": "filius.software.email.AddressEntry",
"properties": {
"mailAddress": {
"string": "b@y"
},
"name": null
}
}
]
},
"neu": false,
"text": {
"string": ""
},
"versendet": false
}
}
]
},
"passwort": null,
"pop3port": null,
"pop3server": null,
"smtpport": null,
"smtpserver": null,
"vorname": {
"string": ""
}
}
},
{
"id": 9,
"class": "filius.software.email.Kontakt",
"properties": {
"email": null,
"hausnr": {
"int": 12
},
"name": {
"string": "Lang"
},
"plz": {
"int": 0
},
"strasse": null,
"telefon": null,
"vorname": null,
"wohnort": null
}
}
],
"errors": []
},
"collections.xml": {
"values": [
{
"id": 0,
"class": "java.util.Vector",
"items": [
{
"int": 1
},
{
"id": 1,
"class": "java.util.ArrayList",
"items": [
{
"string": "nested"
}
]
}
]
},
{
"id": 2,
"class": "java.util.HashMap",
"entries": [
[
{
"string": "key"
},
{
"int": 8
}
]
]
}
],
"errors": []
},
"constants.xml": {
"values": [
{
"int": 0
}
],
"errors": []
},
"indexed.xml": {
"values": [
{
"id": 0,
"class": "java.util.LinkedList",
"items": [
{
"id": 1,
"class": "filius.software.system.Datei",
"properties": {
"dateiInhalt": null,
"dateiTyp": null,
"name": {
"string": "renamed"
}
}
},
{
"string": "replaced"
}
]
}
],
"errors": []
},
"numbers.xml": {
"values": [
{
"int": -1
},
{
"int": 2147483647
},
{
"long": "-9007199254740991"
},
{
"long": "9223372036854775807"
},
{
"double": "0.1"
},
{
"double": "-0.0"
},
{
"double": "1.0E300"
},
{
"double": "NaN"
},
{
"double": "-Infinity"
},
{
"float": "1.5"
},
{
"short": -3
},
{
"byte": 127
},
{
"char": 120
},
{
"char": 1
},
true,
false,
null,
{
"class": "java.lang.String"
}
],
"errors": []
},
"shared.xml": {
"values": [
{
"id": 0,
"class": "java.util.LinkedList",
"items": [
{
"id": 1,
"class": "java.util.ArrayList",
"items": [
{
"string": "shared"
}
]
},
{
"id": 2,
"class": "java.util.ArrayList",
"items": [
{
"string": "other"
}
]
},
{
"ref": 1
},
{
"ref": 2
}
]
},
{
"id": 3,
"class": "filius.software.system.Datei",
"properties": {
"dateiInhalt": {
"string": "a.de. A 3600 1.2.3.4\\u000A"
},
"dateiTyp": {
"string": "text"
},
"name": {
"string": "hosts"
}
}
},
{
"ref": 3
}
],
"errors": []
},
"strings.xml": {
"values": [
{
"id": 0,
"class": "java.util.LinkedList",
"items": [
{
"string": "plain"
},
{
"string": ""
},
{
"string": " spaced "
},
{
"string": "<&>\"'"
},
{
"string": "]]>"
},
{
"string": "tab\\u0009here"
},
{
"string": "line\\u000Abreak"
},
{
"string": "cr\\u000Dhere"
},
{
"string": "crlf\\u000D\\u000Ahere"
},
{
"string": "\\u0000\\u0001\\u001F\\u007F"
},
{
"string": "\\u0085\\u2028\\u2029"
},
{
"string": "\\uFEFFbom"
},
{
"string": "Gr\\u00FC\\u00DFe \\uD83D\\uDE00"
},
{
"string": "lone \\uD800 high"
},
{
"string": "lone \\uDC00 low"
},
{
"string": "\\uFFFE\\uFFFF"
}
]
}
],
"errors": []
}
}
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<java version="17" class="java.beans.XMLDecoder">
<object class="filius.software.system.Datei">
<void property="name">
<string>hosts</string>
</void>
<void property="dateiTyp">
<string>text</string>
</void>
<void property="dateiInhalt">
<string>a.de. A 3600 1.2.3.4
</string>
</void>
</object>
<object class="filius.software.email.EmailKonto">
<void property="benutzername">
<string>bob</string>
</void>
<void property="passwort">
<null/>
</void>
<void property="nachname">
<null/>
</void>
<void property="nachrichten">
<void method="add">
<object class="filius.software.email.Email">
<void property="absender">
<object class="filius.software.email.AddressEntry">
<void property="name">
<string>Alice</string>
</void>
<void property="mailAddress">
<string>a@x</string>
</void>
</object>
</void>
<void property="empfaenger">
<void method="add">
<object class="filius.software.email.AddressEntry">
<void property="mailAddress">
<string>b@y</string>
</void>
</object>
</void>
</void>
<void property="betreff">
<string>Hi</string>
</void>
<void property="neu">
<boolean>false</boolean>
</void>
</object>
</void>
</void>
</object>
<object class="filius.software.email.Kontakt">
<void property="hausnr">
<int>12</int>
</void>
<void property="name">
<string>Lang</string>
</void>
</object>
</java>
+25
View File
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<java version="17" class="java.beans.XMLDecoder">
<object class="java.util.Vector">
<void method="add">
<int>1</int>
</void>
<void method="add">
<object class="java.util.ArrayList">
<void method="add">
<string>nested</string>
</void>
</object>
</void>
</object>
<object class="java.util.HashMap">
<void method="put">
<string>key</string>
<int>7</int>
</void>
<void method="put">
<string>key</string>
<int>8</int>
</void>
</object>
</java>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<java version="17" class="java.beans.XMLDecoder">
<object class="javax.swing.SwingConstants" field="CENTER"/>
</java>
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<java version="17" class="java.beans.XMLDecoder">
<object class="java.util.LinkedList">
<void method="add">
<object class="filius.software.system.Datei">
<void property="name">
<string>first</string>
</void>
</object>
</void>
<void index="0">
<void property="name">
<string>renamed</string>
</void>
</void>
<void method="add">
<string>second</string>
</void>
<void index="1">
<string>replaced</string>
</void>
</object>
</java>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<java version="17" class="java.beans.XMLDecoder">
<int>-1</int>
<int>2147483647</int>
<long>-9007199254740991</long>
<long>9223372036854775807</long>
<double>0.1</double>
<double>-0.0</double>
<double>1e+300</double>
<double>NaN</double>
<double>-Infinity</double>
<float>1.5</float>
<short>-3</short>
<byte>127</byte>
<char>x</char>
<char code="#1"/>
<boolean>true</boolean>
<boolean>false</boolean>
<null/>
<class>java.lang.String</class>
</java>
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<java version="17" class="java.beans.XMLDecoder">
<object class="java.util.LinkedList">
<void method="add">
<object class="java.util.ArrayList" id="ArrayList0">
<void method="add">
<string>shared</string>
</void>
</object>
</void>
<void method="add">
<object class="java.util.ArrayList" id="ArrayList1">
<void method="add">
<string>other</string>
</void>
</object>
</void>
<void method="add">
<object idref="ArrayList0"/>
</void>
<void method="add">
<object idref="ArrayList1"/>
</void>
</object>
<object class="filius.software.system.Datei" id="Datei0">
<void property="name">
<string>hosts</string>
</void>
<void property="dateiTyp">
<string>text</string>
</void>
<void property="dateiInhalt">
<string>a.de. A 3600 1.2.3.4
</string>
</void>
</object>
<object idref="Datei0"/>
</java>
+55
View File
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<java version="17" class="java.beans.XMLDecoder">
<object class="java.util.LinkedList">
<void method="add">
<string>plain</string>
</void>
<void method="add">
<string></string>
</void>
<void method="add">
<string> spaced </string>
</void>
<void method="add">
<string>&lt;&amp;&gt;"'</string>
</void>
<void method="add">
<string>]]&gt;</string>
</void>
<void method="add">
<string>tab here</string>
</void>
<void method="add">
<string>line
break</string>
</void>
<void method="add">
<string>cr<char code="#D"/>here</string>
</void>
<void method="add">
<string>crlf<char code="#D"/>
here</string>
</void>
<void method="add">
<string><char code="#0"/><char code="#1"/><char code="#1F"/></string>
</void>
<void method="add">
<string>…

</string>
</void>
<void method="add">
<string>bom</string>
</void>
<void method="add">
<string>Grüße 😀</string>
</void>
<void method="add">
<string>lone <char code="#D800"/> high</string>
</void>
<void method="add">
<string>lone <char code="#DC00"/> low</string>
</void>
<void method="add">
<string><char code="#FFFE"/><char code="#FFFF"/></string>
</void>
</object>
</java>
+193
View File
@@ -0,0 +1,193 @@
// The XMLDecoder format as javaBeansWriter.ts writes it, read by Java
// itself: every case is written to test/beans/<name>.xml (UPDATE_BEAN_CASES=1
// rewrites them), tools/java-oracle BeansDump reads each with XMLDecoder and
// describes what it got (beans.java.json). The descriptions must match the
// expectations written here by hand, and Java must report no error.
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import { decodeJavaBeans } from '../src/javabeans.js';
import {
double,
encodeJavaBeans,
float,
int,
long,
object,
type BeanObject,
type BeanValue,
} from '../src/javaBeansWriter.js';
const directory = join(dirname(fileURLToPath(import.meta.url)), 'beans');
const list = (className: string, items: BeanValue[]): BeanObject =>
object(className, items.map((item) => ({ call: 'add', args: [item] })));
/** BeansDump's escaping: every unit outside printable ASCII, and the backslash, as backslash-u and four hex digits. */
const text = (value: string) => ({
string: value.replace(/[^\x20-\x7E]|\\/g, (unit) => `\\u${unit.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}`),
});
const bean = (className: string, properties: Record<string, unknown>) => ({ class: className, properties });
const items = (className: string, values: unknown[]) => ({ class: className, items: values });
const STRINGS = ['plain', '', ' spaced ', '<&>"\'', ']]>', 'tab\there', 'line\nbreak', 'cr\rhere', 'crlf\r\nhere',
'\u0000\u0001\u001F\u007F', '\u0085\u2028\u2029', '\uFEFFbom', 'Grüße 😀', 'lone \uD800 high', 'lone \uDC00 low', '\uFFFE\uFFFF'];
const inner = list('java.util.ArrayList', ['shared']);
const other = list('java.util.ArrayList', ['other']);
const file = object('filius.software.system.Datei', [
{ set: 'name', value: 'hosts' },
{ set: 'dateiTyp', value: 'text' },
{ set: 'dateiInhalt', value: 'a.de. A 3600 1.2.3.4\n' },
]);
const CASES: Record<string, { values: BeanValue[]; expected: unknown[] }> = {
strings: {
values: [list('java.util.LinkedList', STRINGS)],
expected: [items('java.util.LinkedList', STRINGS.map(text))],
},
numbers: {
values: [int(-1), int(2_147_483_647), long(-9_007_199_254_740_991), long(9_223_372_036_854_775_807n), double(0.1), double(-0),
double(1e300), double(Number.NaN), double(-Infinity), float(1.5), { kind: 'short', value: -3 }, { kind: 'byte', value: 127 },
{ kind: 'char', value: 'x' }, { kind: 'char', value: '\u0001' }, true, false, null, { kind: 'class', name: 'java.lang.String' }],
expected: [{ int: -1 }, { int: 2_147_483_647 }, { long: '-9007199254740991' }, { long: '9223372036854775807' }, { double: 0.1 },
{ double: -0 }, { double: 1e300 }, { double: Number.NaN }, { double: -Infinity }, { float: 1.5 }, { short: -3 }, { byte: 127 },
{ char: 120 }, { char: 1 }, true, false, null, { class: 'java.lang.String' }],
},
collections: {
values: [
list('java.util.Vector', [int(1), list('java.util.ArrayList', ['nested'])]),
object('java.util.HashMap', [{ call: 'put', args: ['key', int(7)] }, { call: 'put', args: ['key', int(8)] }]),
],
expected: [
items('java.util.Vector', [{ int: 1 }, items('java.util.ArrayList', [text('nested')])]),
{ class: 'java.util.HashMap', entries: [[text('key'), { int: 8 }]] },
],
},
shared: {
values: [list('java.util.LinkedList', [inner, other, inner, other]), file, file],
expected: [
{
id: 0,
class: 'java.util.LinkedList',
items: [items('java.util.ArrayList', [text('shared')]), items('java.util.ArrayList', [text('other')]), { ref: 1 }, { ref: 2 }],
},
{ id: 3, class: 'filius.software.system.Datei' },
{ ref: 3 },
],
},
awt: {
values: [object('java.awt.Rectangle', [], [int(1), int(2), int(3), int(4)]), object('java.awt.Color', [], [int(10), int(20), int(30), int(40)])],
expected: [{ rectangle: [1, 2, 3, 4] }, { color: [10, 20, 30, 40] }],
},
beans: {
values: [
file,
object('filius.software.email.EmailKonto', [
{ set: 'benutzername', value: 'bob' },
{ set: 'passwort', value: null },
{ set: 'nachname', value: null },
{
get: 'nachrichten',
statements: [
{
call: 'add',
args: [
object('filius.software.email.Email', [
{ set: 'absender', value: object('filius.software.email.AddressEntry', [{ set: 'name', value: 'Alice' }, { set: 'mailAddress', value: 'a@x' }]) },
{ get: 'empfaenger', statements: [{ call: 'add', args: [object('filius.software.email.AddressEntry', [{ set: 'mailAddress', value: 'b@y' }])] }] },
{ set: 'betreff', value: 'Hi' },
{ set: 'neu', value: false },
]),
],
},
],
},
]),
object('filius.software.email.Kontakt', [{ set: 'hausnr', value: int(12) }, { set: 'name', value: 'Lang' }]),
],
expected: [
bean('filius.software.system.Datei', { name: text('hosts'), dateiTyp: text('text'), dateiInhalt: text('a.de. A 3600 1.2.3.4\n') }),
bean('filius.software.email.EmailKonto', {
benutzername: text('bob'),
passwort: null,
// setNachname keeps "" for null.
nachname: text(''),
nachrichten: items('java.util.LinkedList', [
bean('filius.software.email.Email', {
betreff: text('Hi'),
neu: false,
empfaenger: items('java.util.ArrayList', [bean('filius.software.email.AddressEntry', { name: null, mailAddress: text('b@y') })]),
}),
]),
}),
bean('filius.software.email.Kontakt', { hausnr: { int: 12 }, name: text('Lang') }),
],
},
arrays: {
values: [{ kind: 'array', componentClass: 'java.lang.String', items: ['a', null, 'c'] }, { kind: 'array', componentClass: 'int', items: [int(0), int(5)] }],
expected: [items('[Ljava.lang.String;', [text('a'), null, text('c')]), items('[I', [{ int: 0 }, { int: 5 }])],
},
indexed: {
values: [object('java.util.LinkedList', [
{ call: 'add', args: [object('filius.software.system.Datei', [{ set: 'name', value: 'first' }])] },
{ getIndex: 0, statements: [{ set: 'name', value: 'renamed' }] },
{ call: 'add', args: ['second'] },
{ setIndex: 1, value: 'replaced' },
])],
expected: [items('java.util.LinkedList', [bean('filius.software.system.Datei', { name: text('renamed') }), text('replaced')])],
},
constants: {
values: [{ kind: 'object', className: 'javax.swing.SwingConstants', staticField: 'CENTER' }],
expected: [{ int: 0 }],
},
};
/** Whether Java's description has everything expected: listed properties only, numbers by value, ids only where given. */
function matches(actual: unknown, expected: unknown): boolean {
if (expected === null || typeof expected !== 'object') return Object.is(actual, expected);
if (actual === null || typeof actual !== 'object') return false;
const want = expected as Record<string, unknown>;
const have = actual as Record<string, unknown>;
for (const [key, value] of Object.entries(want)) {
if ((key === 'double' || key === 'float') && typeof have[key] === 'string') {
if (!Object.is(Number(have[key]), value)) return false;
continue;
}
if (Array.isArray(value)) {
const list = have[key];
if (!Array.isArray(list) || list.length !== value.length || !value.every((item, index) => matches(list[index], item))) return false;
continue;
}
if (!(key in have) || !matches(have[key], value)) return false;
}
return true;
}
const javaDump = existsSync(join(directory, 'beans.java.json'))
? (JSON.parse(readFileSync(join(directory, 'beans.java.json'), 'utf8')) as Record<string, { values: unknown[]; errors: string[] }>)
: {};
describe('javaBeansWriter, read by Java', () => {
for (const [name, { values, expected }] of Object.entries(CASES)) {
it(name, () => {
const path = join(directory, `${name}.xml`);
const xml = encodeJavaBeans(values);
if (process.env.UPDATE_BEAN_CASES === '1') writeFileSync(path, xml);
expect(readFileSync(path, 'utf8')).toBe(xml);
const java = javaDump[`${name}.xml`];
expect(java?.errors).toEqual([]);
expect(java!.values).toHaveLength(expected.length);
java!.values.forEach((value, index) => {
expect(matches(value, expected[index]), `value ${index}: ${JSON.stringify(value)}`).toBe(true);
});
});
}
it('reads back with the reader of this package', () => {
expect(decodeJavaBeans(encodeJavaBeans(CASES.strings!.values))).toEqual([
expect.objectContaining({ calls: STRINGS.map((value) => ({ method: 'add', args: [value] })) }),
]);
});
});
+6
View File
@@ -56,3 +56,9 @@ packages/fls/::gesendete
packages/fls/::erstellte
packages/fls/::konten
packages/fls/::laden
# Property names of Java FILIUS's Datei (dateiTyp, dateiInhalt) and Kontakt
# (hausnr), as Java's XMLDecoder and the Java oracle name them.
packages/fls/::datei
packages/fls/::hausnr
packages/fls/::typ
packages/fls/::inhalt
+10
View File
@@ -85,6 +85,16 @@ wird leer, der Eintrag bleibt trotzdem; Typen außer A/MX/NS (auch `a`,
`CNAME`, eine Kommentarzeile) gelten als gültig; eine TTL, die kein `int`
ist, wird still 3600.
## Was Java aus unserem XML macht
`BeansDump` liest jede `*.xml` unter `packages/fls/test/beans/` mit
`XMLDecoder` und beschreibt, was dabei herauskommt (Beans über ihre
Eigenschaften mit Lese- und Schreibmethode, Listen, Maps, Arrays,
`Rectangle`, `Color`; ein zweites Mal gesehenes Objekt als Verweis) —
`beans.java.json`. Fehler, die der Decoder sonst nur ins Log schreibt,
sammelt ein `ExceptionListener`. Zeichenketten stehen als ASCII mit
`\uXXXX`, weil ein einsames Surrogat in UTF-8 sonst zu „?“ würde.
## E-Mail-Kontodateien
`EmailFileCases server|client` legt für jede Zeile von
+7 -1
View File
@@ -21,7 +21,7 @@ if [ ! -f "$upstream/target/filius.jar" ]; then
fi
classpath="$upstream/target/filius.jar:$upstream/target/lib/*"
mkdir -p "$here/.build"
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java"
javac -encoding UTF-8 -d "$here/.build" -cp "$classpath" "$here/src/FlsOracle.java" "$here/src/GetterDefaults.java" "$here/src/IpCheckCases.java" "$here/src/DnsHostsCases.java" "$here/src/EmailFileCases.java" "$here/src/BeansDump.java"
# FILIUS writes its settings below $HOME; keep them out of the real one.
mkdir -p "$here/.build/home"
# Removed first: a crashed run must not leave the previous result standing.
@@ -82,3 +82,9 @@ for kind in server client; do
HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" EmailFileCases "$kind" "$cases" "$output" 2>&1 | grep -v 'logback\|^$' || true
test -s "$output"
done
# What XMLDecoder makes of the files packages/fls/src/javaBeansWriter.ts writes.
beans="$root/packages/fls/test/beans"
rm -f "$beans/beans.java.json"
HOME="$here/.build/home" xvfb-run -a java -cp "$classpath:$here/.build" BeansDump "$beans" "$beans/beans.java.json" 2>&1 | grep -v 'logback\|^$' || true
test -s "$beans/beans.java.json"
+141
View File
@@ -0,0 +1,141 @@
// Reads every *.xml of a directory with java.beans.XMLDecoder and describes
// the objects it gets — the check that Java reads what
// packages/fls/src/javaBeansWriter.ts writes. Errors the decoder would only
// log are collected; strings are written escaped (see escaped). Beans are described by their read-write properties
// (java.beans.Introspector), lists, maps, arrays, Rectangle and Color by
// their content; a value met again is a reference to its first description.
//
// Usage: java -cp filius.jar:lib/*:classes BeansDump <directory> <output json>
import java.awt.Color;
import java.awt.Rectangle;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.beans.XMLDecoder;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.lang.reflect.Array;
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.Collection;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Stream;
public class BeansDump {
public static void main(String[] args) throws Exception {
Map<String, Object> result = new TreeMap<>();
List<Path> files;
try (Stream<Path> list = Files.list(Path.of(args[0]))) {
files = list.filter(file -> file.toString().endsWith(".xml")).sorted().toList();
}
for (Path file : files) {
List<Object> errors = new ArrayList<>();
List<Object> values = new ArrayList<>();
try (XMLDecoder decoder = new XMLDecoder(new FileInputStream(file.toFile()), null,
error -> errors.add(error.getClass().getSimpleName() + ": " + error.getMessage()))) {
BeansDump dump = new BeansDump();
while (true) {
Object value;
try {
value = decoder.readObject();
} catch (ArrayIndexOutOfBoundsException end) {
break;
}
values.add(dump.describe(value, 0));
}
}
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("values", values);
entry.put("errors", errors);
result.put(file.getFileName().toString(), entry);
}
try (FileOutputStream out = new FileOutputStream(args[1])) {
out.write((FlsOracle.Json.write(result, "") + "\n").getBytes(StandardCharsets.UTF_8));
}
System.out.println("beans dump: " + files.size() + " files");
System.exit(0);
}
private final IdentityHashMap<Object, Integer> seen = new IdentityHashMap<>();
Object describe(Object value, int depth) throws Exception {
if (value == null) return null;
if (value instanceof Boolean flag) return flag;
if (value instanceof String text) return tagged("string", escaped(text));
if (value instanceof Integer number) return tagged("int", number);
if (value instanceof Short number) return tagged("short", (int) number);
if (value instanceof Byte number) return tagged("byte", (int) number);
if (value instanceof Long number) return tagged("long", number.toString());
if (value instanceof Double || value instanceof Float) return tagged(value.getClass().getSimpleName().toLowerCase(), value.toString());
if (value instanceof Character character) return tagged("char", (int) character.charValue());
if (value instanceof Class<?> type) return tagged("class", type.getName());
if (value instanceof Rectangle rectangle) {
return tagged("rectangle", List.of(rectangle.x, rectangle.y, rectangle.width, rectangle.height));
}
if (value instanceof Color color) {
return tagged("color", List.of(color.getRed(), color.getGreen(), color.getBlue(), color.getAlpha()));
}
Integer reference = seen.get(value);
if (reference != null) return tagged("ref", reference);
int id = seen.size();
seen.put(value, id);
Map<String, Object> json = new LinkedHashMap<>();
json.put("id", id);
json.put("class", value.getClass().getName());
if (value.getClass().isArray()) {
List<Object> items = new ArrayList<>();
for (int index = 0; index < Array.getLength(value); index++) items.add(describe(Array.get(value, index), depth + 1));
json.put("items", items);
} else if (value instanceof Collection<?> collection) {
List<Object> items = new ArrayList<>();
for (Object item : collection) items.add(describe(item, depth + 1));
json.put("items", items);
} else if (value instanceof Map<?, ?> map) {
List<Object> entries = new ArrayList<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
entries.add(Arrays.asList(describe(entry.getKey(), depth + 1), describe(entry.getValue(), depth + 1)));
}
json.put("entries", entries);
} else if (depth < 8) {
Map<String, Object> properties = new TreeMap<>();
for (PropertyDescriptor property : Introspector.getBeanInfo(value.getClass()).getPropertyDescriptors()) {
if (property.getReadMethod() == null || property.getWriteMethod() == null) continue;
try {
properties.put(property.getName(), describe(property.getReadMethod().invoke(value), depth + 1));
} catch (Exception error) {
properties.put(property.getName(), tagged("error", error.getClass().getSimpleName()));
}
}
json.put("properties", properties);
}
return json;
}
/**
* Every UTF-16 unit outside printable ASCII, and the backslash, as
* backslash-u and four hex digits, so that no encoding step can change
* a unit (a lone surrogate would become "?" in UTF-8).
*/
static String escaped(String text) {
StringBuilder out = new StringBuilder();
for (char unit : text.toCharArray()) {
if (unit < 0x20 || unit > 0x7E || unit == '\\') out.append(String.format("\\u%04X", (int) unit));
else out.append(unit);
}
return out.toString();
}
private static Map<String, Object> tagged(String tag, Object value) {
Map<String, Object> json = new LinkedHashMap<>();
json.put(tag, value);
return json;
}
}