feat(core): Gnutella search and download after Java, measured against Java
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>
This commit is contained in:
+3
-3
@@ -27,7 +27,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
||||
| `src/runtime/apps/emailServer.ts` | Java `EmailServer`, `SMTPServer`, `SMTPMitarbeiter`, `POP3Server`, `POP3Mitarbeiter`, `EmailKonto` | neu nach Java (F3.3a): Konten einer Domain, SMTP auf Port 25 speichert Mails an eigene Konten (wie Java neu gelesen) und schickt bei unbekannten Empfängern eine Benachrichtigung an einen Absender der Domain, POP3 auf Port 110 gibt Mails ab 0 nummeriert aus; beide zusammen ein- und ausgeschaltet, POP3 zuerst; SMTP-Antworten in der Sprache des Projekts; Fensterzeilen wie Java. An Java-Filius gemessen (`EmailRun`, `MailCases`). Abweichend von Java: nach einer Mail ist der Umschlag neu (Java antwortet dem nächsten Befehl 503 und behält Absender, Empfänger und Text); ein Befehl, den der Zustand nicht nimmt, bekommt 503 (Java schweigt), HELO beginnt neu; in DATA ist alles Text, auch `quit`; die Domain gilt in jeder Schreibweise; ein Empfänger ohne `@` ist unbekannt, ein leerer Absender bekommt keine Benachrichtigung (Java wirft), eine Benachrichtigung an eine Mail ohne From geht an den Absender des Umschlags (Java: `To: null`); POP3: eine Mail lässt sich mehrmals abrufen, eine fehlende Mail, `LIST` ohne Mails und falsche Argumente geben `-ERR` (Java: leere Mail, leere Antwort, Abbruch), `LIST n` listet die eine Mail, Nummern bleiben nach `DELE` (Javas `LIST` zählt neu), vor der Anmeldung `-ERR unknown command`, `QUIT` immer `+OK`. Seit F3.3b leitet der Server Mails an fremde Domains per MX weiter (in seinem eigenen Thread, je Weiterleitung ein neuer SMTP-Client) und schickt eine Benachrichtigung an einen fremden Absender mit leerem Absender an dessen Domain. Noch nicht: die Kontodatei `/mailserver/konten.txt` zur Laufzeit |
|
||||
| `src/runtime/apps/emailClient.ts` | Java `EmailAnwendung`, `SMTPClient`, `POP3Client`, das Senden aus `GUIApplicationEmailAnwendungWindow` | neu nach Java (F3.3b): das E-Mail-Programm sendet eine Mail an den SMTP-Server seines Kontos, eine Verbindung je Empfänger (An, Cc, Bcc), und holt die Mails seines Kontos vom POP3-Server (jede abgerufen, dann gelöscht); die Prüfungen des Fensters (kein Konto, Adresse, die Javas Muster nicht nimmt, kein Empfänger); Meldungen wie Javas Beobachter (Verbindung, versendet, getrennt, Ausnahmen); der SMTP-Client dient auch dem Server zum Weiterleiten. An Java-Filius gemessen (`EmailClientRun`). Abweichend von Java: der SMTP-Port des Kontos gilt (Java verbindet immer zu 25); drei SYN statt einem |
|
||||
| `src/runtime/apps/filePrograms.ts` | Java `FileExplorer`, `TextEditor`, `ImageViewer` und ihre Fenster, `config/filetypes.txt`, die Meldungen `sw_fileexplorer_msg1`–`3`, `fileexplorer_msg19` | neu nach Java (F3.5b): die Dateioperationen hinter den Knöpfen von Datei-Explorer (Ordnerliste, neuer Ordner, löschen, kopieren/ausschneiden/einfügen, umbenennen, importieren), Texteditor (speichern, speichern unter) und Bildbetrachter, auf den Dateien des Knotens; an Java-Filius gemessen (`FileCases`). Abweichend von Java: ein Ordnername, der zu keinem Namen wird, legt nichts an; Einfügen auf einen vorhandenen Namen wird abgelehnt (Java: zweiter Eintrag gleichen Namens); Umbenennen auf einen Namen mit `/` oder mit Leerzeichen am Rand wird abgelehnt, eine reine Änderung der Groß-/Kleinschreibung ausgeführt (Java lehnt sie ab); „Speichern unter“ und Import auf einen Ordnernamen werden abgelehnt (Java wirft); die Größe einer Textdatei sind ihre Zeichen |
|
||||
| `src/runtime/apps/gnutella.ts` | Java `filius.software.dateiaustausch` (`PeerToPeerAnwendung`, `PeerToPeerServer`, `PeerToPeerServerMitarbeiter`, `PeerToPeerClient`, `PeerToPeerClientMitarbeiter`, `PingPaket`, `PongPaket`) | neu nach Java (F3.6a): Beitreten mit Ping und Pong in Javas Textformat über Port 6346, Weiterleiten an die eigenen Teilnehmer, Pongs auf dem Weg des Pings zurück, Höchstzahl der Teilnehmer, Zurücksetzen, Ordner `/peer2peer`; an Java-Filius gemessen (`GnutellaRun`). Die Swift-Fassung (`TopologyRuntimeGnutella.swift`, eigenes Protokoll) nicht übernommen. Abweichend von Java: ein zweites Mal ankommender Ping bekommt keinen Pong, und seine Verbindung bleibt (Java wirft und schließt sie) |
|
||||
| `src/runtime/apps/gnutella.ts` | Java `filius.software.dateiaustausch` (`PeerToPeerAnwendung`, `PeerToPeerServer`, `PeerToPeerServerMitarbeiter`, `PeerToPeerClient`, `PeerToPeerClientMitarbeiter`, `PingPaket`, `PongPaket`) | neu nach Java (F3.6a): Beitreten mit Ping und Pong in Javas Textformat über Port 6346, Weiterleiten an die eigenen Teilnehmer, Pongs auf dem Weg des Pings zurück, Höchstzahl der Teilnehmer, Zurücksetzen, Ordner `/peer2peer`; an Java-Filius gemessen (`GnutellaRun`). Die Swift-Fassung (`TopologyRuntimeGnutella.swift`, eigenes Protokoll) nicht übernommen. Seit F3.6b Suche mit Query und Treffern (weitergeleitet wie Pings, Treffer auf dem Weg der Query zurück), Ergebnisse, Download per HTTP über 6346 (`QueryPaket`, `QueryHitPaket`, `httpAnfrageVerarbeiten`, `herunterladenDatei`). Abweichend von Java: ein zweites Mal ankommender Ping bekommt keinen Pong, und seine Verbindung bleibt (Java wirft und schließt sie); wer Treffer geschickt hat, hält die Verbindung, damit auch die Treffer der weiter entfernten Teilnehmer zurückkommen (Java schließt sie, die gehen verloren); Größen in Treffer und Pong in Zeichen (Java: Base64-dekodiert) |
|
||||
| `src/runtime/apps/terminal.ts`, `src/runtime/apps/terminalTexts.ts` | Java `filius.software.lokal.Terminal`, die Eingabe aus `GUIApplicationTerminalWindow`, die Texte `sw_terminal_*` und `terminal_msg*` des Bündels | neu nach Java (F3.4a): die Befehlszeile mit Javas Zerlegung der Eingabe, Verteilung nach dem Befehlsnamen und den lokalen Befehlen `ls`/`dir`, `cd`, `pwd`, `mkdir`, `touch`, `cp`/`copy`, `mv`/`move`, `rm`/`del`, `cat`/`type`, `test`, `help`, `ipconfig`, `route`; Texte in der Sprache des Projekts, aus dem von Java geladenen Bündel erzeugt. An Java-Filius gemessen (`TerminalCases`). Abweichend von Java: die Größe einer Textdatei sind ihre Zeichen (Java dekodiert Base64); ein fehlender Ordner ist für `ls` „nicht vorhanden“, `ls /` nennt die Wurzel; `cd` auf eine Datei, `touch`/`mkdir` in fehlenden Ordnern und `cp` auf einen Ordnernamen scheitern; `..` bleibt an der Wurzel; `rm` und `mv` nehmen absolute Pfade absolut, die Wurzel ist nicht löschbar, nach dem Löschen des Arbeitsordners gilt der nächste verbliebene darüber; `mv` auf die Datei selbst benennt sie um. Seit F3.4b die Netzbefehle `ping` (Strg+C über `interrupt`), `host`, `nslookup`, `arp` und `netstat`, mit Javas Laufmarken; ein unbekannter Befehl wird wie bei Java sofort gemeldet, auch während ein anderer läuft; seit F3.4c `traceroute` und `tcpdump` (`arpsend` nicht gebaut, der Befehl ist unbekannt); an Java-Filius gemessen (`TerminalRun`). Abweichend von Java: `ping` nennt den Host, wo Java seinen ersten Parameter nennt (`-b` in der Statistik), und nimmt vor dem Host nur `-b` (Java nimmt jedes Wort und pingt einen Broadcast); `arp` und `netstat` listen in der Reihenfolge des Lernens bzw. Anlegens (Java: Reihenfolge einer `HashMap`); `netstat` zeigt von einem UDP-Server nur den lauschenden Socket (Java auch einen für jede je beantwortete Adresse, nie geschlossen); `traceroute` sagt sein Fazit vor der Endmarke (Java danach, wo sein Fenster keinen Text mehr zeigt) und nennt ein Ziel beim 20. Sprung erreicht (Java: nicht erreicht); `tcpdump` meldet jeden Frame sofort (Java schaut alle 100 ms) |
|
||||
| `src/runtime/apps/echo.ts` | Java `filius.software.clientserver` (`ServerBaustein`, `ServerBausteinMitarbeiter`, `ClientAnwendung`, `ClientBaustein`) | neu nach Java statt nach Swift (F3.1): Echo-Server (auf `server.ts`, sendet jede Nachricht zurück) und einfacher Client (verbinden über Adresse oder Namen, senden, empfangen, trennen), mit Javas Fensterzeilen. An Java-Filius gemessen. Abweichend von Java: Der Server merkt das Ausschalten, der Client die Verbindung sofort (Java alle 100 ms); ohne Route scheitert der Client sofort (Java wartet ewig ohne Meldung); ausgeschaltet bestätigt der Server noch das FIN seiner Verbindungen (Java gibt den Port frei, das FIN bleibt unbeantwortet) |
|
||||
| `src/runtime/dns.ts` | Java `filius.software.dns.DNSNachricht`, `Query`, `ResourceRecord` | neu nach Java statt nach Swift (Swift ersetzt Javas Textnachrichten durch `FILIUS-DNS-TYPED/1`): Kopfzeile `ID=… QR=… RCODE=… QDCOUNT=… …`, Frage- und Eintragszeilen, Suche nach Namen ohne Rücksicht auf Groß- und Kleinschreibung, NS der längsten Zone, sonst der Wurzel |
|
||||
@@ -42,7 +42,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
||||
| `src/runtime/capture.ts` | Java `filius.rahmenprogramm.nachrichten.Lauscher` | neu nach Java (Swift hat stattdessen die Paketspur je Schritt): der Mitschnitt je Netzwerkkarte, jeder gesendete und empfangene Frame einmal mit Zeit und Nummer, wie Java auch Frames an andere Karten und solche, die die Firewall verwirft; Switches ohne Liste; verlorene Frames getrennt; bleibt über Stopp und Neustart, geleert nur auf Wunsch. Abweichend von Java: je Port statt je MAC-Adresse (Java legt zwei Karten mit gleicher MAC zusammen) und höchstens 10.000 Frames je Karte, die ältesten 1.000 fallen auf einmal heraus, die Nummern bleiben (Java behält alles) |
|
||||
| `src/util/equal.ts` | — | neu (Ersatz für Swifts `Equatable`) |
|
||||
| `src/util/swiftString.ts` | — | neu: Swift-String-Verhalten, das der übertragene Code stillschweigend voraussetzt (Trimmen, Kleinschreibung je Skalar, kanonische Gleichheit, Sortierung nach Skalarwerten, Base64 wie Foundation); am Swift-Orakel gemessen, Leerraum und Steuerzeichen über ganz Unicode |
|
||||
| `src/model/virtualFileSystem.ts`, `src/model/defaultImages.ts` | `State/TopologyVirtualFileSystem.swift` | übertragen; Schlüssel in NFC statt Swifts kanonischer Wörterbuch-Gleichheit; Steuerzeichen nach Unicode Cc+Cf, ohne Foundations Tabellenfehler in Ebene 14; seit F3.4a die Einträge eines Ordners auch in der Reihenfolge ihres Entstehens, wie Javas Dateibaum (`entriesInCreationOrder`); seit F3.5b `renameInPlace`: ein Eintrag behält beim Umbenennen seine Stelle, auch bei reiner Änderung der Groß-/Kleinschreibung, wie Javas Knoten (`renameItem` bleibt Swifts Verschieben) |
|
||||
| `src/model/virtualFileSystem.ts`, `src/model/defaultImages.ts` | `State/TopologyVirtualFileSystem.swift` | übertragen; Schlüssel in NFC statt Swifts kanonischer Wörterbuch-Gleichheit; Steuerzeichen nach Unicode Cc+Cf, ohne Foundations Tabellenfehler in Ebene 14; seit F3.4a die Einträge eines Ordners auch in der Reihenfolge ihres Entstehens, wie Javas Dateibaum (`entriesInCreationOrder`); seit F3.5b `renameInPlace`: ein Eintrag behält beim Umbenennen seine Stelle, auch bei reiner Änderung der Groß-/Kleinschreibung, wie Javas Knoten (`renameItem` bleibt Swifts Verschieben); seit F3.6b `javaFileTypeName` und `fileContentOfJavaType` (Javas Dateityp aus der Art und zurück), aus Export und Import von `packages/fls` hierher, damit auch der Gnutella-Download sie nutzt |
|
||||
| `test/virtualFileSystem.test.ts`, `test/vfs.*.json` | `TopologyProjectPersistenceTests.swift`, `TopologyEditorReducerTests.swift` (drei Dateisystem-Tests, als Fälle) | übertragen und erweitert: 11 Fälle, Ergebnis aus dem Swift-Kern |
|
||||
| `test/strings.test.ts`, `test/strings.*.json` | — | neu: Fälle und ganze Zeichenmengen, Ergebnis aus dem Swift-Kern |
|
||||
| `test/engine.test.ts` | `TopologyNetworkRuntimeEngineTests.swift` (21 von 72 Tests) | übertragen; `nextInt`- und `nextDouble`-Referenzwerte an OpenJDK 21 gemessen; die DHCP-Client-Tests seit F2.3b nach Javas Verhalten (neun, davon zwei benannte Java-Fehler, die der Kern nicht übernimmt); seit F2.8a sechs Tests für den Mitschnitt (über Neustart, Leeren, Obergrenze, verlorene Frames, Switches) |
|
||||
@@ -62,7 +62,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
||||
| `test/emailClientJava.test.ts`, `test/scenarios/email-client-java-*.java.json` | — | neu (F3.3b): sechs Abläufe mit dem E-Mail-Programm und zwei Servern gegen Java-Filius (`EmailClientRun`): senden mit Weiterleitung, abholen, Server per Name und Benachrichtigungen von beiden Servern, falsches Passwort, kein Server, unbekannter Servername; je Karte Frames, Zeiten, Tabellen, die Meldungen von Programm und Servern, Posteingang, Gesendete und Konten am Ende; Javas 100-ms-Blick kostet je Verbindung (und je DNS-Antwort, auf die es so wartet) eine Periode Toleranz; ein benannter Java-Fehler (nur ein SYN); bei `byName` die Startnummern ohne die Million (Thread-Wettlauf in Java) |
|
||||
| `test/emailClient.test.ts` | — | neu (F3.3b): was das Fenster ablehnt, Bcc, der SMTP-Port des Kontos, eine ungültige Kontoadresse, eine Verbindung, die beim Warten endet, eine Mail ohne Absender aus dem Modell, die Ordner im Schnappschuss |
|
||||
| `test/filePrograms.test.ts`, `test/file-programs.cases.txt`, `test/file-programs.{de,en}.java.json` | — | neu (F3.5b): sechs Fälle der Dateiprogramme gegen Java-Filius (`FileCases`), je Schritt Rückgabe und alle Dateien, Deutsch und Englisch, benannte Java-Fehler (`JAVA_BUGS`); dazu Dateitypen, Ordnerliste, Bildbetrachter, leerer Ordnername |
|
||||
| `test/gnutellaJava.test.ts`, `test/gnutella.test.ts`, `test/scenarios/gnutella-java-*.java.json` | — | neu (F3.6a): sechs Abläufe des Beitretens gegen Java-Filius (`GnutellaRun`), je Karte die Frames samt Tabellen (wo viele Verbindungen zugleich laufen: je Verbindung und Richtung mit ihren Daten), die Teilnehmer jedes Programms mit Zeit; Javas GUIDs ersetzen die gezogenen, damit Längen und Sequenznummern gleich bleiben; dazu Paketformat, doppelter Ping, Kopf ohne Paket, Lebensdauer, Dateien im Pong |
|
||||
| `test/gnutellaJava.test.ts`, `test/gnutella.test.ts`, `test/scenarios/gnutella-java-*.java.json` | — | neu (F3.6a, F3.6b): sechs Abläufe des Beitretens und zwei der Suche mit Download gegen Java-Filius (`GnutellaRun`), Ergebnisse und Dateien am Ende, je Karte die Frames samt Tabellen (wo viele Verbindungen zugleich laufen: je Verbindung und Richtung mit ihren Daten), die Teilnehmer jedes Programms mit Zeit; Javas GUIDs ersetzen die gezogenen, damit Längen und Sequenznummern gleich bleiben; dazu Paketformat, doppelter Ping, Kopf ohne Paket, Lebensdauer, Dateien im Pong |
|
||||
| `test/terminal.test.ts`, `test/terminal.cases.txt`, `test/terminal.{de,en}.java.json` | — | neu (F3.4a): 16 Fälle der Befehlszeile gegen Java-Filius (`TerminalCases`), je Zeile die Handlung des Fensters, was das Programm sagt, Arbeitsordner und alle Dateien, Deutsch und Englisch; Javas Größe von Textdateien überall als Java-Fehler korrigiert, dazu acht Fälle mit benannten Java-Fehlern (`JAVA_BUGS`); ein Bild bleibt beim Kopieren ein Bild; jeder Text der Tabelle ist wörtlich Javas |
|
||||
| `test/terminalJava.test.ts`, `test/scenarios/terminal-net.scenario.json`, `test/scenarios/terminal-java-*.java.json` | — | neu (F3.4b): zwölf Abläufe der Netzbefehle gegen Java-Filius (`TerminalRun`) auf einem LAN mit DNS-Server, Router und Rechner dahinter: `ping` im LAN, über den Router, ins Leere, hinter dem Router unerreichbar, ohne Route am Router, Broadcast, eigene Adresse, Fehler, Strg+C; `host` und `nslookup`, auch ohne erreichbaren DNS-Server; `arp`; `netstat` mit TCP-Verbindung und DNS-Server; je Karte die Frames samt Javas Tabellen, je Rechner Texte und Laufmarken mit Zeit; benannte Java-Fehler (`JAVA_BUGS`); die Reihenfolge der Antworten auf einen Broadcast ist ein Wettlauf von Javas Threads (je Sekunde als Menge verglichen); seit F3.4c neun Abläufe mit `traceroute` (LAN, über den Router, unerreichbar, ohne Route, ins Leere, eigene Adresse, Fehler, Strg+C) und `tcpdump` auf zwei Rechnern; ein Wächter verwirft Aufnahmen, in denen Java sein Aufwecken verpasst hat; dazu zehn Einzelfälle (traceroute über 20 Sprünge, Strg+C vor dem Start, fremde Echo-Antworten, gerundeter Verlust, Broadcast bei anderer Maske, zufälliger Identifier und sein Weiterzählen, gleich lange Routen) |
|
||||
| `test/apps.test.ts` | — | neu (F3.1): wie Threads aufwachen (nach dem Ereignis, nach einer Pause hinter den anderen, ein Weckruf nur für sein Warten, Reihenfolge des Beginns, kein Warten auf schon Geltendes), und was die Java-Abläufe nicht zeigen: belegter Port, Name über DNS und ohne DNS, keine Route, unbestätigtes Senden, Neustart, eine Nachricht vor dem Schließen |
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
swiftEqual,
|
||||
swiftHasPrefix,
|
||||
swiftLowercased,
|
||||
swiftPathExtension,
|
||||
swiftTrim,
|
||||
} from '../util/swiftString.js';
|
||||
import { DEFAULT_IMAGE_FILES, LEGACY_DEFAULT_IMAGE_BASE64 } from './defaultImages.js';
|
||||
@@ -146,6 +147,70 @@ function keyOf(path: string): string {
|
||||
return path.normalize('NFC');
|
||||
}
|
||||
|
||||
/** The text types Java's importer reads as text, named by a file's extension. */
|
||||
const TEXT_EXTENSIONS = new Set(['txt', 'html', 'htm', 'css', 'xml', 'csv', 'conf', 'log']);
|
||||
|
||||
/** "image/png" → "png", "image/jpeg" → "jpg", as Java names them. */
|
||||
function javaSubtype(mediaType: string): string {
|
||||
const name = mediaType.slice(mediaType.indexOf('/') + 1);
|
||||
return name === 'jpeg' ? 'jpg' : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* A file's type as Java FILIUS keeps it (Datei.dateiTyp), from what the
|
||||
* model keeps: a text file its extension when Java reads that as text, else
|
||||
* "text"; an image or binary file its media type's subtype (none: empty).
|
||||
* The project export writes it, and a server that names it (Gnutella's
|
||||
* Content-type) says it.
|
||||
*/
|
||||
export function javaFileTypeName(name: string, content: Exclude<VirtualFileContent, { kind: 'directory' }>): string {
|
||||
if (content.kind === 'text') {
|
||||
const dot = name.lastIndexOf('.');
|
||||
const extension = dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
|
||||
return TEXT_EXTENSIONS.has(extension) ? extension : 'text';
|
||||
}
|
||||
if (content.kind === 'image') return javaSubtype(content.mediaType);
|
||||
return content.mediaType === undefined ? '' : javaSubtype(content.mediaType);
|
||||
}
|
||||
|
||||
const IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'image']);
|
||||
const TEXT_TYPES = new Set(['', 'text', 'txt', 'text/txt', 'html', 'htm', 'css', 'xml', 'csv', 'conf', 'log']);
|
||||
|
||||
/**
|
||||
* Swift's importedContent: an image by type or extension (Base64), text by
|
||||
* type, else Base64 as binary data, else text — each fallback with a warning.
|
||||
*/
|
||||
/**
|
||||
* A file's content as the model keeps it, from Java's type and content
|
||||
* (Datei.dateiTyp, getDateiInhalt): an image or an unknown type decoded
|
||||
* from Base64, a text type as text; what is no Base64 kept as text or bytes,
|
||||
* with a warning. The project import reads files so, and a program that
|
||||
* gets a file from another (Gnutella's download).
|
||||
*/
|
||||
export function fileContentOfJavaType(rawType: string, content: string, path: string, warn: (message: string) => void = () => {}): VirtualFileContent {
|
||||
const type = swiftLowercased(swiftTrim(rawType));
|
||||
const extension = swiftLowercased(swiftPathExtension(path));
|
||||
const binary = (data: Uint8Array): VirtualFileContent =>
|
||||
type === '' ? { kind: 'binary', data } : { kind: 'binary', data, mediaType: `application/${type}` };
|
||||
if (IMAGE_TYPES.has(type) || IMAGE_TYPES.has(extension)) {
|
||||
const data = swiftBase64Decode(content);
|
||||
if (data !== undefined) {
|
||||
const subtype = type === 'image' || type === '' ? (extension === '' ? 'png' : extension) : type;
|
||||
return { kind: 'image', data, mediaType: `image/${subtype === 'jpg' ? 'jpeg' : subtype}` };
|
||||
}
|
||||
warn(`Java file '${path}' declared image type '${type}' but was not valid Base64; imported as UTF-8 binary data.`);
|
||||
return binary(encoder.encode(content));
|
||||
}
|
||||
if (TEXT_TYPES.has(type)) return { kind: 'text', text: content };
|
||||
const data = swiftBase64Decode(content);
|
||||
if (data !== undefined && (data.length > 0 || swiftTrim(content) === '')) {
|
||||
warn(`Java file '${path}' used unknown type '${type}'; preserved as binary data.`);
|
||||
return binary(data);
|
||||
}
|
||||
warn(`Java file '${path}' used unknown type '${type}'; preserved as UTF-8 text.`);
|
||||
return { kind: 'text', text: content };
|
||||
}
|
||||
|
||||
export class VirtualFileSystem {
|
||||
private store: Store;
|
||||
|
||||
|
||||
@@ -2,21 +2,32 @@
|
||||
// with its PeerToPeerServer on port 6346 and PeerToPeerClient): a host joins
|
||||
// the network by a ping to one peer, which answers with a pong, takes the
|
||||
// host as a peer and sends the ping on to its own peers, whose pongs come
|
||||
// back the same way; each knows at most its number of peers. The messages
|
||||
// are Java's text packets (PeerToPeerPaket.toString), each on a connection
|
||||
// of its own that stays open, as Java's do.
|
||||
// back the same way; each knows at most its number of peers. A search goes
|
||||
// to the peers as a query, each sends it on and answers with a hit for each
|
||||
// of its files in /peer2peer whose name holds the words; hits come back the
|
||||
// way the query came. A hit's file is fetched from its host by HTTP on the
|
||||
// same port. The messages are Java's text packets (PeerToPeerPaket
|
||||
// .toString), each on a connection of its own that stays open, as Java's do.
|
||||
//
|
||||
// Departures from Java (gnutellaJava.test.ts names each):
|
||||
// - a ping that comes a second time gets no pong, and its connection stays
|
||||
// (Java means so, but its worker throws on the missing pong and closes the
|
||||
// connection).
|
||||
// connection);
|
||||
// - a peer that answered a query with hits keeps its connection, so that the
|
||||
// hits of the peers it sent the query on to come back too (Java closes it
|
||||
// after its own hits, and theirs are lost);
|
||||
// - a hit names a text file's size in characters (Java decodes the text as
|
||||
// Base64: "Hallo Welt" is 6 B).
|
||||
|
||||
import type { Id } from '../../ids.js';
|
||||
import { fileContentOfJavaType, javaFileTypeName, type VirtualFileContent } from '../../model/virtualFileSystem.js';
|
||||
import type { GnutellaConfiguration } from '../config.js';
|
||||
import { formatHttpRequest, formatHttpResponse, HTTP_GET, httpPath, parseHttpRequest, parseHttpResponse } from '../http.js';
|
||||
import type { NetworkRuntimeEngine } from '../engine.js';
|
||||
import type { Steps } from '../threads.js';
|
||||
import { ProgramLog } from './program.js';
|
||||
import { TcpServerProgram } from './server.js';
|
||||
import { javaBase64 } from '../../util/javaBase64.js';
|
||||
|
||||
/** PeerToPeerServer, PeerToPeerClientMitarbeiter: Gnutella's port. */
|
||||
export const GNUTELLA_PORT = 6346;
|
||||
@@ -47,9 +58,27 @@ export interface PongPacket extends GnutellaHead {
|
||||
readonly bytes: number;
|
||||
}
|
||||
|
||||
export type GnutellaPacket = PingPacket | PongPacket;
|
||||
/** QueryPaket: the least speed asked for (Java's search asks "1") and the words searched for. */
|
||||
export interface QueryPacket extends GnutellaHead {
|
||||
readonly kind: 'query';
|
||||
readonly minimumSpeed: string;
|
||||
readonly criteria: string;
|
||||
}
|
||||
|
||||
const PAYLOAD_KIND = { ping: '0x00', pong: '0x01' } as const;
|
||||
/** QueryHitPaket: how many hits its host has, its port and address, its speed, the hit ("name: size B"), its servent id. */
|
||||
export interface QueryHitPacket extends GnutellaHead {
|
||||
readonly kind: 'queryHit';
|
||||
readonly hits: number;
|
||||
readonly port: number;
|
||||
readonly ipAddress: string;
|
||||
readonly speed: string;
|
||||
readonly result: string;
|
||||
readonly servent: string;
|
||||
}
|
||||
|
||||
export type GnutellaPacket = PingPacket | PongPacket | QueryPacket | QueryHitPacket;
|
||||
|
||||
const PAYLOAD_KIND = { ping: '0x00', pong: '0x01', query: '0x80', queryHit: '0x81' } as const;
|
||||
|
||||
/** PeerToPeerPaket.anzahlBenoetigterBits: the least exponent whose power of two is at least the number. */
|
||||
function bitsFor(value: number): number {
|
||||
@@ -63,10 +92,35 @@ export function pongPayloadLength(ipAddress: string, port: number, files: number
|
||||
return ipAddress.length * 8 + bitsFor(port) + bitsFor(files) + bitsFor(bytes);
|
||||
}
|
||||
|
||||
/** PeerToPeerPaket.toString and its subclasses': the fields joined by "//". */
|
||||
/** QueryPaket.payloadLengthBerechnen. */
|
||||
export function queryPayloadLength(minimumSpeed: string, criteria: string): number {
|
||||
return (minimumSpeed.length + criteria.length) * 8;
|
||||
}
|
||||
|
||||
/** QueryHitPaket.payloadLengthBerechnen, as Java's worker leaves it: counted before the hit is put in (an empty one). */
|
||||
export function queryHitPayloadLength(hits: number, port: number, ipAddress: string, speed: string, servent: string): number {
|
||||
return bitsFor(hits) + bitsFor(port) + ipAddress.length * 8 + speed.length * 8 + servent.length * 8;
|
||||
}
|
||||
|
||||
/** PeerToPeerPaket.toString and its subclasses': the fields joined by "//"; an empty text written as one blank. */
|
||||
export function gnutellaText(packet: GnutellaPacket): string {
|
||||
const head = [packet.guid, PAYLOAD_KIND[packet.kind], packet.hops, packet.ttl, packet.payloadLength];
|
||||
const body = packet.kind === 'ping' ? [packet.ipAddress] : [packet.ipAddress, packet.port, packet.files, packet.bytes];
|
||||
const blankForEmpty = (text: string) => (text === '' ? ' ' : text);
|
||||
let body: (string | number)[];
|
||||
switch (packet.kind) {
|
||||
case 'ping':
|
||||
body = [packet.ipAddress];
|
||||
break;
|
||||
case 'pong':
|
||||
body = [packet.ipAddress, packet.port, packet.files, packet.bytes];
|
||||
break;
|
||||
case 'query':
|
||||
body = [blankForEmpty(packet.minimumSpeed), blankForEmpty(packet.criteria)];
|
||||
break;
|
||||
case 'queryHit':
|
||||
body = [packet.hits, packet.port, packet.ipAddress, packet.speed, blankForEmpty(packet.result), blankForEmpty(packet.servent)];
|
||||
break;
|
||||
}
|
||||
return [...head, ...body].join('//');
|
||||
}
|
||||
|
||||
@@ -93,11 +147,16 @@ export function gnutellaPacket(text: string): GnutellaPacket | undefined {
|
||||
if (fields[1] === PAYLOAD_KIND.pong && fields[5] !== undefined && port !== undefined && files !== undefined && bytes !== undefined) {
|
||||
return { kind: 'pong', ...head, ipAddress: fields[5], port, files, bytes };
|
||||
}
|
||||
if (fields[1] === PAYLOAD_KIND.query && fields[5] !== undefined && fields[6] !== undefined) return { kind: 'query', ...head, minimumSpeed: fields[5], criteria: fields[6] };
|
||||
const [hits, hitPort] = [number(5), number(6)];
|
||||
if (fields[1] === PAYLOAD_KIND.queryHit && hits !== undefined && hitPort !== undefined && fields.length >= 11) {
|
||||
return { kind: 'queryHit', ...head, hits, port: hitPort, ipAddress: fields[7]!, speed: fields[8]!, result: fields[9]!, servent: fields[10]! };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** What the program tells its window: its known peers, whenever they change. */
|
||||
export type GnutellaLogEntry = { readonly kind: 'peers'; readonly peers: readonly string[] };
|
||||
/** What the program tells its window: its known peers, or its search results ("address/name: size B"), whenever they change. */
|
||||
export type GnutellaLogEntry = { readonly kind: 'peers'; readonly peers: readonly string[] } | { readonly kind: 'results'; readonly results: readonly string[] };
|
||||
|
||||
/** PeerToPeerServer: answers pings on 6346; a connection's worker remembers the id of its last packet. */
|
||||
class GnutellaServer extends TcpServerProgram {
|
||||
@@ -125,8 +184,13 @@ class GnutellaServer extends TcpServerProgram {
|
||||
return;
|
||||
}
|
||||
const text = new TextDecoder().decode(received.payload);
|
||||
// PeerToPeerServerMitarbeiter.verarbeiteNachricht: a text without a packet's head throws, and the worker closes the
|
||||
// connection; a packet of another kind is left.
|
||||
const send = (answer: string) => this.engine.sendTcpSteps(socketId, new TextEncoder().encode(answer));
|
||||
// PeerToPeerServerMitarbeiter.verarbeiteNachricht: a request for a file; else a text without a packet's head
|
||||
// throws, and the worker closes the connection; a packet of another kind is left.
|
||||
if (text.startsWith(HTTP_GET)) {
|
||||
yield* send(this.peer.fileResponse(text));
|
||||
continue;
|
||||
}
|
||||
if (!hasGnutellaHead(text)) {
|
||||
this.lastGuidBySocket.delete(socketId);
|
||||
return;
|
||||
@@ -134,7 +198,8 @@ class GnutellaServer extends TcpServerProgram {
|
||||
const packet = gnutellaPacket(text);
|
||||
if (packet === undefined) continue;
|
||||
this.lastGuidBySocket.set(socketId, packet.guid);
|
||||
if (packet.kind === 'ping') yield* this.peer.answerPing(packet, address, (text) => this.engine.sendTcpSteps(socketId, new TextEncoder().encode(text)));
|
||||
if (packet.kind === 'ping') yield* this.peer.answerPing(packet, address, send);
|
||||
else if (packet.kind === 'query') yield* this.peer.answerQuery(packet, address, send);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,10 +220,15 @@ class GnutellaServer extends TcpServerProgram {
|
||||
export class GnutellaPeer {
|
||||
readonly log: ProgramLog<GnutellaLogEntry>;
|
||||
private peersValue: string[] = [];
|
||||
private resultsValue: string[] = [];
|
||||
private readonly maximumKnownPeers: number;
|
||||
/** The ids of the program's own pings (eigeneAnfragen), of others' (fremdeAnfragen), and of those it sent on (schonmalVerschicktListe). */
|
||||
/**
|
||||
* The ids of the program's own pings and queries (eigeneAnfragen), and of
|
||||
* those it sent on (schonmalVerschicktListe). Java also keeps others'
|
||||
* (fremdeAnfragen) and sends back only their hits; a hit goes back only on
|
||||
* a connection whose last packet had its id, which comes to the same.
|
||||
*/
|
||||
private readonly ownRequests = new Set<number>();
|
||||
private readonly foreignRequests = new Set<number>();
|
||||
private readonly alreadySent = new Set<number>();
|
||||
private readonly server: GnutellaServer;
|
||||
|
||||
@@ -177,6 +247,11 @@ export class GnutellaPeer {
|
||||
return this.peersValue;
|
||||
}
|
||||
|
||||
/** The search results (holeErgebnisse): each hit's host address and its hit, "address/name: size B". */
|
||||
get results(): readonly string[] {
|
||||
return this.resultsValue;
|
||||
}
|
||||
|
||||
/** The node's first address (Betriebssystem.primaryIPAdresse). */
|
||||
private get ownAddress(): string {
|
||||
return this.engine.networkInterfaces(this.nodeId)[0]?.ipAddress ?? '';
|
||||
@@ -196,6 +271,46 @@ export class GnutellaPeer {
|
||||
this.log.write({ kind: 'peers', peers: [] });
|
||||
}
|
||||
|
||||
/** sucheDatei: no results, a query for the words to every peer. */
|
||||
search(criteria: string): void {
|
||||
this.setResults([]);
|
||||
const query: QueryPacket = {
|
||||
kind: 'query',
|
||||
guid: this.engine.drawGnutellaGuid(),
|
||||
hops: 0,
|
||||
ttl: 8,
|
||||
payloadLength: queryPayloadLength('1', criteria),
|
||||
minimumSpeed: '1',
|
||||
criteria,
|
||||
};
|
||||
this.ownRequests.add(query.guid);
|
||||
this.sendQueryOn(query, this.ownAddress);
|
||||
}
|
||||
|
||||
/** loescheSuchergebnisse. */
|
||||
clearResults(): void {
|
||||
this.setResults([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* herunterladenDatei: the result's file asked for by HTTP from its host
|
||||
* (the result's address, and its name up to the ":"), and kept in
|
||||
* /peer2peer when the host has it.
|
||||
*/
|
||||
download(index: number): void {
|
||||
const result = this.resultsValue[index];
|
||||
if (result === undefined) return;
|
||||
const [owner, rest] = result.split('/').filter((part) => part !== '');
|
||||
const name = rest?.split(':').find((part) => part !== '');
|
||||
if (owner === undefined || name === undefined) return;
|
||||
this.engine.startThread(this.fetchFile(owner, name));
|
||||
}
|
||||
|
||||
private setResults(results: string[]): void {
|
||||
this.resultsValue = results;
|
||||
this.log.write({ kind: 'results', results });
|
||||
}
|
||||
|
||||
/** hinzuTeilnehmer: a new peer while there is room. */
|
||||
private addPeer(address: string): void {
|
||||
if (this.peersValue.includes(address) || this.peersValue.length >= this.maximumKnownPeers) return;
|
||||
@@ -213,7 +328,7 @@ export class GnutellaPeer {
|
||||
// erstellePong: none for a ping sent on before.
|
||||
if (!this.alreadySent.has(ping.guid)) {
|
||||
const files = this.sharedFiles();
|
||||
const bytes = files.reduce((sum, size) => sum + size, 0);
|
||||
const bytes = files.reduce((sum, file) => sum + file.size, 0);
|
||||
const address = this.ownAddress;
|
||||
const pong: PongPacket = {
|
||||
kind: 'pong',
|
||||
@@ -235,28 +350,129 @@ export class GnutellaPeer {
|
||||
private sendPingOn(ping: PingPacket, from: string): void {
|
||||
if (ping.ttl <= 0 || this.alreadySent.has(ping.guid)) return;
|
||||
this.alreadySent.add(ping.guid);
|
||||
this.foreignRequests.add(ping.guid);
|
||||
for (const peer of this.peersValue) {
|
||||
if (peer !== from && peer !== ping.ipAddress) this.engine.startThread(this.sendTo(peer, ping));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PeerToPeerServerMitarbeiter.verarbeiteQuery, PeerToPeerAnwendung
|
||||
* .verarbeiteAnfrage: a query not seen before sent on to the other peers
|
||||
* while it lives, and answered with a hit for each file of /peer2peer
|
||||
* whose name holds the words (in any case).
|
||||
*/
|
||||
*answerQuery(query: QueryPacket, from: string, send: (text: string) => Steps<unknown>): Steps {
|
||||
if (this.alreadySent.has(query.guid)) return;
|
||||
this.alreadySent.add(query.guid);
|
||||
const wanted = query.criteria.toLowerCase();
|
||||
const hits = this.sharedFiles().filter((file) => file.name.toLowerCase().includes(wanted));
|
||||
if (query.ttl > 0) this.sendQueryOn({ ...query, ttl: query.ttl - 1, hops: query.hops + 1 }, from);
|
||||
const address = this.ownAddress;
|
||||
for (const hit of hits) {
|
||||
const packet: QueryHitPacket = {
|
||||
kind: 'queryHit',
|
||||
guid: query.guid,
|
||||
hops: 0,
|
||||
ttl: 8,
|
||||
payloadLength: queryHitPayloadLength(hits.length, GNUTELLA_PORT, address, '2', ' '),
|
||||
hits: hits.length,
|
||||
port: GNUTELLA_PORT,
|
||||
ipAddress: address,
|
||||
speed: '2',
|
||||
result: `${hit.name}: ${hit.size} B`,
|
||||
servent: ' ',
|
||||
};
|
||||
yield* send(gnutellaText(packet));
|
||||
}
|
||||
}
|
||||
|
||||
/** PeerToPeerClient.sendeAnfrage: to every peer but the sender. */
|
||||
private sendQueryOn(query: QueryPacket, from: string): void {
|
||||
for (const peer of this.peersValue) if (peer !== from) this.engine.startThread(this.sendTo(peer, query));
|
||||
}
|
||||
|
||||
/** verarbeiteQueryHit: a hit for the program's own search (a result, once), else sent back the way its query came while it lives. */
|
||||
private takeQueryHit(hit: QueryHitPacket): void {
|
||||
if (this.ownRequests.has(hit.guid)) {
|
||||
const result = `${hit.ipAddress}/${hit.result}`;
|
||||
if (!this.resultsValue.includes(result)) this.setResults([...this.resultsValue, result]);
|
||||
} else if (hit.ttl > 0) {
|
||||
this.server.sendBack({ ...hit, ttl: hit.ttl - 1, hops: hit.hops + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
/** httpAnfrageVerarbeiten: the asked file of /peer2peer with its type, or 404. */
|
||||
fileResponse(text: string): string {
|
||||
const request = parseHttpRequest(text);
|
||||
const path = request.type === 'request' ? `${GNUTELLA_FOLDER}/${httpPath(request.request.path)}` : undefined;
|
||||
const file = path === undefined ? undefined : this.sharedFile(path);
|
||||
if (file === undefined) return formatHttpResponse({ protocolVersion: 'HTTP/1.1', statusCode: 404, contentType: undefined, body: undefined });
|
||||
const content = file.content;
|
||||
return formatHttpResponse({
|
||||
protocolVersion: 'HTTP/1.1',
|
||||
statusCode: 200,
|
||||
contentType: javaFileTypeName(file.name, content),
|
||||
body: content.kind === 'text' ? content.text : javaBase64(content.data),
|
||||
});
|
||||
}
|
||||
|
||||
/** PeerToPeerClientMitarbeiter.run for HTTP: the request, the answer, the file kept when it came. */
|
||||
private *fetchFile(owner: string, name: string): Steps {
|
||||
const engine = this.engine;
|
||||
const socketId = engine.openTcpClientSocket(this.nodeId, owner, GNUTELLA_PORT);
|
||||
if (socketId === undefined || (yield* engine.connectTcpSteps(socketId)) !== 'connected') {
|
||||
if (socketId !== undefined) engine.requestTcpClose(socketId);
|
||||
return;
|
||||
}
|
||||
yield* engine.sendTcpSteps(socketId, new TextEncoder().encode(formatHttpRequest({ method: HTTP_GET, path: name, host: owner })));
|
||||
const received = yield* engine.receiveTcpSteps(socketId);
|
||||
const response = received.type === 'message' ? parseHttpResponse(new TextDecoder().decode(received.payload)) : undefined;
|
||||
if (response?.statusCode === 200) {
|
||||
const path = `${GNUTELLA_FOLDER}/${name}`;
|
||||
this.keepFile(path, fileContentOfJavaType(response.contentType ?? '', response.body ?? '', path));
|
||||
}
|
||||
engine.requestTcpClose(socketId);
|
||||
}
|
||||
|
||||
/** speicherDatei: over a file of that name in any case, keeping its name (as the file system does). */
|
||||
private keepFile(path: string, content: VirtualFileContent): void {
|
||||
const files = this.engine.state.topologySnapshot.fileSystemsByNodeId.get(this.nodeId);
|
||||
if (files === undefined || content.kind === 'directory') return;
|
||||
if (content.kind === 'text') files.writeTextFile(path, content.text);
|
||||
else if (content.kind === 'image') files.writeImageFile(path, content.data, content.mediaType);
|
||||
else if (content.mediaType === undefined) files.writeBinaryFile(path, content.data);
|
||||
else files.writeBinaryFile(path, content.data, content.mediaType);
|
||||
}
|
||||
|
||||
/** verarbeitePong: the program's own ping answered (a new peer), else the pong sent back the way its ping came. */
|
||||
private takePong(pong: PongPacket): void {
|
||||
if (this.ownRequests.has(pong.guid)) this.addPeer(pong.ipAddress);
|
||||
else this.server.sendBack({ ...pong, ttl: pong.ttl - 1, hops: pong.hops + 1 });
|
||||
}
|
||||
|
||||
/** The sizes of the files in /peer2peer (Datei.holeGroesse, a text in characters). */
|
||||
private sharedFiles(): number[] {
|
||||
/** The files in /peer2peer (Dateisystem.holeDateien), their names and sizes (Datei.holeGroesse, a text in characters). */
|
||||
private sharedFiles(): { readonly name: string; readonly size: number }[] {
|
||||
const files = this.engine.state.topologySnapshot.fileSystemsByNodeId.get(this.nodeId);
|
||||
if (files === undefined || !files.contains(GNUTELLA_FOLDER)) return [];
|
||||
return files.entriesInCreationOrder(GNUTELLA_FOLDER).flatMap((entry) => {
|
||||
const content = entry.content;
|
||||
return content.kind === 'directory' ? [] : [content.kind === 'text' ? content.text.length : content.data.length];
|
||||
const name = entry.path.slice(entry.path.lastIndexOf('/') + 1);
|
||||
return content.kind === 'directory' ? [] : [{ name, size: content.kind === 'text' ? content.text.length : content.data.length }];
|
||||
});
|
||||
}
|
||||
|
||||
/** A file at the path, found in any case (Dateisystem.holeDatei); none for a folder or nothing. */
|
||||
private sharedFile(path: string): { readonly path: string; readonly name: string; readonly content: Exclude<VirtualFileContent, { kind: 'directory' }> } | undefined {
|
||||
const files = this.engine.state.topologySnapshot.fileSystemsByNodeId.get(this.nodeId);
|
||||
try {
|
||||
const entry = files?.entry(path);
|
||||
if (entry === undefined || entry.content.kind === 'directory') return undefined;
|
||||
return { path: entry.path, name: entry.path.slice(entry.path.lastIndexOf('/') + 1), content: entry.content };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PeerToPeerClientMitarbeiter.run: a connection to the peer's port 6346, the
|
||||
* packet sent, then each answer taken until the connection ends.
|
||||
@@ -274,6 +490,7 @@ export class GnutellaPeer {
|
||||
if (received.type !== 'message') break;
|
||||
const answer = gnutellaPacket(new TextDecoder().decode(received.payload));
|
||||
if (answer?.kind === 'pong') this.takePong(answer);
|
||||
else if (answer?.kind === 'queryHit') this.takeQueryHit(answer);
|
||||
}
|
||||
engine.requestTcpClose(socketId);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,17 @@ 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 {
|
||||
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';
|
||||
|
||||
@@ -38,11 +48,27 @@ describe('Gnutella packets as Java writes and reads them', () => {
|
||||
});
|
||||
|
||||
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(
|
||||
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);
|
||||
@@ -144,4 +170,120 @@ describe('the Gnutella program in the engine', () => {
|
||||
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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { withSnapshot, type NetworkRuntimeEngine } from '../src/index.js';
|
||||
import { GNUTELLA_FOLDER, javaBase64, javaFileTypeName, VirtualFileSystem, withSnapshot, type NetworkRuntimeEngine } from '../src/index.js';
|
||||
import {
|
||||
engineText,
|
||||
expectSameCaptures,
|
||||
@@ -39,7 +39,7 @@ interface JavaRun extends JavaTables {
|
||||
acts: [number, string, string][];
|
||||
/** time, host, kind (call or peers), text */
|
||||
events: [number, string, string, string][];
|
||||
programs: Record<string, { peers: string[]; results: 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). */
|
||||
@@ -50,14 +50,17 @@ const STORIES: Record<string, { readonly looks: number }> = {
|
||||
full: { looks: 3 },
|
||||
reset: { looks: 1 },
|
||||
errors: { looks: 1 },
|
||||
search: { looks: 3 },
|
||||
searchBoth: { looks: 3 },
|
||||
};
|
||||
|
||||
/** The ids of Java's own pings, in the order they were made: the engine draws its own, these take their place. */
|
||||
/** 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) => {
|
||||
const match = /^(\d+)\/\/0x00\/\/0\/\//.exec(frame.payload ?? '');
|
||||
// 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]);
|
||||
@@ -65,19 +68,86 @@ function javaPingIds(java: JavaRun): number[] {
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
function javaPeers(java: JavaRun): Record<string, [number, string][]> {
|
||||
const peers: Record<string, [number, string][]> = Object.fromEntries(PEERS.map((host) => [host, []]));
|
||||
/**
|
||||
* 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 !== 'peers') continue;
|
||||
const list = peers[host]!;
|
||||
if (kind !== wanted || (wanted === 'results' && text === '')) continue;
|
||||
const list = told[host]!;
|
||||
if ((list.at(-1)?.[1] ?? '') === text) continue;
|
||||
list.push([time, text]);
|
||||
}
|
||||
return peers;
|
||||
return told;
|
||||
}
|
||||
|
||||
function enginePeers(engine: NetworkRuntimeEngine): Record<string, [number, string][]> {
|
||||
return Object.fromEntries(PEERS.map((host) => [host, engine.gnutella(host)!.log.all.map((line): [number, string] => [line.timeMilliseconds, line.peers.join(',')])]));
|
||||
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)]];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,7 +164,38 @@ function enginePeers(engine: NetworkRuntimeEngine): Record<string, [number, stri
|
||||
* 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']);
|
||||
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;
|
||||
@@ -146,22 +247,54 @@ const UNTIL: Record<string, (java: JavaRun) => number> = {
|
||||
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 java = JSON.parse(readFileSync(join(scenarioDirectory, `gnutella-java-${name}.java.json`), 'utf8')) as JavaRun;
|
||||
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: name !== 'full' ? 3 : host === ECHO ? 2 : host === FAR ? 3 : 1 }])),
|
||||
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 });
|
||||
if (call.startsWith('join ')) engine.gnutella(host)!.join(call.slice('join '.length));
|
||||
else engine.gnutella(host)!.reset();
|
||||
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 });
|
||||
|
||||
@@ -169,21 +302,37 @@ describe('the Gnutella program as Java FILIUS runs it', () => {
|
||||
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(
|
||||
list
|
||||
.filter((frame) => frame.time < until)
|
||||
.map((frame) => ({ time: frame.time, raw: javaText(frame), text: `${withClientPortText(javaText(frame))} ${data(frame.payload)}` })),
|
||||
(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 captured = engine.capture.frames(portOf.get(card)!).filter((each) => each.timeMilliseconds < until);
|
||||
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: `${withClientPortText(text)} ${data(bytes.length === 0 ? undefined : new TextDecoder().decode(bytes))}` };
|
||||
}),
|
||||
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) {
|
||||
@@ -200,17 +349,25 @@ describe('the Gnutella program as Java FILIUS runs it', () => {
|
||||
expectSameCaptures(engine, scenario, java.frames, { text: withClientPortText, tables: java, until: UNTIL[name]?.(java), tolerance });
|
||||
}
|
||||
|
||||
const expected = javaPeers(java);
|
||||
const actual = enginePeers(engine);
|
||||
for (const host of PEERS) {
|
||||
expect(
|
||||
actual[host]!.map(([, peers]) => peers),
|
||||
`${host}'s peers`,
|
||||
).toEqual(expected[host]!.map(([, peers]) => peers));
|
||||
for (const [index, [javaTime]] of expected[host]!.entries()) {
|
||||
expect(Math.abs(javaTime - actual[host]![index]![0]), `${host}'s peers #${index + 1}`).toBeLessThanOrEqual(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);
|
||||
}
|
||||
}
|
||||
expect(engine.gnutella(host)!.peers, `${host}'s peers at the end`).toEqual(java.programs[host]!.peers);
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -74,6 +74,7 @@
|
||||
// Java FILIUS to the topology of the original up to those departures.
|
||||
|
||||
import {
|
||||
javaFileTypeName,
|
||||
accountFileField,
|
||||
serverAccountsFileText,
|
||||
javaBase64,
|
||||
@@ -462,36 +463,17 @@ function putFile(root: FlsFileTree, path: string, type: string, content: string)
|
||||
else directory.children[index] = file;
|
||||
}
|
||||
|
||||
/** The text types Java's importer reads as text, named by a file's extension. */
|
||||
const TEXT_EXTENSIONS = new Set(['txt', 'html', 'htm', 'css', 'xml', 'csv', 'conf', 'log']);
|
||||
|
||||
function fileTrees(files: VirtualFileSystem, directory: string): FlsFileTree[] {
|
||||
return files.entries(directory).map((entry): FlsFileTree => {
|
||||
const name = entry.path.slice(entry.path.lastIndexOf('/') + 1);
|
||||
const file = (type: string | null, content: string): FlsFileTree => ({ kind: 'file', name, type, content, children: [] });
|
||||
switch (entry.content.kind) {
|
||||
case 'directory':
|
||||
return { kind: 'directory', name, children: fileTrees(files, entry.path) };
|
||||
case 'text': {
|
||||
const dot = name.lastIndexOf('.');
|
||||
const extension = dot < 0 ? '' : name.slice(dot + 1).toLowerCase();
|
||||
return file(TEXT_EXTENSIONS.has(extension) ? extension : 'text', entry.content.text);
|
||||
}
|
||||
case 'image':
|
||||
return file(subtype(entry.content.mediaType), javaBase64(entry.content.data));
|
||||
case 'binary':
|
||||
return file(entry.content.mediaType === undefined ? '' : subtype(entry.content.mediaType), javaBase64(entry.content.data));
|
||||
}
|
||||
if (entry.content.kind === 'directory') return { kind: 'directory', name, children: fileTrees(files, entry.path) };
|
||||
return file(javaFileTypeName(name, entry.content), entry.content.kind === 'text' ? entry.content.text : javaBase64(entry.content.data));
|
||||
});
|
||||
}
|
||||
|
||||
export { javaBase64 };
|
||||
|
||||
/** "image/png" → "png", "image/jpeg" → "jpg", as Java names them. */
|
||||
function subtype(mediaType: string): string {
|
||||
const name = mediaType.slice(mediaType.indexOf('/') + 1);
|
||||
return name === 'jpeg' ? 'jpg' : name;
|
||||
}
|
||||
|
||||
function exportDhcpServer(server: DhcpServerConfiguration): FlsDhcpServer {
|
||||
return {
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
// "IN", names with a leading digit — and drops Java's duplicates.
|
||||
|
||||
import {
|
||||
fileContentOfJavaType,
|
||||
canonicalMacAddress,
|
||||
forwardsIPv4ByDefault,
|
||||
deepEqual,
|
||||
@@ -662,44 +663,6 @@ function firewallConfigurationOf(firewall: FlsFirewall, where: string, warnings:
|
||||
};
|
||||
}
|
||||
|
||||
const IMAGE_TYPES = new Set(['png', 'jpg', 'jpeg', 'gif', 'bmp', 'image']);
|
||||
const TEXT_TYPES = new Set(['', 'text', 'txt', 'text/txt', 'html', 'htm', 'css', 'xml', 'csv', 'conf', 'log']);
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Swift's importedContent: an image by type or extension (Base64), text by
|
||||
* type, else Base64 as binary data, else text — each fallback with a warning.
|
||||
*/
|
||||
function importedContent(
|
||||
rawType: string | null,
|
||||
rawContent: string | null,
|
||||
path: string,
|
||||
warn: (message: string) => void,
|
||||
): VirtualFileContent {
|
||||
const type = swiftLowercased(trimmedOrEmpty(rawType));
|
||||
const content = rawContent ?? '';
|
||||
const extension = swiftLowercased(swiftPathExtension(path));
|
||||
const binary = (data: Uint8Array): VirtualFileContent =>
|
||||
type === '' ? { kind: 'binary', data } : { kind: 'binary', data, mediaType: `application/${type}` };
|
||||
if (IMAGE_TYPES.has(type) || IMAGE_TYPES.has(extension)) {
|
||||
const data = swiftBase64Decode(content);
|
||||
if (data !== undefined) {
|
||||
const subtype = type === 'image' || type === '' ? (extension === '' ? 'png' : extension) : type;
|
||||
return { kind: 'image', data, mediaType: `image/${subtype === 'jpg' ? 'jpeg' : subtype}` };
|
||||
}
|
||||
warn(`Java file '${path}' declared image type '${type}' but was not valid Base64; imported as UTF-8 binary data.`);
|
||||
return binary(encoder.encode(content));
|
||||
}
|
||||
if (TEXT_TYPES.has(type)) return { kind: 'text', text: content };
|
||||
const data = swiftBase64Decode(content);
|
||||
if (data !== undefined && (data.length > 0 || swiftTrim(content) === '')) {
|
||||
warn(`Java file '${path}' used unknown type '${type}'; preserved as binary data.`);
|
||||
return binary(data);
|
||||
}
|
||||
warn(`Java file '${path}' used unknown type '${type}'; preserved as UTF-8 text.`);
|
||||
return { kind: 'text', text: content };
|
||||
}
|
||||
|
||||
/** A usable name: not empty after trimming, no path separator. */
|
||||
function safeName(name: string | null): string | undefined {
|
||||
const trimmed = trimmedOrEmpty(name);
|
||||
@@ -726,7 +689,7 @@ function importFileSystem(root: FlsFileTree | undefined, nodeName: string, warni
|
||||
const rawPath = parent === '/' ? `/${name}` : `${parent}/${name}`;
|
||||
try {
|
||||
const path = normalizedAbsolutePath(rawPath);
|
||||
const content = importedContent(tree.type, tree.content, path, warn);
|
||||
const content = fileContentOfJavaType(trimmedOrEmpty(tree.type), tree.content ?? '', path, warn);
|
||||
switch (content.kind) {
|
||||
case 'text':
|
||||
fileSystem.writeTextFile(path, content.text, false);
|
||||
|
||||
@@ -90,3 +90,6 @@ packages/core/src/runtime/apps/emailClient.ts::smtpclient
|
||||
packages/core/src/runtime/apps/::arpsend
|
||||
# sw_fileexplorer_msg1: the key of a message in FILIUS' bundle, read from Java's recording as Java names it.
|
||||
packages/core/test/filePrograms.test.ts::fileexplorer
|
||||
# "Hallo Welt": the content of the measured example file, quoted where the Gnutella program names Java's size of it.
|
||||
packages/core/src/runtime/apps/gnutella.ts::hallo
|
||||
packages/core/src/runtime/apps/gnutella.ts::welt
|
||||
|
||||
@@ -905,6 +905,37 @@ Gemessen (02.10.2026):
|
||||
herum, einige zehn Millisekunden auseinander: Javas Thread-Takt über
|
||||
mehrere Sprünge.
|
||||
|
||||
## Gnutella, Suche und Download (F3.6b)
|
||||
|
||||
Zwei weitere Abläufe von `GnutellaRun`: die Teilnehmer bilden über ihre
|
||||
Höchstzahlen eine Linie .20 – .30 – .31 – 10.0.0.2; .31 teilt
|
||||
`notizen.txt` (in `searchBoth` auch `lied.txt`), 10.0.0.2 `Lied der
|
||||
Woche.txt`. Geschrieben werden zusätzlich die Suchergebnisse bei jeder
|
||||
Meldung und am Ende die Dateien in `/peer2peer` jedes Programms.
|
||||
|
||||
Gemessen (02.10.2026):
|
||||
|
||||
* Suche (`sucheDatei`): die Ergebnisse geleert, eine Query
|
||||
`guid//0x80//0//8//Bits//1//Worte` (ein leeres Feld als ein Leerzeichen)
|
||||
über je eine neue Verbindung an jeden Teilnehmer außer sich selbst. Wer
|
||||
sie noch nicht kennt, schickt sie (hops+1, ttl−1) an seine Teilnehmer
|
||||
außer dem Absender und antwortet für jede Datei in `/peer2peer`, deren
|
||||
Name die Worte enthält (Groß/klein egal), mit einem Treffer
|
||||
`guid//0x81//0//8//Bits//Anzahl//6346//IP//2//Name: Größe B// `; die
|
||||
Bitlänge rechnet Java, bevor der Name eingetragen ist. Treffer gehen
|
||||
(hops+1, ttl−1) zurück wie Pongs; das Ergebnis heißt `IP/Name: Größe B`,
|
||||
jedes einmal.
|
||||
* Fehler Javas: wer selbst Treffer schickt, schließt danach die
|
||||
Verbindung; die Treffer der Teilnehmer, an die er die Query
|
||||
weitergeschickt hat, gehen verloren (`searchBoth`: der von 10.0.0.2). Die
|
||||
Größe ist wieder die des Base64-dekodierten Textes („Hallo Welt“ = 6 B),
|
||||
ebenso die Bytes im Pong.
|
||||
* Download (`herunterladenDatei`): `GET Name HTTP/1.1` mit `Host:` an Port
|
||||
6346 des Rechners (der Name bis zum `:`, Leerzeichen kodiert), Antwort
|
||||
`HTTP/1.1 200 OK` mit `Content-type:` gleich dem Dateityp und dem Inhalt,
|
||||
sonst `HTTP/1.1 404 Not Found`; die Datei landet in `/peer2peer`, die
|
||||
Verbindung schließt der Client nach der Antwort.
|
||||
|
||||
## Datei-Explorer, Texteditor, Bildbetrachter (F3.5b)
|
||||
|
||||
`FileCases` tut, was die Fenster dieser Programme mit den Dateien eines
|
||||
|
||||
@@ -286,9 +286,9 @@ for language in de en; do
|
||||
test -s "$output"
|
||||
done
|
||||
|
||||
# The Gnutella program joining peers on terminal-net (GnutellaRun;
|
||||
# The Gnutella program joining peers, searching and downloading on terminal-net (GnutellaRun;
|
||||
# packages/core/test/gnutellaJava.test.ts).
|
||||
for run in pair:6000 chain:9000 twoPaths:15000 full:11000 reset:7000 errors:40000; do
|
||||
for run in pair:6000 chain:9000 twoPaths:15000 full:11000 reset:7000 errors:40000 search:29000 searchBoth:13000; do
|
||||
story="${run%%:*}"
|
||||
output="$root/packages/core/test/scenarios/gnutella-java-$story.java.json"
|
||||
rm -f "$output"
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
// Runs a story of FILIUS' Gnutella program (PeerToPeerAnwendung with its
|
||||
// PeerToPeerServer on port 6346 and PeerToPeerClient) on terminal-net in Java
|
||||
// FILIUS and records what really happens: every frame each card saw, Java's
|
||||
// tables of them, each program's known peers whenever it tells its window,
|
||||
// and at the end each program's known peers and search results.
|
||||
// tables of them, each program's known peers and search results whenever it
|
||||
// tells its window, and at the end its peers, results and files in /peer2peer.
|
||||
//
|
||||
// GnutellaRun <scenario.json> <story> <milliseconds> <out.json>
|
||||
//
|
||||
// terminal-net: the program on 192.168.0.20, .30, .31 and 10.0.0.2 (behind
|
||||
// the router), each keeping at most three peers unless the story says
|
||||
// otherwise; .10 has none. A call comes as the program's window makes it
|
||||
// (beitretenNetzwerk, resetNetwork); the acts are in the output.
|
||||
// (beitretenNetzwerk, resetNetwork, sucheDatei, herunterladenDatei,
|
||||
// loescheSuchergebnisse); the acts are in the output.
|
||||
// pair .30 joins .31
|
||||
// chain .30 joins .31, then .20 joins .30 (the ping goes on to .31)
|
||||
// twoPaths .31 joins 10.0.0.2, .30 joins .31 and 10.0.0.2, then .20 joins
|
||||
@@ -17,13 +18,22 @@
|
||||
// full .31 keeps two peers, .30 and .20 one; .30, .20 and 10.0.0.2 join .31
|
||||
// reset .30 joins .31, resets, joins again
|
||||
// errors .20 joins itself, .10 (no program), .99 (no one)
|
||||
// search a line .20 - .30 - .31 - 10.0.0.2 (by their numbers of peers);
|
||||
// .31 shares notizen.txt, 10.0.0.2 "Lied der Woche.txt"; .20
|
||||
// searches "woche" (10.0.0.2's hit comes back through .31 and
|
||||
// .30) and downloads it, searches "notiz" and downloads it,
|
||||
// again after .31 removed it, searches "xyz", clears
|
||||
// searchBoth the line, .31 also shares lied.txt; .20 searches "lied"
|
||||
|
||||
import filius.software.dateiaustausch.PeerToPeerAnwendung;
|
||||
import filius.software.system.Datei;
|
||||
import filius.software.system.Dateisystem;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.swing.tree.DefaultMutableTreeNode;
|
||||
|
||||
public class GnutellaRun {
|
||||
static final String CLIENT = "00000000-0000-0000-0000-000000000401";
|
||||
@@ -72,6 +82,25 @@ public class GnutellaRun {
|
||||
acts.add(new Object[] { 3000L, WEB, "reset" });
|
||||
acts.add(new Object[] { 3500L, WEB, "join 192.168.0.31" });
|
||||
break;
|
||||
case "search":
|
||||
acts.add(new Object[] { 500L, CLIENT, "join 192.168.0.30" });
|
||||
acts.add(new Object[] { 3000L, WEB, "join 192.168.0.31" });
|
||||
acts.add(new Object[] { 5500L, FAR, "join 192.168.0.31" });
|
||||
acts.add(new Object[] { 8000L, CLIENT, "search woche" });
|
||||
acts.add(new Object[] { 11000L, CLIENT, "download 0" });
|
||||
acts.add(new Object[] { 14000L, CLIENT, "search notiz" });
|
||||
acts.add(new Object[] { 17000L, CLIENT, "download 0" });
|
||||
acts.add(new Object[] { 20000L, ECHO, "remove notizen.txt" });
|
||||
acts.add(new Object[] { 20500L, CLIENT, "download 0" });
|
||||
acts.add(new Object[] { 23000L, CLIENT, "search xyz" });
|
||||
acts.add(new Object[] { 26000L, CLIENT, "clear" });
|
||||
break;
|
||||
case "searchBoth":
|
||||
acts.add(new Object[] { 500L, CLIENT, "join 192.168.0.30" });
|
||||
acts.add(new Object[] { 3000L, WEB, "join 192.168.0.31" });
|
||||
acts.add(new Object[] { 5500L, FAR, "join 192.168.0.31" });
|
||||
acts.add(new Object[] { 8000L, CLIENT, "search lied" });
|
||||
break;
|
||||
case "errors":
|
||||
acts.add(new Object[] { 500L, CLIENT, "join 192.168.0.20" });
|
||||
acts.add(new Object[] { 1000L, CLIENT, "join 192.168.0.10" });
|
||||
@@ -93,18 +122,43 @@ public class GnutellaRun {
|
||||
for (String host : PEERS) {
|
||||
network.system(host).installApp(CLASS, false);
|
||||
PeerToPeerAnwendung program = (PeerToPeerAnwendung) network.system(host).holeSoftware(CLASS);
|
||||
program.setMaxTeilnehmerZahl(!story.equals("full") ? 3 : host.equals(ECHO) ? 2 : host.equals(FAR) ? 3 : 1);
|
||||
program.hinzuBeobachter((observable, data) -> event(host, "peers", String.join(",", program.holeBekanntePeerToPeerTeilnehmer())));
|
||||
if (story.equals("full")) program.setMaxTeilnehmerZahl(host.equals(ECHO) ? 2 : host.equals(FAR) ? 3 : 1);
|
||||
else if (story.startsWith("search")) program.setMaxTeilnehmerZahl(host.equals(CLIENT) || host.equals(FAR) ? 1 : 2);
|
||||
else program.setMaxTeilnehmerZahl(3);
|
||||
program.hinzuBeobachter((observable, data) -> {
|
||||
event(host, "peers", String.join(",", program.holeBekanntePeerToPeerTeilnehmer()));
|
||||
event(host, "results", String.join("|", program.holeErgebnisse()));
|
||||
});
|
||||
programs.put(host, program);
|
||||
}
|
||||
if (story.startsWith("search")) {
|
||||
Dateisystem echoFiles = network.system(ECHO).getDateisystem();
|
||||
if (story.equals("searchBoth")) echoFiles.speicherDatei("/peer2peer", new Datei("lied.txt", "txt", "Hallo Welt"));
|
||||
echoFiles.speicherDatei("/peer2peer", new Datei("notizen.txt", "txt", "Notiz"));
|
||||
network.system(FAR).getDateisystem().speicherDatei("/peer2peer", new Datei("Lied der Woche.txt", "txt", "La la la"));
|
||||
}
|
||||
network.start();
|
||||
for (Object[] act : acts) {
|
||||
sleepUntil((Long) act[0]);
|
||||
String host = (String) act[1];
|
||||
String call = (String) act[2];
|
||||
event(host, "call", call);
|
||||
if (call.startsWith("join ")) programs.get(host).beitretenNetzwerk(call.substring("join ".length()));
|
||||
else programs.get(host).resetNetwork();
|
||||
PeerToPeerAnwendung program = programs.get(host);
|
||||
if (call.startsWith("join ")) program.beitretenNetzwerk(call.substring("join ".length()));
|
||||
else if (call.startsWith("search ")) program.sucheDatei(call.substring("search ".length()));
|
||||
else if (call.startsWith("download ")) {
|
||||
// The window offers only the rows it shows.
|
||||
int row = Integer.parseInt(call.substring("download ".length()));
|
||||
if (row < program.holeErgebnisse().size()) program.herunterladenDatei(row);
|
||||
else event(host, "skipped", call);
|
||||
}
|
||||
else if (call.startsWith("remove ")) {
|
||||
// The file explorer's "delete" on the peer that has the file.
|
||||
Dateisystem files = network.system(host).getDateisystem();
|
||||
DefaultMutableTreeNode folder = files.verzeichnisKnoten("/peer2peer");
|
||||
folder.remove(Dateisystem.verzeichnisKnoten(folder, call.substring("remove ".length())));
|
||||
} else if (call.equals("clear")) program.loescheSuchergebnisse();
|
||||
else program.resetNetwork();
|
||||
}
|
||||
sleepUntil(end);
|
||||
|
||||
@@ -129,6 +183,11 @@ public class GnutellaRun {
|
||||
out.key("results").open('[');
|
||||
for (String result : program.holeErgebnisse()) out.string(result);
|
||||
out.close(']');
|
||||
out.key("files").open('[');
|
||||
for (Datei file : network.system(entry.getKey()).getDateisystem().holeDateien(program.holeVerzeichnis())) {
|
||||
out.open('[').string(file.getName()).string(file.getDateiTyp()).string(file.getDateiInhalt()).close(']');
|
||||
}
|
||||
out.close(']');
|
||||
out.close('}');
|
||||
}
|
||||
out.close('}');
|
||||
|
||||
Reference in New Issue
Block a user