From 3795316faeb63b4716e8ff3f80bc13134bb161a0 Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Wed, 30 Sep 2026 07:50:01 +0000 Subject: [PATCH] feat(fls): a writer for the XMLDecoder format, checked by Java MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 . 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 --- CHANGES.md | 2 + packages/fls/src/javaBeansWriter.ts | 292 +++++++++++++++ packages/fls/test/beans/arrays.xml | 22 ++ packages/fls/test/beans/awt.xml | 15 + packages/fls/test/beans/beans.java.json | 411 ++++++++++++++++++++++ packages/fls/test/beans/beans.xml | 65 ++++ packages/fls/test/beans/collections.xml | 25 ++ packages/fls/test/beans/constants.xml | 4 + packages/fls/test/beans/indexed.xml | 23 ++ packages/fls/test/beans/numbers.xml | 21 ++ packages/fls/test/beans/shared.xml | 38 ++ packages/fls/test/beans/strings.xml | 55 +++ packages/fls/test/javaBeansWriter.test.ts | 193 ++++++++++ scripts/code_language_allow.txt | 6 + tools/java-oracle/README.md | 10 + tools/java-oracle/run.sh | 8 +- tools/java-oracle/src/BeansDump.java | 141 ++++++++ 17 files changed, 1330 insertions(+), 1 deletion(-) create mode 100644 packages/fls/src/javaBeansWriter.ts create mode 100644 packages/fls/test/beans/arrays.xml create mode 100644 packages/fls/test/beans/awt.xml create mode 100644 packages/fls/test/beans/beans.java.json create mode 100644 packages/fls/test/beans/beans.xml create mode 100644 packages/fls/test/beans/collections.xml create mode 100644 packages/fls/test/beans/constants.xml create mode 100644 packages/fls/test/beans/indexed.xml create mode 100644 packages/fls/test/beans/numbers.xml create mode 100644 packages/fls/test/beans/shared.xml create mode 100644 packages/fls/test/beans/strings.xml create mode 100644 packages/fls/test/javaBeansWriter.test.ts create mode 100644 tools/java-oracle/src/BeansDump.java diff --git a/CHANGES.md b/CHANGES.md index 55eba05..451d713 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -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 ``; 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 | diff --git a/packages/fls/src/javaBeansWriter.ts b/packages/fls/src/javaBeansWriter.ts new file mode 100644 index 0000000..3d0bc25 --- /dev/null +++ b/packages/fls/src/javaBeansWriter.ts @@ -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 = ['', '']; + for (const value of values) writer.value(value, 1, lines); + lines.push('', ''); + return lines.join('\n'); +} + +class Writer { + private readonly ids = new Map(); + private readonly counters = new Map(); + /** Objects written more than once need an id; found before writing. */ + constructor(private readonly shared: ReadonlySet) {} + + value(value: BeanValue, depth: number, lines: string[]): void { + const indent = ' '.repeat(depth); + if (value === null) { + lines.push(`${indent}`); + } else if (typeof value === 'string') { + lines.push(`${indent}${stringContent(value)}`); + } else if (typeof value === 'boolean') { + lines.push(`${indent}${value}`); + } else { + switch (value.kind) { + case 'int': + case 'short': + case 'byte': + lines.push(`${indent}<${value.kind}>${integerText(value.value)}`); + break; + case 'long': + lines.push(`${indent}${typeof value.value === 'bigint' ? value.value : integerText(value.value)}`); + break; + case 'float': + case 'double': + lines.push(`${indent}<${value.kind}>${floatingText(value.value)}`); + break; + case 'char': + lines.push(`${indent}${charElement(value.value)}`); + break; + case 'class': + lines.push(`${indent}${escapeText(value.name)}`); + 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}`); + return; + } + const id = this.idFor(value, simpleName(value.className)); + const idAttribute = id === undefined ? '' : ` id="${id}"`; + if (value.staticField !== undefined) { + lines.push(`${indent}`); + return; + } + const args = value.args ?? []; + const statements = value.statements ?? []; + if (args.length === 0 && statements.length === 0) { + lines.push(`${indent}`); + return; + } + lines.push(`${indent}`); + 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}`); + } + + 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}`); + return; + } + const id = this.idFor(value, `${simpleName(value.componentClass)}Array`); + const idAttribute = id === undefined ? '' : ` id="${id}"`; + const head = `${indent}`); + return; + } + lines.push(`${head}>`); + for (const [index, item] of value.items.entries()) { + lines.push(`${indent} `); + this.value(item, depth + 2, lines); + lines.push(`${indent} `); + } + lines.push(`${indent}`); + } + + 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}`); + }; + if ('set' in statement) { + nested(` this.value(statement.value, depth + 1, lines)); + } else if ('get' in statement) { + if (statement.statements.length === 0) return; + nested(` { + 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}`); + return; + } + nested(` { + for (const arg of statement.args) this.value(arg, depth + 1, lines); + }); + } else if ('setIndex' in statement) { + nested(` this.value(statement.value, depth + 1, lines)); + } else { + if (statement.statements.length === 0) return; + nested(` { + 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 { + const seen = new Set(); + const shared = new Set(); + 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 ``, 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) ? `` : `${escapeText(value)}`; +} + +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 ``; +} + +function escapeText(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>'); +} + +function escapeAttribute(text: string): string { + return escapeText(text).replace(/"/g, '"'); +} diff --git a/packages/fls/test/beans/arrays.xml b/packages/fls/test/beans/arrays.xml new file mode 100644 index 0000000..cac8fb3 --- /dev/null +++ b/packages/fls/test/beans/arrays.xml @@ -0,0 +1,22 @@ + + + + + a + + + + + + c + + + + + 0 + + + 5 + + + diff --git a/packages/fls/test/beans/awt.xml b/packages/fls/test/beans/awt.xml new file mode 100644 index 0000000..3313758 --- /dev/null +++ b/packages/fls/test/beans/awt.xml @@ -0,0 +1,15 @@ + + + + 1 + 2 + 3 + 4 + + + 10 + 20 + 30 + 40 + + diff --git a/packages/fls/test/beans/beans.java.json b/packages/fls/test/beans/beans.java.json new file mode 100644 index 0000000..cb763bc --- /dev/null +++ b/packages/fls/test/beans/beans.java.json @@ -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": [] + } +} diff --git a/packages/fls/test/beans/beans.xml b/packages/fls/test/beans/beans.xml new file mode 100644 index 0000000..9a5043d --- /dev/null +++ b/packages/fls/test/beans/beans.xml @@ -0,0 +1,65 @@ + + + + + hosts + + + text + + + a.de. A 3600 1.2.3.4 + + + + + + bob + + + + + + + + + + + + + + Alice + + + a@x + + + + + + + + b@y + + + + + + Hi + + + false + + + + + + + + 12 + + + Lang + + + diff --git a/packages/fls/test/beans/collections.xml b/packages/fls/test/beans/collections.xml new file mode 100644 index 0000000..c2f586e --- /dev/null +++ b/packages/fls/test/beans/collections.xml @@ -0,0 +1,25 @@ + + + + + 1 + + + + + nested + + + + + + + key + 7 + + + key + 8 + + + diff --git a/packages/fls/test/beans/constants.xml b/packages/fls/test/beans/constants.xml new file mode 100644 index 0000000..3846c84 --- /dev/null +++ b/packages/fls/test/beans/constants.xml @@ -0,0 +1,4 @@ + + + + diff --git a/packages/fls/test/beans/indexed.xml b/packages/fls/test/beans/indexed.xml new file mode 100644 index 0000000..c3105fb --- /dev/null +++ b/packages/fls/test/beans/indexed.xml @@ -0,0 +1,23 @@ + + + + + + + first + + + + + + renamed + + + + second + + + replaced + + + diff --git a/packages/fls/test/beans/numbers.xml b/packages/fls/test/beans/numbers.xml new file mode 100644 index 0000000..2828a68 --- /dev/null +++ b/packages/fls/test/beans/numbers.xml @@ -0,0 +1,21 @@ + + + -1 + 2147483647 + -9007199254740991 + 9223372036854775807 + 0.1 + -0.0 + 1e+300 + NaN + -Infinity + 1.5 + -3 + 127 + x + + true + false + + java.lang.String + diff --git a/packages/fls/test/beans/shared.xml b/packages/fls/test/beans/shared.xml new file mode 100644 index 0000000..deedcb5 --- /dev/null +++ b/packages/fls/test/beans/shared.xml @@ -0,0 +1,38 @@ + + + + + + + shared + + + + + + + other + + + + + + + + + + + + + hosts + + + text + + + a.de. A 3600 1.2.3.4 + + + + + diff --git a/packages/fls/test/beans/strings.xml b/packages/fls/test/beans/strings.xml new file mode 100644 index 0000000..25b978d --- /dev/null +++ b/packages/fls/test/beans/strings.xml @@ -0,0 +1,55 @@ + + + + + plain + + + + + + spaced + + + <&>"' + + + ]]> + + + tab here + + + line +break + + + crhere + + + crlf +here + + +  + + + …

 + + + bom + + + Grüße 😀 + + + lone high + + + lone low + + + + + + diff --git a/packages/fls/test/javaBeansWriter.test.ts b/packages/fls/test/javaBeansWriter.test.ts new file mode 100644 index 0000000..56590a8 --- /dev/null +++ b/packages/fls/test/javaBeansWriter.test.ts @@ -0,0 +1,193 @@ +// The XMLDecoder format as javaBeansWriter.ts writes it, read by Java +// itself: every case is written to test/beans/.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) => ({ 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 = { + 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; + const have = actual as Record; + 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) + : {}; + +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] })) }), + ]); + }); +}); diff --git a/scripts/code_language_allow.txt b/scripts/code_language_allow.txt index 97c759f..bc91a02 100644 --- a/scripts/code_language_allow.txt +++ b/scripts/code_language_allow.txt @@ -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 diff --git a/tools/java-oracle/README.md b/tools/java-oracle/README.md index 770295a..c27b55a 100644 --- a/tools/java-oracle/README.md +++ b/tools/java-oracle/README.md @@ -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 diff --git a/tools/java-oracle/run.sh b/tools/java-oracle/run.sh index 892673f..089ebc7 100755 --- a/tools/java-oracle/run.sh +++ b/tools/java-oracle/run.sh @@ -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" diff --git a/tools/java-oracle/src/BeansDump.java b/tools/java-oracle/src/BeansDump.java new file mode 100644 index 0000000..c319087 --- /dev/null +++ b/tools/java-oracle/src/BeansDump.java @@ -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 + +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 result = new TreeMap<>(); + List files; + try (Stream list = Files.list(Path.of(args[0]))) { + files = list.filter(file -> file.toString().endsWith(".xml")).sorted().toList(); + } + for (Path file : files) { + List errors = new ArrayList<>(); + List 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 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 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 json = new LinkedHashMap<>(); + json.put("id", id); + json.put("class", value.getClass().getName()); + if (value.getClass().isArray()) { + List 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 items = new ArrayList<>(); + for (Object item : collection) items.add(describe(item, depth + 1)); + json.put("items", items); + } else if (value instanceof Map map) { + List 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 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 tagged(String tag, Object value) { + Map json = new LinkedHashMap<>(); + json.put(tag, value); + return json; + } +}