// The Gnutella program as Java FILIUS runs it. tools/java-oracle's // GnutellaRun let FILIUS' Gnutella program (PeerToPeerAnwendung) join peers // on terminal-net and wrote every frame each card saw, Java's tables of // them, each program's known peers whenever it told its window, and at the // end each program's peers (gnutella-java-.java.json). The engine // plays the same calls at the same times (apps/gnutella.ts): each card's // frames in the same order, with the same content and rows, at about the // same time; the same peers, in the same order, at about the same time. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { withSnapshot, type NetworkRuntimeEngine } from '../src/index.js'; import { engineText, expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, javaText, scenarioCards, watchedEngine, withClientPortText, type JavaFrame, type JavaTables, } from './javaRun.js'; import { POLL_MILLISECONDS } from './programLines.js'; import { snapshotOf, type Scenario } from './scenario.js'; const scenarioDirectory = join(dirname(fileURLToPath(import.meta.url)), 'scenarios'); const CLIENT = '00000000-0000-0000-0000-000000000401'; const WEB = '00000000-0000-0000-0000-000000000403'; const ECHO = '00000000-0000-0000-0000-000000000404'; const FAR = '00000000-0000-0000-0000-000000000406'; const PEERS = [CLIENT, WEB, ECHO, FAR]; interface JavaRun extends JavaTables { frames: Record; acts: [number, string, string][]; /** time, host, kind (call or peers), text */ events: [number, string, string, string][]; programs: Record; } /** The stories, and how many connections each makes one after the other (Java looks every 100 ms whether one is made). */ const STORIES: Record = { pair: { looks: 1 }, chain: { looks: 2 }, twoPaths: { looks: 3 }, full: { looks: 3 }, reset: { looks: 1 }, errors: { looks: 1 }, }; /** The ids of Java's own pings, in the order they were made: the engine draws its own, these take their place. */ function javaPingIds(java: JavaRun): number[] { const pings = Object.values(java.frames) .flat() .flatMap((frame) => { const match = /^(\d+)\/\/0x00\/\/0\/\//.exec(frame.payload ?? ''); return match === null ? [] : [[frame.time, Number(match[1])] as const]; }) .sort((lhs, rhs) => lhs[0] - rhs[0]); return [...new Set(pings.map(([, guid]) => guid))]; } /** Each program's known peers as it told them, each change once (Java tells its window also when nothing changed, and an empty list at its start). */ function javaPeers(java: JavaRun): Record { const peers: Record = Object.fromEntries(PEERS.map((host) => [host, []])); for (const [time, host, kind, text] of java.events) { if (kind !== 'peers') continue; const list = peers[host]!; if ((list.at(-1)?.[1] ?? '') === text) continue; list.push([time, text]); } return peers; } function enginePeers(engine: NetworkRuntimeEngine): Record { return Object.fromEntries(PEERS.map((host) => [host, engine.gnutella(host)!.log.all.map((line): [number, string] => [line.timeMilliseconds, line.peers.join(',')])])); } /** * Where Java errs, compared only up to a time (Java's): twoPaths, from the * first ping that comes a second time — Java's worker throws on the * missing pong and closes the connection (gnutella.ts); * errors, before the engine's second SYN to the closed port. */ /** * twoPaths, full: many connections at once, and at a card the frames of two * of them, or the two directions of one, may come in either order a few ten * milliseconds apart (Java's threads over several hops). Each card's frames * are compared connection by connection and direction by direction — the * frames between the two hosts on the server's port, in the order the * connections began — with their data; ARP on its own. */ const BY_CONNECTION = new Set(['twoPaths', 'full']); interface FlowFrame { readonly time: number; /** The frame's text with its real ports, which tell the connection. */ readonly raw: string; /** The text compared: a client's port as "client", a packet's id as "x". */ readonly text: string; } /** A card's frames by connection: its two hosts and the server's port (a client's port is drawn), each connection's frames in order. */ function connections(frames: readonly FlowFrame[]): Map { const byKey = new Map>(); for (const frame of frames) { const tcp = / TCP (\S+):(\d+) > (\S+):(\d+) /.exec(frame.raw); // A connection by its client's end, and each of its directions on its own (TCP orders a direction, not the two). const [key, connection] = tcp === null ? ['arp', 'arp'] : tcp[2] === '6346' ? [`${tcp[3]}>${tcp[1]}:6346 answers`, `${tcp[3]}:${tcp[4]}`] : [`${tcp[1]}>${tcp[3]}:6346`, `${tcp[1]}:${tcp[2]}`]; const flows = byKey.get(key) ?? new Map(); byKey.set(key, flows); flows.set(connection, [...(flows.get(connection) ?? []), frame]); } return new Map([...byKey].map(([key, flows]) => [key, [...flows.values()]])); } /** * When a ping first comes to a host a second time (the same id to the same * address, on another connection; a frame shows on both its cards); before * it, by more than a timing difference. */ function secondPing(java: JavaRun): number { const connectionsByPing = new Map>(); for (const frame of Object.values(java.frames) .flat() .sort((lhs, rhs) => lhs.time - rhs.time)) { const match = /^(\d+)\/\/0x00\//.exec(frame.payload ?? ''); if (match === null) continue; const ping = `${match[1]}>${frame.destinationIp}`; const connections = connectionsByPing.get(ping) ?? new Set(); connections.add(`${frame.sourceIp}:${frame.sourcePort}`); connectionsByPing.set(ping, connections); if (connections.size > 1) return frame.time - 150; } return Number.POSITIVE_INFINITY; } const UNTIL: Record number> = { // The SYN to a closed port goes once in Java, three times in the engine (tcpJava.test.ts, closedPort). errors: (java) => java.frames[CLIENT]!.find((frame) => frame.tcpFlags === 'S')!.time + 1_000, twoPaths: secondPing, }; describe('the Gnutella program as Java FILIUS runs it', () => { const scenario = JSON.parse(readFileSync(join(scenarioDirectory, 'terminal-net.scenario.json'), 'utf8')) as Scenario; for (const [name, story] of Object.entries(STORIES)) { it(name, () => { const java = JSON.parse(readFileSync(join(scenarioDirectory, `gnutella-java-${name}.java.json`), 'utf8')) as JavaRun; const snapshot = withSnapshot(snapshotOf(scenario), { gnutellaByNodeId: new Map(PEERS.map((host) => [host, { maximumKnownPeers: name !== 'full' ? 3 : host === ECHO ? 2 : host === FAR ? 3 : 1 }])), }); const { engine } = watchedEngine(scenario, snapshot); const ids = javaPingIds(java); engine.drawGnutellaGuid = () => ids.shift() ?? 1; for (const [time, host, call] of java.acts) { engine.handle({ type: 'advance', toMilliseconds: time }); if (call.startsWith('join ')) engine.gnutella(host)!.join(call.slice('join '.length)); else engine.gnutella(host)!.reset(); } engine.handle({ type: 'advance', toMilliseconds: Math.max(...java.events.map(([time]) => time)) + 10_000 }); const tolerance = GAP_TOLERANCE_MILLISECONDS + story.looks * POLL_MILLISECONDS; const until = UNTIL[name]?.(java) ?? Number.POSITIVE_INFINITY; if (BY_CONNECTION.has(name)) { const portOf = new Map(scenarioCards(scenario).map((card) => [card.key, card.portId])); const data = (text: string | undefined) => withClientPortText(text ?? '').replace(/^\d+\/\/0x/, 'x//0x'); for (const [card, list] of Object.entries(java.frames)) { const expected = connections( list .filter((frame) => frame.time < until) .map((frame) => ({ time: frame.time, raw: javaText(frame), text: `${withClientPortText(javaText(frame))} ${data(frame.payload)}` })), ); const captured = engine.capture.frames(portOf.get(card)!).filter((each) => each.timeMilliseconds < until); const actual = connections( captured.map(({ timeMilliseconds, frame }) => { const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined; const bytes = packet?.payload.kind === 'tcp' ? packet.payload.segment.payload : new Uint8Array(); const text = engineText(frame); return { time: timeMilliseconds, raw: text, text: `${withClientPortText(text)} ${data(bytes.length === 0 ? undefined : new TextDecoder().decode(bytes))}` }; }), ); expect([...actual.keys()].sort(), `${card}'s connections`).toEqual([...expected.keys()].sort()); for (const [key, flows] of expected) { const texts = (each: FlowFrame[][]) => each.map((flow) => flow.map((frame) => frame.text)); expect(texts(actual.get(key)!), `${card} ${key}`).toEqual(texts(flows)); for (const [index, flow] of flows.entries()) { for (const [at, frame] of flow.entries()) { expect(Math.abs(actual.get(key)![index]![at]!.time - frame.time), `${card} ${key} #${index + 1} frame ${at + 1}`).toBeLessThanOrEqual(tolerance); } } } } } else { expectSameCaptures(engine, scenario, java.frames, { text: withClientPortText, tables: java, until: UNTIL[name]?.(java), tolerance }); } const expected = javaPeers(java); const actual = enginePeers(engine); for (const host of PEERS) { expect( actual[host]!.map(([, peers]) => peers), `${host}'s peers`, ).toEqual(expected[host]!.map(([, peers]) => peers)); for (const [index, [javaTime]] of expected[host]!.entries()) { expect(Math.abs(javaTime - actual[host]![index]![0]), `${host}'s peers #${index + 1}`).toBeLessThanOrEqual(tolerance); } expect(engine.gnutella(host)!.peers, `${host}'s peers at the end`).toEqual(java.programs[host]!.peers); } }); } });