Files
julian.kiedaischandClaude Opus 5.5 21a404693c feat(core): the packet capture's table after Java's Lauscher, measured against Java's own tables
captureRows.ts builds Java's rows per frame (Lauscher.datenVorbereiten):
Ethernet; ARP, ICMP or IP; TCP or UDP; the data with Java's protocol
recognition; with addresses and ports or ports only. The comment stays
structured; captureCommentText says it with given words and numbers.

ScenarioNetwork writes Java's finished tables per card (Lauscher.getDaten,
German locale); all runtime runs are recorded again, NatRun has ttlOut.
expectSameCaptures compares both views for every frame. Fixed where the
engine differed: an ARP request's target MAC is the broadcast address,
an ICMP error's identifier and sequence number are 0 (RFC 792), and host
unreachable from a router carries the packet as forwarded. Not taken:
Java's lower-case broadcast address in a reply to 0.0.0.0, and the typo
'Exeeded'. RIP keeps its recordings: Java's staggered router start now
gives a different startup on this machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 15:11:05 +00:00

130 lines
6.7 KiB
TypeScript

// DNS as Java FILIUS runs it. tools/java-oracle's DnsRun called a client's
// resolver on dns-lan's network in Java FILIUS and wrote every frame each
// card saw and what the call returned (dns-java-<story>.java.json); on
// dns-recursive's, where the server resolves recursively, likewise
// (dns-recursive-java-<story>.java.json). The
// engine makes the same call: the frames must come in the same order, with
// the same content, at about the same distance from each other (javaRun.ts),
// the call must return the same, after about as long.
//
// Java draws each message's ID with Math.random and a client's free port with
// its random; both are compared as placeholders, and an answer must carry the
// ID of its question.
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';
import type { DnsAddressResolution, NetworkRuntimeEngine } from '../src/index.js';
import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, gaps, javaFrames, watchedEngine, type JavaFrame, type SeenFrame, type JavaTables } from './javaRun.js';
import type { Scenario } from './scenario.js';
const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios');
const CLIENT = '00000000-0000-0000-0000-000000000101';
const LOST_CLIENT = '00000000-0000-0000-0000-000000000104';
interface JavaRun extends JavaTables {
frames: Record<string, JavaFrame[]>;
events: [number, string][];
}
type Story = (engine: NetworkRuntimeEngine) => DnsAddressResolution;
/** Per scenario the stories DnsRun played on it. */
const RUNS: Record<string, Record<string, Story>> = {
'dns-lan': {
address: (engine) => engine.resolveDnsAddress(CLIENT, 'web.filius.de'),
mail: (engine) => engine.resolveDnsMailServer(CLIENT, 'filius.de'),
referral: (engine) => engine.resolveDnsAddress(CLIENT, 'www.schule.de'),
unknown: (engine) => engine.resolveDnsAddress(CLIENT, 'x.unbekannt.org'),
noServer: (engine) => engine.resolveDnsAddress(LOST_CLIENT, 'web.filius.de'),
},
'dns-recursive': {
address: (engine) => engine.resolveDnsAddress(CLIENT, 'web.filius.de'),
referral: (engine) => engine.resolveDnsAddress(CLIENT, 'www.schule.de'),
unknown: (engine) => engine.resolveDnsAddress(CLIENT, 'x.unbekannt.org'),
unknownInZone: (engine) => engine.resolveDnsAddress(CLIENT, 'nix.schule.de'),
unknownInOwnZone: (engine) => engine.resolveDnsAddress(CLIENT, 'nix.filius.de'),
},
};
/** The recording of a story: dns-java-<story> for dns-lan, <scenario>-java-<story> else. */
function recording(scenarioName: string, story: string): string {
return scenarioName === 'dns-lan' ? `dns-java-${story}.java.json` : `${scenarioName}-java-${story}.java.json`;
}
/**
* Where Java errs, per scenario and story: compared up to the frames Java
* sends before its error shows, not the result (engine.test.ts has the rest).
*
* dns-recursive/unknownInOwnZone: the server's own zone names itself as its
* name server, and Java's server asks it — itself, which asks itself again,
* without end; the client times out, then every level falls back to .11. The
* engine asks neither itself nor, here, anyone but .11. Compared up to the
* client's query.
*/
const JAVA_UNTIL: Record<string, (java: JavaRun) => number> = {
'dns-recursive/unknownInOwnZone': (java) => Math.min(...Object.values(java.frames).flat().filter((frame) => frame.destinationPort === 53).map((frame) => frame.time)) + 1,
};
/** Java's words for the result: the address, null, or the exception. */
function javaResult(resolution: DnsAddressResolution): string {
return resolution.type === 'timedOut' ? 'failed TimeOutException' : `returned ${resolution.address ?? 'null'}`;
}
/** IDs and free ports as placeholders; each ID stands for the question it first appeared in. */
function normalized(frames: readonly SeenFrame[]): SeenFrame[] {
const ids = new Map<string, string>();
return frames.map((frame) => ({
time: frame.time,
text: frame.text
.replace(/ID=(\d+)/, (_, id: string) => {
if (!ids.has(id)) ids.set(id, `q${ids.size + 1}`);
return `ID=${ids.get(id)}`;
})
.replace(/:(\d{5})\b/g, (whole, port: string) => (Number(port) >= 49_152 ? ':client' : whole)),
}));
}
describe('DNS as Java FILIUS runs it', () => {
it('cuts only where Java goes on with its error', () => {
for (const [key, until] of Object.entries(JAVA_UNTIL)) {
const [scenarioName, story] = key.split('/') as [string, string];
const java = JSON.parse(readFileSync(join(scenarioDirectory, recording(scenarioName, story)), 'utf8')) as JavaRun;
expect(Object.values(java.frames).flat().some((frame) => frame.time >= until(java)), key).toBe(true);
}
});
for (const [scenarioName, stories] of Object.entries(RUNS)) {
const scenario = JSON.parse(readFileSync(join(scenarioDirectory, `${scenarioName}.scenario.json`), 'utf8')) as Scenario;
for (const [name, story] of Object.entries(stories)) {
it(`${scenarioName}: ${name}`, () => {
const java = JSON.parse(readFileSync(join(scenarioDirectory, recording(scenarioName, name)), 'utf8')) as JavaRun;
const { engine, frames } = watchedEngine(scenario);
engine.handle({ type: 'advance', toMilliseconds: 500 });
const called = engine.state.currentTimeMilliseconds;
const result = story(engine);
const returned = engine.state.currentTimeMilliseconds;
let expected = gaps(normalized(javaFrames(java.frames)));
let actual = gaps(normalized(frames()));
const until = JAVA_UNTIL[`${scenarioName}/${name}`]?.(java);
if (until !== undefined) {
const count = javaFrames(java.frames).filter((frame) => frame.time < until).length;
expected = expected.slice(0, count);
actual = actual.slice(0, count);
} else {
const [[javaCalled], [javaReturned, javaOutcome]] = java.events as [[number, string], [number, string]];
expect(`${javaOutcome.split(' ').slice(-2).join(' ')}`).toBe(javaResult(result).split(' ').slice(-2).join(' '));
expect(Math.abs(returned - called - (javaReturned - javaCalled)), 'duration of the call').toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
}
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
expect(actual[index]?.text, `frame #${index + 1}`).toBe(expected[index]?.text);
expect(Math.abs(actual[index]!.gap - expected[index]!.gap), `gap before frame #${index + 1}`).toBeLessThanOrEqual(GAP_TOLERANCE_MILLISECONDS);
}
expectSameCaptures(engine, scenario, java.frames, { text: (text) => normalized([{ time: 0, text }])[0]!.text, until, tables: java });
});
}
}
});