// 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, queryHitPayloadLength, queryPayloadLength, 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', '42//0x01//0//8//77//10.0.0.2//6346//2'].map(gnutellaPacket)).toEqual( Array(6).fill(undefined), ); }); it('writes and reads a query and a hit, an empty text as one blank', () => { const query = { kind: 'query', guid: 5, hops: 0, ttl: 8, payloadLength: queryPayloadLength('1', ''), minimumSpeed: '1', criteria: '' } as const; expect(gnutellaText(query)).toBe('5//0x80//0//8//8//1// '); expect(gnutellaPacket('5//0x80//1//7//40//1//lied')).toEqual({ kind: 'query', guid: 5, hops: 1, ttl: 7, payloadLength: 40, minimumSpeed: '1', criteria: 'lied' }); const hit = { kind: 'queryHit', guid: 5, hops: 0, ttl: 8, payloadLength: 125, hits: 1, port: 6346, ipAddress: '192.168.0.31', speed: '2', result: '', servent: ' ' } as const; expect(gnutellaText(hit)).toBe('5//0x81//0//8//125//1//6346//192.168.0.31//2// // '); expect(gnutellaPacket('5//0x81//1//7//125//1//6346//192.168.0.31//2//lied.txt: 10 B// ')).toEqual({ ...hit, hops: 1, ttl: 7, result: 'lied.txt: 10 B' }); expect(gnutellaPacket('5//0x81//1//7//125//1//6346//192.168.0.31//2//lied.txt: 10 B')).toBeUndefined(); }); it("counts a query's and a hit's payload in Java's bits (a hit's without the hit itself, as Java's worker leaves it)", () => { expect(queryPayloadLength('1', 'lied')).toBe(40); expect(queryHitPayloadLength(1, 6346, '192.168.0.31', '2', ' ')).toBe(125); expect(queryHitPayloadLength(3, 6346, '10.0.0.2', '2', ' ')).toBe(2 + 13 + 64 + 8 + 8); }); 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); }); it('gives a file of its folder by HTTP with its type, and 404 for one it has not', () => { const files = new VirtualFileSystem(); files.createDirectory(GNUTELLA_FOLDER); files.writeTextFile(`${GNUTELLA_FOLDER}/Lied der Woche.txt`, 'La la la'); files.writeImageFile(`${GNUTELLA_FOLDER}/bild.png`, new Uint8Array([1, 2, 3]), 'image/png'); const engine = started(files); expect(ping(engine, 'GET Lied%20der%20Woche.txt HTTP/1.1\nHost: 192.168.0.31\n').answers).toEqual(['HTTP/1.1 200 OK\nContent-type: txt\n\nLa la la']); expect(ping(engine, 'GET BILD.PNG HTTP/1.1\nHost: 192.168.0.31\n').answers).toEqual(['HTTP/1.1 200 OK\nContent-type: png\n\nAQID']); expect(ping(engine, 'GET fehlt.txt HTTP/1.1\nHost: 192.168.0.31\n').answers).toEqual(['HTTP/1.1 404 Not Found\n']); }); it('answers a query once, and sends it on only while it lives', () => { const files = new VirtualFileSystem(); files.createDirectory(GNUTELLA_FOLDER); files.writeTextFile(`${GNUTELLA_FOLDER}/lied.txt`, 'Hallo Welt'); files.writeTextFile(`${GNUTELLA_FOLDER}/Lied2.TXT`, 'Hallo'); const engine = started(files); const first = ping(engine, '8//0x80//0//8//40//1//LIED'); expect(first.answers.map((answer) => answer.replace(/^\d+\/\/0x81\/\/0\/\/8\/\/\d+\/\//, ''))).toEqual([ '2//6346//192.168.0.31//2//lied.txt: 10 B// ', '2//6346//192.168.0.31//2//Lied2.TXT: 5 B// ', ]); expect(ping(engine, '8//0x80//0//8//40//1//LIED').answers).toEqual([]); expect(ping(engine, '9//0x80//0//8//40//1//xyz').answers).toEqual([]); }); it('tells its window when it clears its results', () => { const engine = started(); engine.gnutella(ECHO)!.clearResults(); expect(engine.gnutella(ECHO)!.log.all.map((line) => line.kind)).toEqual(['results']); }); it('downloads an image as an image, over a file of the name in another case (keeping that name)', () => { const WEB = '00000000-0000-0000-0000-000000000403'; const echoFiles = new VirtualFileSystem(); echoFiles.createDirectory(GNUTELLA_FOLDER); echoFiles.writeImageFile(`${GNUTELLA_FOLDER}/bild.png`, new Uint8Array([1, 2, 3]), 'image/png'); const webFiles = new VirtualFileSystem(); webFiles.createDirectory(GNUTELLA_FOLDER); webFiles.writeTextFile(`${GNUTELLA_FOLDER}/BILD.png`, 'alt'); const engine = watchedEngine( scenario, withSnapshot(snapshotOf(scenario), { gnutellaByNodeId: new Map([ [ECHO, { maximumKnownPeers: 3 }], [WEB, { maximumKnownPeers: 3 }], ]), fileSystemsByNodeId: new Map([ [ECHO, echoFiles], [WEB, webFiles], ]), }), ).engine; const web = engine.gnutella(WEB)!; web.join('192.168.0.31'); engine.handle({ type: 'advance', toMilliseconds: 3_000 }); web.search('bild'); engine.handle({ type: 'advance', toMilliseconds: 6_000 }); expect(web.results).toEqual(['192.168.0.31/bild.png: 3 B']); web.download(0); engine.handle({ type: 'advance', toMilliseconds: 9_000 }); expect(webFiles.entriesInCreationOrder(GNUTELLA_FOLDER).map((entry) => [entry.path, entry.content])).toEqual([ [`${GNUTELLA_FOLDER}/BILD.png`, { kind: 'image', data: new Uint8Array([1, 2, 3]), mediaType: 'image/png' }], ]); }); describe('with a peer that answers as a test wants', () => { const DNS = '00000000-0000-0000-0000-000000000402'; /** A peer on .10 made of bare sockets: to each connection, the answers given for it, in order. */ const fakePeer = (engine: NetworkRuntimeEngine, answersByConnection: string[][]) => { const listener = engine.openTcpServerSocket(DNS, 6346)!; const received: string[] = []; engine.startThread( (function* () { for (const answers of answersByConnection) { const socketId = (yield* engine.acceptTcpSteps(listener))!; const message = yield* engine.receiveTcpSteps(socketId); if (message.type === 'message') received.push(new TextDecoder().decode(message.payload)); for (const answer of answers) yield* engine.sendTcpSteps(socketId, new TextEncoder().encode(answer)); } })(), ); return received; }; it('takes a result once, also when it comes twice', () => { const engine = watchedEngine(scenario, withSnapshot(snapshotOf(scenario), { gnutellaByNodeId: new Map([[CLIENT, { maximumKnownPeers: 3 }]]) })).engine; const ids = [11, 12]; engine.drawGnutellaGuid = () => ids.shift()!; const hit = '12//0x81//0//8//125//1//6346//192.168.0.10//2//a.txt: 1 B// '; fakePeer(engine, [['11//0x01//0//8//109//192.168.0.10//6346//1//1'], [hit, hit]]); engine.gnutella(CLIENT)!.join('192.168.0.10'); engine.handle({ type: 'advance', toMilliseconds: 3_000 }); engine.gnutella(CLIENT)!.search('a'); engine.handle({ type: 'advance', toMilliseconds: 6_000 }); expect(engine.gnutella(CLIENT)!.results).toEqual(['192.168.0.10/a.txt: 1 B']); }); it('sends back a hit only for a query it sent on, and only while the hit lives; a query only while it lives', () => { const engine = started(); const hit = (guid: number, ttl: number) => `${guid}//0x81//0//${ttl}//125//1//6346//192.168.0.10//2//a.txt: 1 B// `; // .31 learns .10 as a peer from .10's ping; then each query .31 sends on to .10 gets the hits given. const received = fakePeer(engine, [[hit(21, 8), hit(99, 8)], [hit(22, 0)], []]); const join = engine.openTcpClientSocket(DNS, '192.168.0.31', 6346)!; expect(engine.connectTcp(join)).toBe(true); engine.sendTcp(join, new TextEncoder().encode('20//0x00//0//8//0//192.168.0.10')); engine.handle({ type: 'advance', toMilliseconds: 3_000 }); expect(engine.gnutella(ECHO)!.peers).toEqual(['192.168.0.10']); expect(ping(engine, '21//0x80//0//8//16//1//a').answers).toEqual([hit(21, 7).replace('//0//7//', '//1//7//')]); expect(ping(engine, '22//0x80//0//8//16//1//a').answers).toEqual([]); // A query at the end of its time to live is answered, not sent on. ping(engine, '23//0x80//7//0//16//1//a'); expect(received.map((text) => text.split('//').slice(0, 4).join('//'))).toEqual(['21//0x80//1//7', '22//0x80//1//7']); }); }); });