// The Gnutella program (apps/gnutella.ts) beyond what Java's runs show // (gnutellaJava.test.ts): its packets as Java writes and reads them, a ping // that comes twice, the files it offers. 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, gnutellaPacket, gnutellaText, pongPayloadLength, VirtualFileSystem, withSnapshot, type NetworkRuntimeEngine } from '../src/index.js'; import { watchedEngine } from './javaRun.js'; import { snapshotOf, type Scenario } from './scenario.js'; const scenario = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'scenarios', 'terminal-net.scenario.json'), 'utf8')) as Scenario; const CLIENT = '00000000-0000-0000-0000-000000000401'; const ECHO = '00000000-0000-0000-0000-000000000404'; describe('Gnutella packets as Java writes and reads them', () => { it('writes a ping and a pong with their fields joined by "//"', () => { expect(gnutellaText({ kind: 'ping', guid: 42, hops: 1, ttl: 7, payloadLength: 0, ipAddress: '192.168.0.30' })).toBe('42//0x00//1//7//0//192.168.0.30'); expect(gnutellaText({ kind: 'pong', guid: 42, hops: 0, ttl: 8, payloadLength: 77, ipAddress: '10.0.0.2', port: 6346, files: 2, bytes: 15 })).toBe( '42//0x01//0//8//77//10.0.0.2//6346//2//15', ); }); it('reads them back, skipping empty fields as a StringTokenizer on "/" does', () => { expect(gnutellaPacket('42///0x00//1//7//0//192.168.0.30/')).toEqual({ kind: 'ping', guid: 42, hops: 1, ttl: 7, payloadLength: 0, ipAddress: '192.168.0.30' }); expect(gnutellaPacket('42//0x01//0//8//77//10.0.0.2//6346//2//15')).toEqual({ kind: 'pong', guid: 42, hops: 0, ttl: 8, payloadLength: 77, ipAddress: '10.0.0.2', port: 6346, files: 2, bytes: 15, }); }); it('reads no packet from a text that is none, or of another kind', () => { expect(['', 'GET /x HTTP/1.1', '42//0x00//1//7//0', 'x//0x00//1//7//0//a', '42//0x80//1//7//0//1//txt', '42//0x01//0//8//77//10.0.0.2//6346//2'].map(gnutellaPacket)).toEqual( Array(6).fill(undefined), ); }); it("counts a pong's payload in Java's bits: the address's characters, then the least power of two for each number", () => { expect(pongPayloadLength('10.0.0.2', 6346, 0, 0)).toBe(77); expect(pongPayloadLength('192.168.0.31', 6346, 0, 0)).toBe(109); expect(pongPayloadLength('1.2.3.4', 6346, 1, 5)).toBe(7 * 8 + 13 + 0 + 3); }); }); describe('the Gnutella program in the engine', () => { const started = (files?: VirtualFileSystem) => watchedEngine( scenario, withSnapshot(snapshotOf(scenario), { gnutellaByNodeId: new Map([[ECHO, { maximumKnownPeers: 3 }]]), ...(files === undefined ? {} : { fileSystemsByNodeId: new Map([[ECHO, files]]) }), }), ).engine; /** A connection of the client's to .31's port 6346, a ping sent on it; what came back. */ const ping = (engine: NetworkRuntimeEngine, text: string) => { const socketId = engine.openTcpClientSocket(CLIENT, '192.168.0.31', 6346)!; expect(engine.connectTcp(socketId)).toBe(true); expect(engine.sendTcp(socketId, new TextEncoder().encode(text))).toBe(true); engine.handle({ type: 'advance', toMilliseconds: engine.state.currentTimeMilliseconds + 3_000 }); const answers: string[] = []; for (let answer = engine.receiveTcp(socketId); answer !== undefined; answer = engine.receiveTcp(socketId)) answers.push(new TextDecoder().decode(answer)); return { socketId, answers }; }; it('makes its folder of files to share', () => { const engine = started(); expect(engine.state.topologySnapshot.fileSystemsByNodeId.get(ECHO)!.entry(GNUTELLA_FOLDER).content.kind).toBe('directory'); }); it('answers a ping once, and keeps the connection of the second (Java closes it)', () => { const engine = started(); const first = ping(engine, '7//0x00//0//8//0//192.168.0.20'); expect(first.answers).toEqual(['7//0x01//0//8//109//192.168.0.31//6346//0//0']); const second = ping(engine, '7//0x00//0//8//0//192.168.0.20'); expect(second.answers).toEqual([]); expect(engine.state.socketsById.get(second.socketId)?.tcpState).toBe('ESTABLISHED'); expect(engine.gnutella(ECHO)!.peers).toEqual(['192.168.0.20']); }); it("counts the files of its folder and their size in a pong (a text's in characters)", () => { const files = new VirtualFileSystem(); files.createDirectory(GNUTELLA_FOLDER); files.writeTextFile(`${GNUTELLA_FOLDER}/lied.txt`, 'Hallo Welt'); files.writeBinaryFile(`${GNUTELLA_FOLDER}/daten.bin`, new Uint8Array(5)); files.createDirectory(`${GNUTELLA_FOLDER}/ordner`); files.writeTextFile('/anderswo.txt', 'nicht geteilt'); const engine = started(files); expect(ping(engine, '9//0x00//0//8//0//192.168.0.20').answers).toEqual([`9//0x01//0//8//${pongPayloadLength('192.168.0.31', 6346, 2, 15)}//192.168.0.31//6346//2//15`]); }); it('joins no one at its own address, and forgets its peers when reset', () => { const engine = started(); engine.gnutella(ECHO)!.join('192.168.0.31'); engine.handle({ type: 'advance', toMilliseconds: 3_000 }); expect(engine.capture.frames(scenario.nodes.find((node) => node.id === ECHO)!.ports[0]!.id)).toEqual([]); ping(engine, '5//0x00//0//8//0//192.168.0.20'); engine.gnutella(ECHO)!.reset(); expect(engine.gnutella(ECHO)!.peers).toEqual([]); expect(engine.gnutella(ECHO)!.log.all.map((line) => line.peers)).toEqual([['192.168.0.20'], []]); }); it('closes a connection whose text has no packet head, and leaves a packet of another kind (Java: its worker throws on the one)', () => { const engine = started(); const garbage = ping(engine, 'hallo'); expect(garbage.answers).toEqual([]); expect(engine.state.socketsById.get(garbage.socketId)?.tcpState).not.toBe('ESTABLISHED'); const other = ping(engine, '3//0x42//0//8//0'); expect(other.answers).toEqual([]); expect(engine.state.socketsById.get(other.socketId)?.tcpState).toBe('ESTABLISHED'); }); it('sends a ping on once, and not when its time to live runs out', () => { const WEB = '00000000-0000-0000-0000-000000000403'; const engine = watchedEngine( scenario, withSnapshot(snapshotOf(scenario), { gnutellaByNodeId: new Map([ [ECHO, { maximumKnownPeers: 3 }], [WEB, { maximumKnownPeers: 3 }], ]), }), ).engine; engine.gnutella(WEB)!.join('192.168.0.31'); engine.handle({ type: 'advance', toMilliseconds: 3_000 }); const webCard = scenario.nodes.find((node) => node.id === WEB)!.ports[0]!.id; const forwarded = () => engine.capture.frames(webCard).filter(({ frame }) => { const packet = frame.payload.kind === 'ipv4' ? frame.payload.packet : undefined; return packet?.payload.kind === 'tcp' && packet.senderIPAddress === '192.168.0.31' && new TextDecoder().decode(packet.payload.segment.payload).includes('//0x00//'); }).length; expect(forwarded()).toBe(0); ping(engine, '11//0x00//7//1//0//192.168.0.20'); expect(forwarded()).toBe(0); ping(engine, '12//0x00//0//8//0//192.168.0.20'); ping(engine, '12//0x00//0//8//0//192.168.0.20'); expect(forwarded()).toBe(1); }); });