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; + } +}