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#verticalTextPosition": 3,
"Notebook#maxLayerOfOperation": 4,
"PeerToPeerAnwendung#maxTeilnehmerZahl": 5,
"PeerToPeerAnwendung#maxTeilnehmerZahl": {
"random": [
"3",
"4",
"5"
]
},
"RIPServer#port": 520,
"Rechner#maxLayerOfOperation": 4,
"ServerBaustein#aktiv": false,
+24 -6
View File
@@ -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<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
* 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}` };
+7 -3
View File
@@ -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);
+11
View File
@@ -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(`<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', () => {
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.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']);
+10 -2
View File
@@ -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")
// 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<String> 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);
result.put(simpleName + "#" + property, value);
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()));