// The classics of the classroom on FILIUS' own examples, as Java FILIUS runs // them (the acceptance of F3): a web page loaded by its DNS name, an e-mail // sent between two networks, a file found and fetched with Gnutella. // tools/java-oracle's ExampleRun opened each file in Java FILIUS as FILIUS // opens it, started it, called the programs as their windows call them and // wrote every frame each card saw, the capture's tables, what each program // told its window and where the programs ended (examples/.java.json). // Here the file is read with this package's reader and run in the core // (projectSnapshot), the same calls at the same times: each card's frames // in the same order, with the same content and rows, at about the same // time; each program's lines in the same order at about the same time; the // same mails, peers, results and files at the end. import { describe, expect, it } from 'vitest'; import { formatMail, GNUTELLA_FOLDER, javaBase64, javaFileTypeName, NetworkRuntimeEngine, VirtualFileSystem, type Id, type LogLine, type MailboxEntry, } from '@mdg-filius/core'; import { projectSnapshot, type ImportedProject } from '../src/index.js'; import { expectSameCaptures, GAP_TOLERANCE_MILLISECONDS, withClientPortText, type JavaFrame, type JavaTables } from '../../core/test/javaRun.js'; import { browserText, emailClientText, emailServerText, isPolled, POLL_MILLISECONDS, serverText } from '../../core/test/programLines.js'; import { exampleCards, exampleRecording, namesInJavaOrder, openExample } from './exampleRun.js'; const SEED = 14; interface JavaRun extends JavaTables { frames: Record; cards: Record; /** What each program told its window: time, program (its node's name, or "browser"), text; a call the story made as "call …". */ programs: [number, string, string][]; /** Where the programs ended (ExampleRun). */ end: Record; /** Every change of each RIP router's table: time, router, `network/mask gateway interface hops; …`. */ ripTables: [number, string, string][]; } type Lines = Record; type Node = (name: string) => Id; type JavaMail = [string, string, string]; interface Classic { readonly file: string; /** Changes to the example before it starts, as ExampleRun makes them. */ readonly prepare?: (project: ImportedProject, node: Node) => void; /** * At the first call: RIP routes the engine's routers chose otherwise than * Java's where both ways are as long (equalRipTies). */ readonly ripTies?: true; /** Placeholders in a table cell of the story (the frames' texts carry no data). */ readonly cell?: (text: string) => string; /** * Where frames of different connections may come in either order: a * frame's flow (expectSameCaptures), by its text. */ readonly flow?: (text: string) => string; /** Changes to the engine before it runs, from Java's run. */ readonly beforeRun?: (engine: NetworkRuntimeEngine, java: JavaRun) => void; /** A call the story made at a program ("call …" in Java's lines, without "call "). */ readonly act: (engine: NetworkRuntimeEngine, node: Node, program: string, call: string) => void; /** Java's lines by program, as its windows show them (without the calls). */ readonly javaLines: (java: JavaRun) => Lines; readonly engineLines: (engine: NetworkRuntimeEngine, node: Node) => Lines; /** Where the engine's programs ended, as ExampleRun writes Java's. */ readonly end: (engine: NetworkRuntimeEngine, node: Node) => unknown; /** * The waits Java ends only at a look every 100 ms, one after the other, on * the way to a line (a connection opened); a line is compared within * GAP_TOLERANCE_MILLISECONDS and these looks. */ readonly looks: number; /** * When the DHCP clients are done: the cards are compared from the first * call on (the leases before it are the acceptance of F2, examplesJava's * firewall story). */ readonly afterLeases?: true; } function lines(log: readonly LogLine[] | undefined, text: (entry: E) => string | undefined): [number, string][] { return (log ?? []).flatMap((line) => { const each = text(line); return each === undefined ? [] : [[line.timeMilliseconds, withClientPortText(each)] as [number, string]]; }); } /** Java's lines of the programs: what their windows show (no empty notices, null, nor the browser's "update" at its start). */ function javaLinesOf(java: JavaRun, programs: readonly string[]): Lines { const told: Lines = Object.fromEntries(programs.map((program) => [program, []])); const firstCall = Math.min(...java.programs.filter(([, , text]) => text.startsWith('call ')).map(([time]) => time)); for (const [time, program, text] of java.programs) { if (text === 'null' || text.startsWith('call ') || (time < firstCall && text === 'update')) continue; told[program]!.push([time, withClientPortText(text)]); } return told; } /** * Where a router has two ways to a network as long as each other, Java takes * the one whose message its threads handle first (examplesJava's firewall * story; tools/java-oracle/README.md, Abnahme F2), the engine the first it * learned; neither replaces it with a way as long. As a random number Java * drew, Java's choice is given to the engine (its table at the time), * route by route; a route that differs otherwise (another length, or one * of them missing) is a difference, not a tie. */ function equalRipTies(engine: NetworkRuntimeEngine, java: JavaRun, node: Node, time: number): void { const latest = new Map(); for (const [changed, router, table] of java.ripTables) if (changed <= time) latest.set(router, table); for (const [router, table] of latest) { const routes = [...(engine.state.ripTablesByNodeId.get(node(router)) ?? [])]; for (const entry of table.split('; ')) { const [destination, gateway, card, hops] = entry.split(' ') as [string, string, string, string]; const index = routes.findIndex((route) => `${route.destinationNetwork}/${route.subnetMask}` === destination); const route = routes[index]; expect(route?.metric, `${router}: ${destination}`).toBe(Number(hops)); if (route!.nextHop === gateway) continue; // Java's hopPublicIp: the first address of the router that told it, as its other routes through that router say. const told = routes.find((each) => each.nextHop === gateway && each.hopPublicIPAddress !== undefined)?.hopPublicIPAddress; routes[index] = { ...route!, nextHop: gateway, interfaceIPAddress: card, ...(told === undefined ? {} : { hopPublicIPAddress: told }) }; } engine.state.ripTablesByNodeId.set(node(router), routes); } } const mails = (entries: readonly MailboxEntry[]): JavaMail[] => entries.map((entry) => [formatMail(entry.mail), String(entry.isNew), String(entry.isMarkedForDeletion)]); const GMX = 'mail.gmx.de (130.100.200.25)'; const WEBMAIL = 'mail.webmail.de (140.100.200.25)'; const GNUTELLA_PEERS = ['Notebook 1.10', 'Notebook 1.11', 'DNS-Server 2.10']; const CLASSICS: Record = { web: { file: 'internetwork_komplett.fls', act: (engine, node, _program, call) => engine.webBrowser(node('Notebook 1.10'))!.open(call.slice('holeWebseite '.length)), javaLines: (java) => javaLinesOf(java, ['browser', 'Webserver 5.10']), engineLines: (engine, node) => ({ browser: lines(engine.webBrowser(node('Notebook 1.10'))!.log.all, browserText), 'Webserver 5.10': lines(engine.webServer(node('Webserver 5.10'))!.log.all, serverText), }), end: () => ({}), looks: 1, }, email: { file: 'Internet_Komplett_mit_eMail_Webserver_Intranet_Portforwarding_Firewall_DHCP_DE.fls', act: (engine, node, program, call) => { const client = engine.emailClient(node(program))!; if (call === 'emailsAbholenEmails') { expect(client.fetch()).toBe(true); } else { expect(client.send({ to: call.slice('versendeEmail '.length), cc: '', bcc: '', subject: 'Treffen', text: 'Hallo Frimp,\nbis morgen!\nHerbert' })).toBeUndefined(); } }, javaLines: (java) => javaLinesOf(java, ['Notebook', 'Notebook#2', GMX, WEBMAIL]), engineLines: (engine, node) => ({ Notebook: lines(engine.emailClient(node('Notebook'))!.log.all, emailClientText), 'Notebook#2': lines(engine.emailClient(node('Notebook#2'))!.log.all, emailClientText), [GMX]: lines(engine.emailServer(node(GMX))!.log.all, (entry) => emailServerText(entry)), [WEBMAIL]: lines(engine.emailServer(node(WEBMAIL))!.log.all, (entry) => emailServerText(entry)), }), end: (engine, node) => ({ [GMX]: engine.emailServer(node(GMX))!.state.accounts.map((each) => [each.username, mails(each.mailbox)]), [WEBMAIL]: engine.emailServer(node(WEBMAIL))!.state.accounts.map((each) => [each.username, mails(each.mailbox)]), ...Object.fromEntries( ['Notebook', 'Notebook#2'].map((name) => { const client = engine.emailClient(node(name))!; return [name, { inbox: mails(client.inbox), sent: mails(client.sent) }]; }), ), }), // Two connections one after the other, each opened at a look: herbert's to mail.gmx.de, then the // server's on to mail.webmail.de (measured: Java some 80 ms later at each; the fetch is a call of its own). looks: 2, afterLeases: true, // #3 reaches the school's network (42.0.0.0) through "" or #2, two hops each: every answer to the school. ripTies: true, }, gnutella: { file: 'zwei_rechnernetze_komplett.fls', prepare: (project, node) => { for (const name of GNUTELLA_PEERS) { project.installedPrograms.get(node(name))!.add('gnutella'); project.gnutellaConfigurations.set(node(name), { maximumKnownPeers: 3 }); } const files = project.virtualFileSystems.get(node('DNS-Server 2.10')) ?? new VirtualFileSystem(); project.virtualFileSystems.set(node('DNS-Server 2.10'), files); files.createDirectory(GNUTELLA_FOLDER, true); files.writeTextFile(`${GNUTELLA_FOLDER}/Lied der Woche.txt`, 'La la la'); }, // A hit's size and a pong's bytes, with the payload length that counts them (JAVA_BUGS; gnutellaJava.test.ts). cell: (text) => text.replace(/: \d+ B\/\//g, ': n B//').replace(/(\/\/0x01\/\/\d+\/\/\d+\/\/)\d+(\/\/.*\/\/6346\/\/\d+\/\/)\d+$/, '$1n$2n'), // A search goes to both peers of 1.10 at once, and at its card the frames of the two connections come in // either order (gnutellaJava.test.ts, BY_CONNECTION): compared direction by direction between two hosts on // the server's port (the query's and the download's connection to 2.10 one after the other), ARP on its own. flow: (text) => / TCP (\S+) > (\S+) /.exec(text)?.slice(1).join(' > ') ?? 'ARP', // The ids of Java's own pings and queries (a ping or query with no hops yet), in the order they were made: // the engine draws its own, these take their place (gnutellaJava.test.ts). beforeRun: (engine, java) => { const own = Object.values(java.frames) .flat() .flatMap((frame) => { 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]); const ids = [...new Set(own.map(([, guid]) => guid))]; engine.drawGnutellaGuid = () => ids.shift() ?? 1; }, act: (engine, node, program, call) => { const peer = engine.gnutella(node(program))!; if (call.startsWith('join ')) peer.join(call.slice('join '.length)); else if (call.startsWith('search ')) peer.search(call.slice('search '.length)); else peer.download(Number(call.slice('download '.length))); }, // Each program's peers and results as it told them, each change once (Java tells its window also when // nothing changed, and empty lists at its start); results only when there are some. javaLines: (java) => { const told: Lines = Object.fromEntries(GNUTELLA_PEERS.flatMap((name) => [[`${name} peers`, []], [`${name} results`, []]])); for (const [time, program, text] of java.programs) { const [kind, list] = [text.slice(0, text.indexOf(' ')), text.slice(text.indexOf(' ') + 1)]; if (kind === 'call' || (kind === 'results' && list === '')) continue; const each = told[`${program} ${kind}`]!; if ((each.at(-1)?.[1] ?? '') !== list) each.push([time, list]); } return told; }, engineLines: (engine, node) => Object.fromEntries( GNUTELLA_PEERS.flatMap((name) => (['peers', 'results'] as const).map((wanted) => { const told: [number, string][] = []; for (const line of engine.gnutella(node(name))!.log.all) { if (line.kind !== wanted) continue; const text = line.kind === 'peers' ? line.peers.join(',') : line.results.join('|'); if ((wanted === 'results' && text === '') || (told.at(-1)?.[1] ?? '') === text) continue; told.push([line.timeMilliseconds, text]); } return [`${name} ${wanted}`, told]; }), ), ), end: (engine, node) => Object.fromEntries( GNUTELLA_PEERS.map((name) => { const peer = engine.gnutella(node(name))!; const files = engine.state.topologySnapshot.fileSystemsByNodeId.get(node(name))!; const shared = files.contains(GNUTELLA_FOLDER) ? files.entriesInCreationOrder(GNUTELLA_FOLDER).flatMap((entry): [string, string, string][] => { const content = entry.content; if (content.kind === 'directory') return []; const fileName = entry.path.slice(entry.path.lastIndexOf('/') + 1); return [[fileName, javaFileTypeName(fileName, content), content.kind === 'text' ? content.text : javaBase64(content.data)]]; }) : []; return [name, { peers: [...peer.peers], results: [...peer.results], files: shared }]; }), ), looks: 2, }, }; /** A hit's size as Java's Datei.holeGroesse says it (the text decoded as Base64), and as the engine (its characters; gnutellaJava.test.ts). */ const withHitSize = (text: string) => text.replace('/Lied der Woche.txt: 3 B', '/Lied der Woche.txt: 8 B'); /** Java's run without the frames `keep` refuses, each card's table rows numbered anew. */ function withFrames(java: JavaRun, keep: (frame: JavaFrame, card: string, frames: readonly JavaFrame[]) => boolean): JavaRun { const numbers = new Map>(); const frames = Object.fromEntries( Object.entries(java.frames).map(([card, list]) => { const renumbered = new Map(); const kept = list.filter((frame, index) => { if (!keep(frame, card, list)) return false; renumbered.set(index + 1, renumbered.size + 1); return true; }); numbers.set(card, renumbered); return [card, kept]; }), ); const tables = (rows: Record | undefined) => rows === undefined ? undefined : Object.fromEntries( Object.entries(rows).map(([card, list]) => [ card, list.flatMap((row) => { const number = numbers.get(card)?.get(Number(row[0])); return number === undefined ? [] : [[String(number), ...row.slice(1)]]; }), ]), ); return { ...java, frames, rows: tables(java.rows), layeredRows: tables(java.layeredRows) }; } /** A Gnutella connection by its client's end. */ const clientEnd = (frame: JavaFrame) => (frame.sourcePort === 6346 ? `${frame.destinationIp}:${frame.destinationPort}` : `${frame.sourceIp}:${frame.sourcePort}`); /** * Where Java errs, per story: its run corrected, as Java means it. * * gnutella: a hit names the file's size in characters, where Java decodes * its text as Base64 first (gnutella.ts); in the frames' rows the sizes * are masked (Classic.cell), as a pong's bytes are. And Java's server closes * a connection after its hits; the engine keeps it, as it keeps every other * (gnutella.ts): each connection whose server sends the first FIN, without * its frames from that FIN on (gnutellaJava.test.ts, beforeServerFins). * And 2.10 passes the query on to 1.11, which has no gateway and cannot * answer: Java's connectClientMode sends the SYN once and gives up after one * wait (tcpJava.test.ts, closedPort); the engine sends it three times, a * wait of three round trips each. The two later SYNs added on every card * the first showed on (without rows of Java's). */ const JAVA_TIMEOUT_MILLISECONDS = 3 * 2_750; const toUnreachable = (frame: JavaFrame) => frame.tcpFlags === 'S' && frame.destinationIp === '141.99.1.11' && frame.sourceIp === '141.99.2.10'; const JAVA_BUGS: Record JavaRun> = { gnutella: (java) => withSynRetries( withFrames( { ...java, programs: java.programs.map(([time, program, text]) => [time, program, withHitSize(text)]), end: JSON.parse(withHitSize(JSON.stringify(java.end))) as Record, }, (frame, _card, frames) => { if (frame.tcpFlags === undefined) return true; const firstFin = frames.find((each) => each.tcpFlags?.includes('F') === true && clientEnd(each) === clientEnd(frame)); return firstFin === undefined || firstFin.sourcePort !== 6346 || frames.indexOf(frame) < frames.indexOf(firstFin); }, ), ), }; /** Java's run with the SYN to 1.11 sent twice more, a timeout apart, on every card it showed on. */ function withSynRetries(java: JavaRun): JavaRun { return { ...java, frames: Object.fromEntries( Object.entries(java.frames).map(([card, list]) => { const syn = list.find(toUnreachable); return [card, syn === undefined ? list : [...list, ...[1, 2].map((attempt) => ({ ...syn, time: syn.time + attempt * JAVA_TIMEOUT_MILLISECONDS }))]]; }), ), }; } /** * Initial sequence numbers below the millions. Java gives the next number to * each TCP socket as it is made (the core does too), and a server makes its * first listening socket in its own thread when the simulation starts * (ServerAnwendung.annehmenVerbindungen): which server of an example draws * which number is a race of Java's threads. */ const withoutInitialNumbers = (text: string) => text .replace(/(seq|ack)=(\d+)/g, (_, name: string, value: string) => `${name}=${Number(value) % 1_000_000}`) .replace(/(SEQ|ACK): \d+\.(\d{3}\.\d{3})/g, '$1: n.$2'); /** A frame's text with placeholders: free ports, DNS message IDs (drawn at random in Java, from the seed in the engine), initial sequence numbers. */ const frameText = (text: string) => withoutInitialNumbers(withClientPortText(text)).replace(/ID=\d+/g, 'ID=x'); /** The example read, changed as the story changes it, and started in the engine. */ function started(classic: Classic, java: JavaRun) { const project = openExample(classic.file); const names = namesInJavaOrder(project); const node: Node = (nodeName) => names.get(nodeName)!; classic.prepare?.(project, node); const engine = new NetworkRuntimeEngine(SEED); engine.handle({ type: 'start', snapshot: projectSnapshot(project), seed: SEED, initialTimeMilliseconds: 0 }); classic.beforeRun?.(engine, java); return { project, names, node, engine }; } /** The story played in the engine as Java played it, and compared with Java's run (corrected, JAVA_BUGS). */ function expectSameAsJava(name: string, java: JavaRun): void { const classic = CLASSICS[name]!; const { project, names, node, engine } = started(classic, java); const calls = java.programs.filter(([, , text]) => text.startsWith('call ')); for (const [index, [time, program, text]] of calls.entries()) { engine.handle({ type: 'advance', toMilliseconds: time }); if (index === 0 && classic.ripTies !== undefined) equalRipTies(engine, java, node, time); classic.act(engine, node, program, text.slice('call '.length)); } const last = Math.max(...Object.values(java.frames).flat().map((frame) => frame.time), ...java.programs.map(([time]) => time)); engine.handle({ type: 'advance', toMilliseconds: last + 5_000 }); const tolerance = GAP_TOLERANCE_MILLISECONDS + classic.looks * POLL_MILLISECONDS; const { cards, withEngineMacs } = exampleCards(project, names, java.cards); const firstCall = calls[0]![0]; expectSameCaptures(engine, cards, java.frames, { text: (text) => frameText(withEngineMacs(text)), cell: (text) => (classic.cell ?? ((each: string) => each))(withoutInitialNumbers(withEngineMacs(text))), tables: java, tolerance, // Both run on the same clock: the calls came at the same times. starts: { java: 0, engine: 0 }, from: classic.afterLeases === undefined ? undefined : firstCall, flow: classic.flow, // RIP's beacons come every 30 s after random pauses, as many as a router drew since its start, which // depends on the order Java's threads start in (tools/java-oracle/README.md, RIP): RIP is the // acceptance of F2 (examplesJava.test.ts, with its tables), the programs' frames are compared here. compared: (text) => !/ UDP \S+:521 > \S+:520 /.test(text), }); const expected = classic.javaLines(java); const actual = classic.engineLines(engine, node); expect(Object.keys(actual).sort()).toEqual(Object.keys(expected).sort()); for (const [program, expectedLines] of Object.entries(expected)) { const actualLines = actual[program]!; expect( actualLines.map(([, text]) => text), `${program}'s lines`, ).toEqual(expectedLines.map(([, text]) => text)); for (const [index, [javaTime, text]] of expectedLines.entries()) { const late = javaTime - actualLines[index]![0]; const most = (isPolled(text) ? POLL_MILLISECONDS : 0) + tolerance; expect(Math.abs(late), `${program}'s line ${JSON.stringify(text.slice(0, 60))}: Java ${late} ms later`).toBeLessThanOrEqual(most); } } expect(classic.end(engine, node)).toEqual(java.end); } /** * Java's run with two frames of one card in each other's place, their rows * too: the order Java's threads give two connections a few ten * milliseconds apart. */ function withSwapped(java: JavaRun, card: string, index: number): JavaRun { const frames = [...java.frames[card]!]; [frames[index], frames[index + 1]] = [frames[index + 1]!, frames[index]!]; const [first, second] = [String(index + 1), String(index + 2)]; const rows = (tables: Record | undefined) => tables === undefined ? undefined : { ...tables, [card]: tables[card]!.map((row) => (row[0] === first ? [second, ...row.slice(1)] : row[0] === second ? [first, ...row.slice(1)] : row)) }; return { ...java, frames: { ...java.frames, [card]: frames }, rows: rows(java.rows), layeredRows: rows(java.layeredRows) }; } describe("the classics of the classroom on FILIUS' examples as Java FILIUS runs them", () => { it('corrects Java only where it errs', () => { for (const [name, correct] of Object.entries(JAVA_BUGS)) { const java = exampleRecording(name); expect(JSON.stringify(correct(java)), name).not.toBe(JSON.stringify(java)); } }); it("gives the engine Java's way only where both are as long", () => { const java = exampleRecording('email'); const firstCall = java.programs.find(([, , text]) => text.startsWith('call '))![0]; // #3's route to the school's network, two hops through "" (12.0.0.1) or #2 (3.0.0.1), as Java's table says it. const withRoute = (route: string): JavaRun => ({ ...java, ripTables: java.ripTables.map(([time, router, table]) => [time, router, router === '#3' ? table.replace(/42\.0\.0\.0\/\S+ \S+ \S+ \d+/, route) : table]), }); const given = (recorded: JavaRun) => { const { engine, node } = started(CLASSICS.email!, recorded); engine.handle({ type: 'advance', toMilliseconds: firstCall }); equalRipTies(engine, recorded, node, firstCall); return engine.state.ripTablesByNodeId.get(node('#3'))!.find((route) => route.destinationNetwork === '42.0.0.0')!; }; for (const [gateway, card] of [['12.0.0.1', '12.0.0.2'], ['3.0.0.1', '3.0.0.2']] as const) { expect(given(withRoute(`42.0.0.0/255.255.255.0 ${gateway} ${card} 2`)), gateway).toMatchObject({ nextHop: gateway, interfaceIPAddress: card, metric: 2 }); } // Another length is no tie. expect(() => given(withRoute('42.0.0.0/255.255.255.0 3.0.0.1 3.0.0.2 3'))).toThrow(); }); for (const name of Object.keys(CLASSICS)) { it(`${name}: ${CLASSICS[name]!.file}`, () => { const measured = exampleRecording(name); expectSameAsJava(name, JAVA_BUGS[name]?.(measured) ?? measured); }); } it("gnutella: the search's two connections in either order", () => { // A recording before had them the other way round at 1.10's card (CLASSICS.gnutella.flow). const java = JAVA_BUGS.gnutella!(exampleRecording('gnutella')); const card = Object.entries(java.cards).find(([, each]) => each === 'Notebook 1.10/0')![0]; // The first two frames after the search between other hosts each, within 60 ms of each other. const hosts = (frame: JavaFrame) => [frame.sourceIp, frame.destinationIp].sort().join(' '); const frames = java.frames[card]!; const index = frames.findIndex((frame, at) => { const next = frames[at + 1]; return frame.time >= 5_500 && frame.tcpFlags !== undefined && next?.tcpFlags !== undefined && hosts(frame) !== hosts(next) && next.time - frame.time <= 60; }); expect(index).toBeGreaterThan(0); expectSameAsJava('gnutella', withSwapped(java, card, index)); }); });