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>
223 lines
9.8 KiB
TypeScript
223 lines
9.8 KiB
TypeScript
// The XMLDecoder format as javaBeansWriter.ts writes it, read by Java
|
|
// itself: every case is written to test/beans/<name>.xml (UPDATE_BEAN_CASES=1
|
|
// rewrites them), tools/java-oracle BeansDump reads each with XMLDecoder and
|
|
// describes what it got (beans.java.json). The descriptions must match the
|
|
// expectations written here by hand, and Java must report no error.
|
|
|
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { decodeJavaBeans } from '../src/javabeans.js';
|
|
import {
|
|
double,
|
|
encodeJavaBeans,
|
|
float,
|
|
int,
|
|
long,
|
|
object,
|
|
reference,
|
|
type BeanObject,
|
|
type BeanValue,
|
|
} from '../src/javaBeansWriter.js';
|
|
|
|
const directory = join(dirname(fileURLToPath(import.meta.url)), 'beans');
|
|
|
|
const list = (className: string, items: BeanValue[]): BeanObject =>
|
|
object(className, items.map((item) => ({ call: 'add', args: [item] })));
|
|
/** BeansDump's escaping: every unit outside printable ASCII, and the backslash, as backslash-u and four hex digits. */
|
|
const text = (value: string) => ({
|
|
string: value.replace(/[^\x20-\x7E]|\\/g, (unit) => `\\u${unit.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}`),
|
|
});
|
|
const bean = (className: string, properties: Record<string, unknown>) => ({ class: className, properties });
|
|
const items = (className: string, values: unknown[]) => ({ class: className, items: values });
|
|
|
|
const STRINGS = ['plain', '', ' spaced ', '<&>"\'', ']]>', 'tab\there', 'line\nbreak', 'cr\rhere', 'crlf\r\nhere',
|
|
'\u0000\u0001\u001F\u007F', '\u0085\u2028\u2029', '\uFEFFbom', 'Grüße 😀', 'lone \uD800 high', 'lone \uDC00 low', '\uFFFE\uFFFF'];
|
|
|
|
const inner = list('java.util.ArrayList', ['shared']);
|
|
const other = list('java.util.ArrayList', ['other']);
|
|
const file = object('filius.software.system.Datei', [
|
|
{ set: 'name', value: 'hosts' },
|
|
{ set: 'dateiTyp', value: 'text' },
|
|
{ set: 'dateiInhalt', value: 'a.de. A 3600 1.2.3.4\n' },
|
|
]);
|
|
|
|
const port = reference('Port');
|
|
|
|
const CASES: Record<string, { values: BeanValue[]; expected: unknown[] }> = {
|
|
references: {
|
|
values: [
|
|
object('java.util.LinkedList', [
|
|
{ call: 'add', args: [object('filius.hardware.NetzwerkInterface', [{ set: 'ip', value: '10.0.0.1' }, { get: 'port', statements: [], as: port }])] },
|
|
{ call: 'add', args: [port] },
|
|
]),
|
|
],
|
|
expected: [
|
|
items('java.util.LinkedList', [
|
|
bean('filius.hardware.NetzwerkInterface', { ip: text('10.0.0.1'), port: { id: 2, class: 'filius.hardware.Port' } }),
|
|
{ ref: 2 },
|
|
]),
|
|
],
|
|
},
|
|
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 }]] },
|
|
],
|
|
},
|
|
// A method's result changed by the statements inside its call: a map's
|
|
// value reached through get(key), as XMLEncoder reaches what a
|
|
// constructor put into a map.
|
|
results: {
|
|
values: [
|
|
object('java.util.HashMap', [
|
|
{ call: 'put', args: ['key', list('java.util.ArrayList', ['first'])] },
|
|
{ call: 'get', args: ['key'], statements: [{ call: 'add', args: ['second'] }] },
|
|
]),
|
|
],
|
|
expected: [{ class: 'java.util.HashMap', entries: [[text('key'), items('java.util.ArrayList', [text('first'), text('second')])]] }],
|
|
},
|
|
shared: {
|
|
values: [list('java.util.LinkedList', [inner, other, inner, other]), file, file],
|
|
expected: [
|
|
{
|
|
id: 0,
|
|
class: 'java.util.LinkedList',
|
|
items: [items('java.util.ArrayList', [text('shared')]), items('java.util.ArrayList', [text('other')]), { ref: 1 }, { ref: 2 }],
|
|
},
|
|
{ id: 3, class: 'filius.software.system.Datei' },
|
|
{ ref: 3 },
|
|
],
|
|
},
|
|
awt: {
|
|
values: [object('java.awt.Rectangle', [], [int(1), int(2), int(3), int(4)]), object('java.awt.Color', [], [int(10), int(20), int(30), int(40)])],
|
|
expected: [{ rectangle: [1, 2, 3, 4] }, { color: [10, 20, 30, 40] }],
|
|
},
|
|
beans: {
|
|
values: [
|
|
file,
|
|
object('filius.software.email.EmailKonto', [
|
|
{ set: 'benutzername', value: 'bob' },
|
|
{ set: 'passwort', value: null },
|
|
{ set: 'nachname', value: null },
|
|
{
|
|
get: 'nachrichten',
|
|
statements: [
|
|
{
|
|
call: 'add',
|
|
args: [
|
|
object('filius.software.email.Email', [
|
|
{ set: 'absender', value: object('filius.software.email.AddressEntry', [{ set: 'name', value: 'Alice' }, { set: 'mailAddress', value: 'a@x' }]) },
|
|
{ get: 'empfaenger', statements: [{ call: 'add', args: [object('filius.software.email.AddressEntry', [{ set: 'mailAddress', value: 'b@y' }])] }] },
|
|
{ set: 'betreff', value: 'Hi' },
|
|
{ set: 'neu', value: false },
|
|
]),
|
|
],
|
|
},
|
|
],
|
|
},
|
|
]),
|
|
object('filius.software.email.Kontakt', [{ set: 'hausnr', value: int(12) }, { set: 'name', value: 'Lang' }]),
|
|
],
|
|
expected: [
|
|
bean('filius.software.system.Datei', { name: text('hosts'), dateiTyp: text('text'), dateiInhalt: text('a.de. A 3600 1.2.3.4\n') }),
|
|
bean('filius.software.email.EmailKonto', {
|
|
benutzername: text('bob'),
|
|
passwort: null,
|
|
// setNachname keeps "" for null.
|
|
nachname: text(''),
|
|
nachrichten: items('java.util.LinkedList', [
|
|
bean('filius.software.email.Email', {
|
|
betreff: text('Hi'),
|
|
neu: false,
|
|
empfaenger: items('java.util.ArrayList', [bean('filius.software.email.AddressEntry', { name: null, mailAddress: text('b@y') })]),
|
|
}),
|
|
]),
|
|
}),
|
|
bean('filius.software.email.Kontakt', { hausnr: { int: 12 }, name: text('Lang') }),
|
|
],
|
|
},
|
|
arrays: {
|
|
values: [{ kind: 'array', componentClass: 'java.lang.String', items: ['a', null, 'c'] }, { kind: 'array', componentClass: 'int', items: [int(0), int(5)] }],
|
|
expected: [items('[Ljava.lang.String;', [text('a'), null, text('c')]), items('[I', [{ int: 0 }, { int: 5 }])],
|
|
},
|
|
indexed: {
|
|
values: [object('java.util.LinkedList', [
|
|
{ call: 'add', args: [object('filius.software.system.Datei', [{ set: 'name', value: 'first' }])] },
|
|
{ getIndex: 0, statements: [{ set: 'name', value: 'renamed' }] },
|
|
{ call: 'add', args: ['second'] },
|
|
{ setIndex: 1, value: 'replaced' },
|
|
])],
|
|
expected: [items('java.util.LinkedList', [bean('filius.software.system.Datei', { name: text('renamed') }), text('replaced')])],
|
|
},
|
|
constants: {
|
|
values: [{ kind: 'object', className: 'javax.swing.SwingConstants', staticField: 'CENTER' }],
|
|
expected: [{ int: 0 }],
|
|
},
|
|
};
|
|
|
|
/** Whether Java's description has everything expected: listed properties only, numbers by value, ids only where given. */
|
|
function matches(actual: unknown, expected: unknown): boolean {
|
|
if (expected === null || typeof expected !== 'object') return Object.is(actual, expected);
|
|
if (actual === null || typeof actual !== 'object') return false;
|
|
const want = expected as Record<string, unknown>;
|
|
const have = actual as Record<string, unknown>;
|
|
for (const [key, value] of Object.entries(want)) {
|
|
if ((key === 'double' || key === 'float') && typeof have[key] === 'string') {
|
|
if (!Object.is(Number(have[key]), value)) return false;
|
|
continue;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
const list = have[key];
|
|
if (!Array.isArray(list) || list.length !== value.length || !value.every((item, index) => matches(list[index], item))) return false;
|
|
continue;
|
|
}
|
|
if (!(key in have) || !matches(have[key], value)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const javaDump = existsSync(join(directory, 'beans.java.json'))
|
|
? (JSON.parse(readFileSync(join(directory, 'beans.java.json'), 'utf8')) as Record<string, { values: unknown[]; errors: string[] }>)
|
|
: {};
|
|
|
|
describe('javaBeansWriter, read by Java', () => {
|
|
for (const [name, { values, expected }] of Object.entries(CASES)) {
|
|
it(name, () => {
|
|
const path = join(directory, `${name}.xml`);
|
|
const xml = encodeJavaBeans(values);
|
|
if (process.env.UPDATE_BEAN_CASES === '1') writeFileSync(path, xml);
|
|
expect(readFileSync(path, 'utf8')).toBe(xml);
|
|
const java = javaDump[`${name}.xml`];
|
|
expect(java?.errors).toEqual([]);
|
|
expect(java!.values).toHaveLength(expected.length);
|
|
java!.values.forEach((value, index) => {
|
|
expect(matches(value, expected[index]), `value ${index}: ${JSON.stringify(value)}`).toBe(true);
|
|
});
|
|
});
|
|
}
|
|
|
|
it('reads back with the reader of this package', () => {
|
|
expect(decodeJavaBeans(encodeJavaBeans(CASES.strings!.values))).toEqual([
|
|
expect.objectContaining({ calls: STRINGS.map((value) => ({ method: 'add', args: [value] })) }),
|
|
]);
|
|
});
|
|
});
|