// 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 { GNUTELLA_FOLDER, javaBase64, javaFileTypeName, VirtualFileSystem, 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 }, search: { looks: 3 }, searchBoth: { looks: 3 }, }; /** The ids of Java's own pings and queries, 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) => { // A ping or a query with no hops yet: the program's own. const match = /^(\d+)\/\/0x(?:00|80)\/\/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). */ /** * Each program's known peers, or its results, as it told them, each change * once (Java tells its window also when nothing changed, and an empty list * at its start); results only when there are some (Java empties them without * telling). */ function javaTold(java: JavaRun, wanted: 'peers' | 'results'): Record { const told: Record = Object.fromEntries(PEERS.map((host) => [host, []])); for (const [time, host, kind, text] of java.events) { if (kind !== wanted || (wanted === 'results' && text === '')) continue; const list = told[host]!; if ((list.at(-1)?.[1] ?? '') === text) continue; list.push([time, text]); } return told; } function engineTold(engine: NetworkRuntimeEngine, wanted: 'peers' | 'results'): Record { return Object.fromEntries( PEERS.map((host) => { const list: [number, string][] = []; for (const line of engine.gnutella(host)!.log.all) { const text = line.kind === 'peers' ? (wanted === 'peers' ? line.peers.join(',') : undefined) : wanted === 'results' ? line.results.join('|') : undefined; if (text === undefined || (wanted === 'results' && text === '') || list.at(-1)?.[1] === text) continue; list.push([line.timeMilliseconds, text]); } return [host, list]; }), ); } /** Java's run with each result's text changed, where it tells them and at the end. */ function withResults(java: JavaRun, change: (results: string[], host: string) => string[]): JavaRun { return { ...java, events: java.events.map(([time, host, kind, text]) => [time, host, kind, kind === 'results' && text !== '' ? change(text.split('|'), host).join('|') : text]), programs: Object.fromEntries( Object.entries(java.programs).map(([host, program]) => [host, { ...program, results: program.results.length === 0 ? [] : change(program.results, host) }]), ), }; } /** A hit's size as Java's Datei.holeGroesse says it (the text decoded as Base64), and as the engine (its characters). */ const SIZES: Record = { 'Lied der Woche.txt': [3, 8], 'notizen.txt': [3, 5], 'lied.txt': [6, 10] }; const withSizes = (results: string[]) => results.map((result) => result.replace(/\/(.+): (\d+) B$/, (whole, name: string, size: string) => (SIZES[name]?.[0] === Number(size) ? `/${name}: ${SIZES[name]![1]} B` : whole)), ); /** * Where Java errs, its run corrected: a hit's size in characters (search, * searchBoth); in searchBoth also the hit of 10.0.0.2, which Java loses * because .31 closes the connection after its own hit (gnutella.ts). */ const JAVA_BUGS: Record JavaRun> = { search: (java) => withResults(java, withSizes), searchBoth: (java) => { const sized = withResults(java, withSizes); const farHit = '10.0.0.2/Lied der Woche.txt: 8 B'; const last = sized.events.findLast(([, host, kind, text]) => host === CLIENT && kind === 'results' && text !== '')!; return { ...sized, events: [...sized.events, [last[0] + 1, CLIENT, 'results', `${last[3]}|${farHit}`]], programs: { ...sized.programs, [CLIENT]: { ...sized.programs[CLIENT]!, results: [...sized.programs[CLIENT]!.results, farHit] } }, }; }, }; /** Where a result's time is not compared: a result Java never had (JAVA_BUGS). */ const RESULTS_WITHOUT_TIMES = new Set(['searchBoth']); /** Each program's files in /peer2peer as GnutellaRun writes Java's: name, type, content. */ function engineFiles(engine: NetworkRuntimeEngine, host: string): [string, string, string][] { const files = engine.state.topologySnapshot.fileSystemsByNodeId.get(host)!; return files.entriesInCreationOrder(GNUTELLA_FOLDER).flatMap((entry): [string, string, string][] => { const content = entry.content; if (content.kind === 'directory') return []; const name = entry.path.slice(entry.path.lastIndexOf('/') + 1); return [[name, javaFileTypeName(name, content), content.kind === 'text' ? content.text : javaBase64(content.data)]]; }); } /** * 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', 'search', 'searchBoth']); /** * search, searchBoth: a hit names a size, Java's another than the engine's * (JAVA_BUGS), as a pong its files' bytes, so the length of the text and * the numbers after it on its connection differ; the sizes, bytes, * sequence numbers and lengths are left out of the frames compared. A connection Java's server closes after its * hits (gnutella.ts) is compared up to that FIN; one the client closes (a * download's) wholly. */ const SEARCHING = new Set(['search', 'searchBoth']); const withoutSizes = (text: string) => text .replace(/ seq=\d+ ack=\d+ length=\d+/, '') .replace(/: \d+ B\/\//g, ': n B//') // A pong's bytes, and its payload length that counts their bits. .replace(/(\/\/0x01\/\/\d+\/\/\d+\/\/)\d+(\/\/.*\/\/6346\/\/\d+\/\/)\d+$/, '$1n$2n'); /** A card's frames without those of each connection from the FIN its server sent first. */ function beforeServerFins(frames: readonly JavaFrame[]): JavaFrame[] { const closedAt = new Map(); for (const frame of frames) { const client = frame.sourcePort === 6346 ? `${frame.destinationIp}:${frame.destinationPort}` : `${frame.sourceIp}:${frame.sourcePort}`; if (frame.tcpFlags?.includes('F') !== true || closedAt.has(client)) continue; closedAt.set(client, frame.sourcePort === 6346 ? frame.time : Number.POSITIVE_INFINITY); } return frames.filter((frame) => { if (frame.tcpFlags === undefined) return true; const client = frame.sourcePort === 6346 ? `${frame.destinationIp}:${frame.destinationPort}` : `${frame.sourceIp}:${frame.sourcePort}`; return frame.time < (closedAt.get(client) ?? Number.POSITIVE_INFINITY); }); } 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, }; /** searchBoth: each side's frames up to its first hit, after which Java loses the hit of 10.0.0.2 (JAVA_BUGS). */ const UP_TO_FIRST_HIT = new Set(['searchBoth']); const FIRST_HIT = '//0x81//'; /** When the engine's first hit showed on a card. */ function firstEngineHit(engine: NetworkRuntimeEngine, scenario: Scenario): number { const times = scenarioCards(scenario).flatMap((card) => engine.capture.frames(card.portId).flatMap(({ timeMilliseconds, frame }) => { const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined; const text = packet?.payload.kind === 'tcp' ? new TextDecoder().decode(packet.payload.segment.payload) : ''; return text.includes(FIRST_HIT) ? [timeMilliseconds] : []; }), ); return Math.min(...times); } 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 measured = JSON.parse(readFileSync(join(scenarioDirectory, `gnutella-java-${name}.java.json`), 'utf8')) as JavaRun; const java = JAVA_BUGS[name]?.(measured) ?? measured; const maximumKnownPeers = (host: string) => name === 'full' ? (host === ECHO ? 2 : host === FAR ? 3 : 1) : SEARCHING.has(name) ? (host === CLIENT || host === FAR ? 1 : 2) : 3; const files = new Map(PEERS.map((host) => [host, new VirtualFileSystem()])); if (SEARCHING.has(name)) { for (const host of [ECHO, FAR]) files.get(host)!.createDirectory(GNUTELLA_FOLDER); if (name === 'searchBoth') files.get(ECHO)!.writeTextFile(`${GNUTELLA_FOLDER}/lied.txt`, 'Hallo Welt'); files.get(ECHO)!.writeTextFile(`${GNUTELLA_FOLDER}/notizen.txt`, 'Notiz'); files.get(FAR)!.writeTextFile(`${GNUTELLA_FOLDER}/Lied der Woche.txt`, 'La la la'); } const snapshot = withSnapshot(snapshotOf(scenario), { gnutellaByNodeId: new Map(PEERS.map((host) => [host, { maximumKnownPeers: maximumKnownPeers(host) }])), fileSystemsByNodeId: files, }); 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 }); const program = engine.gnutella(host)!; if (call.startsWith('join ')) program.join(call.slice('join '.length)); else if (call.startsWith('search ')) program.search(call.slice('search '.length)); else if (call.startsWith('download ')) program.download(Number(call.slice('download '.length))); else if (call.startsWith('remove ')) files.get(host)!.deleteItem(`${GNUTELLA_FOLDER}/${call.slice('remove '.length)}`); else if (call === 'clear') program.clearResults(); else program.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 masked = (text: string) => (SEARCHING.has(name) ? withoutSizes(text) : text); // search, searchBoth: a bare acknowledgement left out (Java's server closes after its hits, and the client's // acknowledgement of them falls after that FIN). const compared = (frame: FlowFrame) => !SEARCHING.has(name) || !/ TCP \S+ > \S+ A $/.test(frame.text); const data = (text: string | undefined) => withClientPortText(text ?? '').replace(/^\d+\/\/0x/, 'x//0x'); for (const [card, list] of Object.entries(java.frames)) { const javaUntil = UP_TO_FIRST_HIT.has(name) ? Math.min( ...Object.values(java.frames) .flat() .filter((frame) => frame.payload?.includes(FIRST_HIT) === true) .map((frame) => frame.time), ) : until; const expected = connections( (SEARCHING.has(name) ? beforeServerFins(list) : list) .filter((frame) => frame.time < javaUntil) .map((frame) => ({ time: frame.time, raw: javaText(frame), text: masked(`${withClientPortText(javaText(frame))} ${data(frame.payload)}`) })) .filter(compared), ); const engineUntil = UP_TO_FIRST_HIT.has(name) ? firstEngineHit(engine, scenario) : until; const captured = engine.capture.frames(portOf.get(card)!).filter((each) => each.timeMilliseconds < engineUntil); 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: masked(`${withClientPortText(text)} ${data(bytes.length === 0 ? undefined : new TextDecoder().decode(bytes))}`) }; }) .filter(compared), ); 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 }); } for (const wanted of ['peers', 'results'] as const) { const expected = javaTold(java, wanted); const actual = engineTold(engine, wanted); for (const host of PEERS) { expect( actual[host]!.map(([, told]) => told), `${host}'s ${wanted}`, ).toEqual(expected[host]!.map(([, told]) => told)); if (wanted === 'results' && RESULTS_WITHOUT_TIMES.has(name)) continue; for (const [index, [javaTime]] of expected[host]!.entries()) { expect(Math.abs(javaTime - actual[host]![index]![0]), `${host}'s ${wanted} #${index + 1}`).toBeLessThanOrEqual(tolerance); } } } for (const host of PEERS) { const program = engine.gnutella(host)!; expect(program.peers, `${host}'s peers at the end`).toEqual(java.programs[host]!.peers); expect(program.results, `${host}'s results at the end`).toEqual(java.programs[host]!.results); if (java.programs[host]!.files !== undefined) expect(engineFiles(engine, host), `${host}'s files`).toEqual(java.programs[host]!.files); } }); } });