fix(fls): Gnutella's peer limit is random in Java, not a default

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 06:12:14 +00:00
co-authored by Claude Opus 5.5
parent c48d15def4
commit 8080e7dcb2
6 changed files with 65 additions and 17 deletions
+7 -1
View File
@@ -11,7 +11,13 @@
"JSidebarButton#iconTextGap": 4, "JSidebarButton#iconTextGap": 4,
"JSidebarButton#verticalTextPosition": 3, "JSidebarButton#verticalTextPosition": 3,
"Notebook#maxLayerOfOperation": 4, "Notebook#maxLayerOfOperation": 4,
"PeerToPeerAnwendung#maxTeilnehmerZahl": 5, "PeerToPeerAnwendung#maxTeilnehmerZahl": {
"random": [
"3",
"4",
"5"
]
},
"RIPServer#port": 520, "RIPServer#port": 520,
"Rechner#maxLayerOfOperation": 4, "Rechner#maxLayerOfOperation": 4,
"ServerBaustein#aktiv": false, "ServerBaustein#aktiv": false,
+24 -6
View File
@@ -217,15 +217,30 @@ export function decodeXmlBytes(bytes: Uint8Array): string {
// ── Decoder ──────────────────────────────────────────────────────────────── // ── Decoder ────────────────────────────────────────────────────────────────
const PRIMITIVE_ID_PREFIXES = new Set(['Integer', 'Boolean', 'Long', 'Short', 'Byte', 'Float', 'Double', 'Character']); const PRIMITIVE_ID_PREFIXES = new Set(['Integer', 'Boolean', 'Long', 'Short', 'Byte', 'Float', 'Double', 'Character']);
const DEFAULTS = javaDefaults as Readonly<Record<string, string | number | boolean | null>>; 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<Record<string, MeasuredValue | RandomValue>>;
/** 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 * 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 { export function javaDefault(simpleClass: string, property: string): string | number | boolean | null | undefined {
const key = `${simpleClass}#${property}`; return measuredConstant(`${simpleClass}#${property}`);
return Object.hasOwn(DEFAULTS, key) ? DEFAULTS[key] : undefined;
} }
/** `getFoo`/`isFoo` → `foo`, as java.beans.Introspector derives it. */ /** `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 // A static constant: XMLEncoder writes a shared Integer 0 as
// SwingConstants.CENTER when that was the first 0 it met. // SwingConstants.CENTER when that was the first 0 it met.
const constantKey = `${simpleClassName(className)}.${attributes.field}`; 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 args = element.children.filter((child) => child.name !== 'void');
const object = newObject({ const object = newObject({
@@ -457,7 +473,9 @@ class Decoder {
} }
if (target.properties.has(property)) return target.properties.get(property)!; if (target.properties.has(property)) return target.properties.get(property)!;
const defaultKey = `${simpleClassName(target.className)}#${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+$/, ''); const prefix = id?.replace(/\d+$/, '');
if (prefix !== undefined && PRIMITIVE_ID_PREFIXES.has(prefix)) { if (prefix !== undefined && PRIMITIVE_ID_PREFIXES.has(prefix)) {
return { kind: 'unresolved', reason: `no measured default for ${defaultKey}` }; return { kind: 'unresolved', reason: `no measured default for ${defaultKey}` };
+7 -3
View File
@@ -129,8 +129,11 @@ export interface FlsApplication {
recursiveResolutionEnabled?: boolean; recursiveResolutionEnabled?: boolean;
/** WebServer: serve by host name (Java's useVHost). */ /** WebServer: serve by host name (Java's useVHost). */
useVirtualHosts?: boolean; 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: the personal firewall. */
firewall?: FlsFirewall; firewall?: FlsFirewall;
/** The application's JavaBean, for the e-mail layer. */ /** 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); entry.useVirtualHosts = boolean(at('useVHost'), measured('useVHost') === true, 'WebServer useVHost', warn);
break; break;
case 'filius.software.dateiaustausch.PeerToPeerAnwendung': 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; break;
case 'filius.software.firewall.Firewall': case 'filius.software.firewall.Firewall':
entry.firewall = firewallOf([application], true, warn); entry.firewall = firewallOf([application], true, warn);
+11
View File
@@ -240,6 +240,17 @@ describe('XMLDecoder rules', () => {
expect(isUnresolved(listItems(holder)[0])).toBe(true); 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(`<object class="filius.software.dateiaustausch.PeerToPeerAnwendung"><void id="Integer0" property="maxTeilnehmerZahl"/></object>
<object class="java.util.ArrayList"><void method="add"><object idref="Integer0"/></void></object>`),
);
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', () => { it('reports an idref to an id that was never defined', () => {
const [value] = decodeJavaBeans(doc('<object idref="Missing0"/>')); const [value] = decodeJavaBeans(doc('<object idref="Missing0"/>'));
+2 -1
View File
@@ -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.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.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.firewall.Firewall')?.firewall).toMatchObject({ activated: true, defaultPolicy: 0 });
expect(byClass.get('filius.software.dns.DNSServer')).toMatchObject({ isActive: true, port: 5353, recursiveResolutionEnabled: false }); 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']); expect(Object.keys(byClass.get('filius.software.lokal.Terminal')!).sort()).toEqual(['javaClass', 'key', 'source']);
+14 -6
View File
@@ -34,12 +34,20 @@ public class GetterDefaults {
String property = key.substring(key.indexOf('#') + 1); String property = key.substring(key.indexOf('#') + 1);
String simpleName = className.substring(className.lastIndexOf('.') + 1); String simpleName = className.substring(className.lastIndexOf('.') + 1);
try { try {
Object value = className.equals("java.lang.ThreadGroup") // Constructed many times: a constructor may roll its value (Gnutella's
// A Thread's uncaughtExceptionHandler is its group; XMLDecoder runs // peer limit is 3, 4 or 5 at random). Such a value is no default and is
// in the main thread, so that is the group asked. // written as {"random": [the values seen]}.
? read(Thread.currentThread().getThreadGroup(), property) java.util.TreeSet<String> seen = new java.util.TreeSet<>();
: read(Class.forName(className).getDeclaredConstructor().newInstance(), property); Object value = null;
result.put(simpleName + "#" + property, value); 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) { } catch (Throwable error) {
Throwable cause = error.getCause() != null ? error.getCause() : error; Throwable cause = error.getCause() != null ? error.getCause() : error;
result.put(simpleName + "#" + property, Map.of("error", cause.toString())); result.put(simpleName + "#" + property, Map.of("error", cause.toString()));