topologyWriter.ts now sets the system software each device's constructor makes, through its setters: a switch's SSID and learning time, a host's SSID, RIP, manual routes, DHCP client and server with static assignments, static NAT, the modem, and a router's or home router's firewall. The firewall is reached as XMLEncoder reaches it, through the web server's `konfig` plug-in: the constructor hangs the same object there and among the applications, where its key differs (Firewall on a router, NatGateway on a home router). A switch's SSID the file lacked stays unwritten, so Java rolls one as it did. Files and applications are later layers. javaBeansWriter.ts: a call can carry statements on its result (a map's value changed through get(key)), checked by Java's XMLDecoder in a new case; a short() helper for Java's short values. Checked by Java against Java: every corpus file, read and written again, loads with the same system software (without files and applications) as the original. The synthetic topology adds what the corpus lacks: a router firewall switched on with rules of every protocol (one with a null address), every flag away from its default, a modem with its own port and target, and a PC's SSID, routes and DHCP server. 16 mutation probes, each rewritten and loaded by Java, were all caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
342 lines
13 KiB
TypeScript
342 lines
13 KiB
TypeScript
// 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 }
|
|
| BeanReference;
|
|
|
|
/**
|
|
* An object only Java makes — a getter's result, such as the port a network
|
|
* interface's constructor creates — named where the getter is called
|
|
* (`<void id="Port0" property="port"/>`) and referenced after.
|
|
*/
|
|
export interface BeanReference {
|
|
readonly kind: 'reference';
|
|
readonly name: string;
|
|
}
|
|
|
|
export function reference(name: string): BeanReference {
|
|
return { kind: 'reference', name };
|
|
}
|
|
|
|
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; `as` names the result */
|
|
| { readonly get: string; readonly statements: readonly BeanStatement[]; readonly as?: BeanReference }
|
|
/** target.m(args), then the statements on its result */
|
|
| { readonly call: string; readonly args: readonly BeanValue[]; readonly statements?: readonly BeanStatement[] }
|
|
/** target.set(index, value) on a list */
|
|
| { readonly setIndex: number; readonly value: BeanValue }
|
|
/** target.get(index), then the statements on its result; `as` names the result */
|
|
| { readonly getIndex: number; readonly statements: readonly BeanStatement[]; readonly as?: BeanReference };
|
|
|
|
export const int = (value: number): BeanPrimitive => ({ kind: 'int', value });
|
|
export const short = (value: number): BeanPrimitive => ({ kind: 'short', 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 | BeanReference, 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;
|
|
case 'reference': {
|
|
const id = this.ids.get(value);
|
|
if (id === undefined) throw new Error(`reference '${value.name}' used before the getter that names it`);
|
|
lines.push(`${indent}<object idref="${id}"/>`);
|
|
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) {
|
|
this.getter(`property="${escapeAttribute(statement.get)}"`, statement.statements, statement.as, depth, lines);
|
|
} else if ('call' in statement) {
|
|
const inner = statement.statements ?? [];
|
|
if (statement.args.length === 0 && inner.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);
|
|
for (const each of inner) this.statement(each, depth + 1, lines);
|
|
});
|
|
} else if ('setIndex' in statement) {
|
|
nested(`<void index="${statement.setIndex}"`, () => this.value(statement.value, depth + 1, lines));
|
|
} else {
|
|
this.getter(`index="${statement.getIndex}"`, statement.statements, statement.as, depth, lines);
|
|
}
|
|
}
|
|
|
|
/** A getter's call: left out when nothing happens to its result and nothing names it. */
|
|
private getter(
|
|
attribute: string,
|
|
statements: readonly BeanStatement[],
|
|
name: BeanReference | undefined,
|
|
depth: number,
|
|
lines: string[],
|
|
): void {
|
|
const indent = ' '.repeat(depth);
|
|
if (statements.length === 0 && name === undefined) return;
|
|
let idAttribute = '';
|
|
if (name !== undefined) {
|
|
if (this.ids.has(name)) throw new Error(`reference '${name.name}' named twice`);
|
|
const count = this.counters.get(name.name) ?? 0;
|
|
this.counters.set(name.name, count + 1);
|
|
const id = `${name.name}${count}`;
|
|
this.ids.set(name, id);
|
|
idAttribute = ` id="${id}"`;
|
|
}
|
|
if (statements.length === 0) {
|
|
lines.push(`${indent}<void${idAttribute} ${attribute}/>`);
|
|
return;
|
|
}
|
|
lines.push(`${indent}<void${idAttribute} ${attribute}>`);
|
|
for (const inner of statements) this.statement(inner, depth + 1, lines);
|
|
lines.push(`${indent}</void>`);
|
|
}
|
|
|
|
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);
|
|
(statement.statements ?? []).forEach(visitStatement);
|
|
}
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function escapeAttribute(text: string): string {
|
|
return escapeText(text).replace(/"/g, '"');
|
|
}
|