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>
290 lines
15 KiB
TypeScript
290 lines
15 KiB
TypeScript
// 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']);
|
|
});
|
|
});
|
|
});
|