From 8080e7dcb255c18d218590701f985dfcd2682157 Mon Sep 17 00:00:00 2001 From: Julian Kiedaisch Date: Wed, 30 Sep 2026 06:12:14 +0000 Subject: [PATCH] fix(fls): Gnutella's peer limit is random in Java, not a default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PeerToPeerAnwendung's constructor rolls its peer limit (3, 4 or 5). The getter defaults measured it once each run — 4, then 5 — and c48d15d committed 5 as a default. GetterDefaults now constructs every class 64 times and writes a value that varies as {"random": [...]}; the decoder answers a reference to it as unresolved, and the topology layer reports a peer limit the file does not set as null. Co-Authored-By: Claude Opus 5.5 --- packages/fls/src/javaDefaults.json | 8 +++++- packages/fls/src/javabeans.ts | 30 ++++++++++++++++++----- packages/fls/src/systemSoftware.ts | 10 +++++--- packages/fls/test/javabeans.test.ts | 11 +++++++++ packages/fls/test/topology.test.ts | 3 ++- tools/java-oracle/src/GetterDefaults.java | 20 ++++++++++----- 6 files changed, 65 insertions(+), 17 deletions(-) diff --git a/packages/fls/src/javaDefaults.json b/packages/fls/src/javaDefaults.json index e227673..9eb82e8 100644 --- a/packages/fls/src/javaDefaults.json +++ b/packages/fls/src/javaDefaults.json @@ -11,7 +11,13 @@ "JSidebarButton#iconTextGap": 4, "JSidebarButton#verticalTextPosition": 3, "Notebook#maxLayerOfOperation": 4, - "PeerToPeerAnwendung#maxTeilnehmerZahl": 5, + "PeerToPeerAnwendung#maxTeilnehmerZahl": { + "random": [ + "3", + "4", + "5" + ] + }, "RIPServer#port": 520, "Rechner#maxLayerOfOperation": 4, "ServerBaustein#aktiv": false, diff --git a/packages/fls/src/javabeans.ts b/packages/fls/src/javabeans.ts index c1e1703..f517831 100644 --- a/packages/fls/src/javabeans.ts +++ b/packages/fls/src/javabeans.ts @@ -217,15 +217,30 @@ export function decodeXmlBytes(bytes: Uint8Array): string { // ── Decoder ──────────────────────────────────────────────────────────────── const PRIMITIVE_ID_PREFIXES = new Set(['Integer', 'Boolean', 'Long', 'Short', 'Byte', 'Float', 'Double', 'Character']); -const DEFAULTS = javaDefaults as Readonly>; +type MeasuredValue = string | number | boolean | null; +/** A value a constructor rolls at random: measured as the set of values seen, never a default. */ +type RandomValue = { random: string[] }; +const MEASURED = javaDefaults as Readonly>; + +/** The measured constant under `key`; undefined if nothing was measured or Java rolls it. */ +function measuredConstant(key: string): MeasuredValue | undefined { + if (!Object.hasOwn(MEASURED, key)) return undefined; + const value = MEASURED[key]!; + return typeof value === 'object' && value !== null ? undefined : value; +} + +function isRandomInJava(key: string): boolean { + const value = MEASURED[key]; + return typeof value === 'object' && value !== null; +} /** * What a getter returns on a freshly constructed object of `simpleClass`, as - * measured (javaDefaults.json) — undefined where nothing was measured. + * measured (javaDefaults.json) — undefined where nothing was measured or + * where Java's constructor rolls the value. */ export function javaDefault(simpleClass: string, property: string): string | number | boolean | null | undefined { - const key = `${simpleClass}#${property}`; - return Object.hasOwn(DEFAULTS, key) ? DEFAULTS[key] : undefined; + return measuredConstant(`${simpleClass}#${property}`); } /** `getFoo`/`isFoo` → `foo`, as java.beans.Introspector derives it. */ @@ -356,7 +371,8 @@ class Decoder { // A static constant: XMLEncoder writes a shared Integer 0 as // SwingConstants.CENTER when that was the first 0 it met. const constantKey = `${simpleClassName(className)}.${attributes.field}`; - if (Object.hasOwn(DEFAULTS, constantKey)) return this.primitive(element, DEFAULTS[constantKey]!); + const constant = measuredConstant(constantKey); + if (constant !== undefined) return this.primitive(element, constant); } const args = element.children.filter((child) => child.name !== 'void'); const object = newObject({ @@ -457,7 +473,9 @@ class Decoder { } if (target.properties.has(property)) return target.properties.get(property)!; const defaultKey = `${simpleClassName(target.className)}#${property}`; - if (Object.hasOwn(DEFAULTS, defaultKey)) return DEFAULTS[defaultKey]!; + const measured = measuredConstant(defaultKey); + if (measured !== undefined) return measured; + if (isRandomInJava(defaultKey)) return { kind: 'unresolved', reason: `${defaultKey} is random in Java` }; const prefix = id?.replace(/\d+$/, ''); if (prefix !== undefined && PRIMITIVE_ID_PREFIXES.has(prefix)) { return { kind: 'unresolved', reason: `no measured default for ${defaultKey}` }; diff --git a/packages/fls/src/systemSoftware.ts b/packages/fls/src/systemSoftware.ts index 50f9c4b..ca72d58 100644 --- a/packages/fls/src/systemSoftware.ts +++ b/packages/fls/src/systemSoftware.ts @@ -129,8 +129,11 @@ export interface FlsApplication { recursiveResolutionEnabled?: boolean; /** WebServer: serve by host name (Java's useVHost). */ useVirtualHosts?: boolean; - /** PeerToPeerAnwendung: how many peers it keeps (Java's maxTeilnehmerZahl). */ - maxPeers?: number; + /** + * PeerToPeerAnwendung: how many peers it keeps (Java's maxTeilnehmerZahl); + * null when the file has none — Java's constructor rolls 3, 4 or 5. + */ + maxPeers?: number | null; /** Firewall: the personal firewall. */ firewall?: FlsFirewall; /** The application's JavaBean, for the e-mail layer. */ @@ -269,7 +272,8 @@ function applications(map: JavaValue | undefined, warn: Warn): FlsApplication[] entry.useVirtualHosts = boolean(at('useVHost'), measured('useVHost') === true, 'WebServer useVHost', warn); break; case 'filius.software.dateiaustausch.PeerToPeerAnwendung': - entry.maxPeers = integer(at('maxTeilnehmerZahl'), Number(measured('maxTeilnehmerZahl')), 'Gnutella peers', warn); + entry.maxPeers = + at('maxTeilnehmerZahl') === undefined ? null : integer(at('maxTeilnehmerZahl'), 0, 'Gnutella peers', warn); break; case 'filius.software.firewall.Firewall': entry.firewall = firewallOf([application], true, warn); diff --git a/packages/fls/test/javabeans.test.ts b/packages/fls/test/javabeans.test.ts index 5deef27..b933173 100644 --- a/packages/fls/test/javabeans.test.ts +++ b/packages/fls/test/javabeans.test.ts @@ -240,6 +240,17 @@ describe('XMLDecoder rules', () => { expect(isUnresolved(listItems(holder)[0])).toBe(true); }); + it('does not take a value Java rolls in its constructor as a default', () => { + // Gnutella's peer limit is 3, 4 or 5 at random (measured by constructing it 64 times). + const [, holder] = decodeJavaBeans( + doc(` + `), + ); + + const [item] = listItems(holder); + expect(item).toEqual({ kind: 'unresolved', reason: 'PeerToPeerAnwendung#maxTeilnehmerZahl is random in Java' }); + }); + it('reports an idref to an id that was never defined', () => { const [value] = decodeJavaBeans(doc('')); diff --git a/packages/fls/test/topology.test.ts b/packages/fls/test/topology.test.ts index 00990c4..798a70d 100644 --- a/packages/fls/test/topology.test.ts +++ b/packages/fls/test/topology.test.ts @@ -416,7 +416,8 @@ describe('readTopology rules', () => { expect(byClass.get('filius.software.www.WebServer')).toMatchObject({ isActive: false, port: 80, useVirtualHosts: false }); expect(byClass.get('filius.software.clientserver.ClientBaustein')).toMatchObject({ targetAddress: null, targetPort: 55_555 }); - expect(byClass.get('filius.software.dateiaustausch.PeerToPeerAnwendung')).toMatchObject({ maxPeers: 4 }); + // Java rolls it: no default. + expect(byClass.get('filius.software.dateiaustausch.PeerToPeerAnwendung')).toMatchObject({ maxPeers: null }); expect(byClass.get('filius.software.firewall.Firewall')?.firewall).toMatchObject({ activated: true, defaultPolicy: 0 }); expect(byClass.get('filius.software.dns.DNSServer')).toMatchObject({ isActive: true, port: 5353, recursiveResolutionEnabled: false }); expect(Object.keys(byClass.get('filius.software.lokal.Terminal')!).sort()).toEqual(['javaClass', 'key', 'source']); diff --git a/tools/java-oracle/src/GetterDefaults.java b/tools/java-oracle/src/GetterDefaults.java index b1f2491..c3e2957 100644 --- a/tools/java-oracle/src/GetterDefaults.java +++ b/tools/java-oracle/src/GetterDefaults.java @@ -34,12 +34,20 @@ public class GetterDefaults { String property = key.substring(key.indexOf('#') + 1); String simpleName = className.substring(className.lastIndexOf('.') + 1); try { - Object value = className.equals("java.lang.ThreadGroup") - // A Thread's uncaughtExceptionHandler is its group; XMLDecoder runs - // in the main thread, so that is the group asked. - ? read(Thread.currentThread().getThreadGroup(), property) - : read(Class.forName(className).getDeclaredConstructor().newInstance(), property); - result.put(simpleName + "#" + property, value); + // Constructed many times: a constructor may roll its value (Gnutella's + // peer limit is 3, 4 or 5 at random). Such a value is no default and is + // written as {"random": [the values seen]}. + java.util.TreeSet seen = new java.util.TreeSet<>(); + Object value = null; + for (int attempt = 0; attempt < 64; attempt++) { + value = className.equals("java.lang.ThreadGroup") + // A Thread's uncaughtExceptionHandler is its group; XMLDecoder runs + // in the main thread, so that is the group asked. + ? read(Thread.currentThread().getThreadGroup(), property) + : read(Class.forName(className).getDeclaredConstructor().newInstance(), property); + seen.add(String.valueOf(value)); + } + result.put(simpleName + "#" + property, seen.size() == 1 ? value : Map.of("random", new java.util.ArrayList<>(seen))); } catch (Throwable error) { Throwable cause = error.getCause() != null ? error.getCause() : error; result.put(simpleName + "#" + property, Map.of("error", cause.toString()));