// 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-.java.json); on // dns-recursive's, where the server resolves recursively, likewise // (dns-recursive-java-.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; events: [number, string][]; } type Story = (engine: NetworkRuntimeEngine) => DnsAddressResolution; /** Per scenario the stories DnsRun played on it. */ const RUNS: Record> = { '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- for dns-lan, -java- 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 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(); 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 }); }); } } });