GnutellaRun adds a line of four peers: a search goes on as a query, each peer answers with a hit for each matching file and the hits come back the way the query came; a hit's file is fetched by HTTP on port 6346. The file type names of the model move from the project export and import into the core, so that the download uses them too. Departures from Java: a peer that sent hits keeps its connection, so that the hits of farther peers come back too (Java closes it and loses them), and sizes are in characters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
375 lines
19 KiB
TypeScript
375 lines
19 KiB
TypeScript
// 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-<story>.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<string, JavaFrame[]>;
|
|
acts: [number, string, string][];
|
|
/** time, host, kind (call or peers), text */
|
|
events: [number, string, string, string][];
|
|
programs: Record<string, { peers: string[]; results: string[]; files?: [string, string, string][] }>;
|
|
}
|
|
|
|
/** The stories, and how many connections each makes one after the other (Java looks every 100 ms whether one is made). */
|
|
const STORIES: Record<string, { readonly looks: number }> = {
|
|
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<string, [number, string][]> {
|
|
const told: Record<string, [number, string][]> = 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<string, [number, string][]> {
|
|
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<string, [number, number]> = { '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<string, (java: JavaRun) => 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<string, number>();
|
|
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<string, FlowFrame[][]> {
|
|
const byKey = new Map<string, Map<string, FlowFrame[]>>();
|
|
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<string, FlowFrame[]>();
|
|
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<string, Set<string>>();
|
|
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<string>();
|
|
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<string, (java: JavaRun) => 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);
|
|
}
|
|
});
|
|
}
|
|
});
|