GnutellaRun lets Java's PeerToPeerAnwendung join peers on terminal-net. The core's gnutella.ts does the same: a ping in Java's text format to port 6346, a pong back, the ping sent on to the other peers and their pongs back the way it came, at most the program's number of peers. Departure from Java: a ping that comes a second time gets no pong and its connection stays (Java's worker throws on the missing pong). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
301 lines
15 KiB
TypeScript
301 lines
15 KiB
TypeScript
// What tools/java-oracle's DhcpRun and TcpRun recorded in Java FILIUS
|
|
// (<name>.java.json: every frame each card saw, FILIUS' own packet log), and
|
|
// the same view of an engine run: each frame when it first shows on any
|
|
// host's card, as a line of text. Shared by dhcpJava.test.ts and
|
|
// tcpJava.test.ts.
|
|
//
|
|
// Not only the frames of one card: FILIUS' log misses the DISCOVER of the
|
|
// DHCP client that sends second when two start together (measured in four of
|
|
// four runs of dhcp-lan; the server receives it) — a fault of the log, not of
|
|
// the network. Java logs no frame identity; the same text within
|
|
// DUPLICATE_WINDOW_MILLISECONDS is one frame (measured: a frame reaches the
|
|
// last card within some 130 ms; the closest two distinct frames of the same
|
|
// text are the DISCOVERs around a DECLINE, some 520 ms apart).
|
|
//
|
|
// Java runs on threads in real time, at the default speed of 90 % (55 ms a
|
|
// cable, a round trip time of 2750 ms). Its gaps match the engine's within a
|
|
// few milliseconds; where they do not, it is Java's threads (a sender blocked
|
|
// by a busy cable wakes late, up to some 110 ms measured), the log's lost
|
|
// DISCOVER above, or a story whose calls the engine makes one after the
|
|
// other. A gap may differ by GAP_TOLERANCE_MILLISECONDS; a wrong round trip
|
|
// time (250 ms) may not. A cable carries one frame after the other in both
|
|
// (engine.test.ts checks that: one cable's delay is below this tolerance).
|
|
|
|
import { expect } from 'vitest';
|
|
import {
|
|
captureCommentText,
|
|
captureRows,
|
|
javaTextLength,
|
|
NetworkRuntimeEngine,
|
|
TCP_FLAG,
|
|
type CapturedFrame,
|
|
type CaptureLayer,
|
|
type EthernetFrame,
|
|
type TopologySnapshot,
|
|
} from '../src/index.js';
|
|
import { snapshotOf, type Scenario } from './scenario.js';
|
|
|
|
export const GAP_TOLERANCE_MILLISECONDS = 120;
|
|
const DUPLICATE_WINDOW_MILLISECONDS = 300;
|
|
|
|
export interface JavaFrame {
|
|
time: number;
|
|
source: string;
|
|
destination: string;
|
|
arp?: number;
|
|
senderIp?: string;
|
|
targetIp?: string;
|
|
sourceIp?: string;
|
|
destinationIp?: string;
|
|
protocol?: number;
|
|
sourcePort?: number;
|
|
destinationPort?: number;
|
|
payload?: string | null;
|
|
icmp?: number;
|
|
icmpCode?: number;
|
|
tcpFlags?: string;
|
|
sequence?: number;
|
|
acknowledgement?: number;
|
|
}
|
|
|
|
export interface SeenFrame {
|
|
readonly time: number;
|
|
readonly text: string;
|
|
}
|
|
|
|
export function javaText(frame: JavaFrame): string {
|
|
const macs = `${frame.source} > ${frame.destination}`.toUpperCase();
|
|
if (frame.arp !== undefined) return `${macs} ARP ${frame.arp === 1 ? 'request' : 'reply'} ${frame.senderIp} > ${frame.targetIp}`;
|
|
if (frame.icmp !== undefined) return icmpText(macs, frame.icmp, frame.icmpCode!, frame.sourceIp!, frame.destinationIp!, frame.sequence!);
|
|
const ends = `${frame.sourceIp}:${frame.sourcePort} > ${frame.destinationIp}:${frame.destinationPort}`;
|
|
if (frame.tcpFlags !== undefined) {
|
|
return `${macs} TCP ${ends} ${frame.tcpFlags || '-'} seq=${frame.sequence} ack=${frame.acknowledgement} length=${(frame.payload ?? '').length}`;
|
|
}
|
|
return `${macs} UDP ${ends} ${frame.payload}`;
|
|
}
|
|
|
|
/** The ICMP type numbers Java logs. */
|
|
/** Type and code of each ICMP message the engine knows. */
|
|
const ICMP_TYPES: Record<string, [number, number]> = {
|
|
echoReply: [0, 0],
|
|
destinationNetworkUnreachable: [3, 0],
|
|
destinationHostUnreachable: [3, 1],
|
|
echoRequest: [8, 0],
|
|
timeExceeded: [11, 0],
|
|
};
|
|
|
|
/** The sequence number only for echo messages: an error carries none of its own. */
|
|
function icmpText(macs: string, type: number, code: number, source: string, destination: string, sequence: number): string {
|
|
const echo = type === 0 || type === 8 ? ` seq=${sequence}` : '';
|
|
return `${macs} ICMP ${type}/${code} ${source} > ${destination}${echo}`;
|
|
}
|
|
|
|
const FLAG_LETTERS: [number, string][] = [
|
|
[TCP_FLAG.synchronize, 'S'],
|
|
[TCP_FLAG.acknowledgement, 'A'],
|
|
[TCP_FLAG.finish, 'F'],
|
|
[TCP_FLAG.push, 'P'],
|
|
[TCP_FLAG.reset, 'R'],
|
|
];
|
|
|
|
export function engineText(frame: EthernetFrame): string {
|
|
const macs = `${frame.sourceMacAddress} > ${frame.destinationMacAddress}`.toUpperCase();
|
|
if (frame.payload.kind === 'arp') {
|
|
const arp = frame.payload.packet;
|
|
return `${macs} ARP ${arp.operation} ${arp.senderIPAddress} > ${arp.targetIPAddress}`;
|
|
}
|
|
const packet = frame.payload.packet;
|
|
if (packet.payload.kind === 'icmp') {
|
|
const message = packet.payload.message;
|
|
const [type, code] = ICMP_TYPES[message.kind]!;
|
|
return icmpText(macs, type, code, packet.senderIPAddress, packet.receiverIPAddress, message.sequenceNumber);
|
|
}
|
|
if (packet.payload.kind === 'tcp') {
|
|
const segment = packet.payload.segment;
|
|
const flags = FLAG_LETTERS.filter(([bit]) => (segment.flags & bit) !== 0).map(([, letter]) => letter).join('');
|
|
return `${macs} TCP ${packet.senderIPAddress}:${segment.sourcePort} > ${packet.receiverIPAddress}:${segment.destinationPort} ${flags || '-'} seq=${segment.sequenceNumber} ack=${segment.acknowledgementNumber} length=${javaTextLength(segment.payload)}`;
|
|
}
|
|
const datagram = packet.payload.datagram;
|
|
return `${macs} UDP ${packet.senderIPAddress}:${datagram.sourcePort} > ${packet.receiverIPAddress}:${datagram.destinationPort} ${new TextDecoder().decode(datagram.payload)}`;
|
|
}
|
|
|
|
/**
|
|
* A started engine on the scenario's network (or on `snapshot`, made from
|
|
* it) whose frames can be read afterwards as FILIUS logs them.
|
|
*/
|
|
export function watchedEngine(scenario: Scenario, snapshot: TopologySnapshot = snapshotOf(scenario)) {
|
|
const engine = new NetworkRuntimeEngine(scenario.seed);
|
|
const framesByIdentity = new Map<number, EthernetFrame>();
|
|
const send = engine.sendEthernetFrame.bind(engine);
|
|
engine.sendEthernetFrame = (fromNodeId, outgoingPortId, frame) => {
|
|
framesByIdentity.set(frame.identity, frame);
|
|
send(fromNodeId, outgoingPortId, frame);
|
|
};
|
|
engine.handle({ type: 'start', snapshot, seed: scenario.seed, initialTimeMilliseconds: 0 });
|
|
|
|
/** Every frame when it first shows on a host's card (sent, or received). */
|
|
const frames = (): SeenFrame[] => {
|
|
const hosts = new Set(scenario.nodes.filter((node) => node.kind !== 'networkSwitch').map((node) => node.id));
|
|
const firstSeen = new Map<number, SeenFrame>();
|
|
for (const event of engine.state.packetTraces) {
|
|
if (event.layer !== 'physical' || !hosts.has(event.nodeId) || event.frameIdentity === undefined) continue;
|
|
if (!((event.direction === 'outbound' && event.operation === 'sent') || (event.direction === 'inbound' && event.operation === 'received'))) {
|
|
continue;
|
|
}
|
|
if (firstSeen.has(event.frameIdentity)) continue;
|
|
firstSeen.set(event.frameIdentity, { time: event.timeMilliseconds, text: engineText(framesByIdentity.get(event.frameIdentity)!) });
|
|
}
|
|
return [...firstSeen.values()];
|
|
};
|
|
return { engine, frames };
|
|
}
|
|
|
|
/** Java's frames of all cards, each once: the same text within the window is the same frame. */
|
|
export function javaFrames(framesByNode: Record<string, JavaFrame[]>, windowMilliseconds = DUPLICATE_WINDOW_MILLISECONDS): SeenFrame[] {
|
|
const all = Object.values(framesByNode)
|
|
.flat()
|
|
.map((frame) => ({ time: frame.time, text: javaText(frame) }))
|
|
.sort((lhs, rhs) => lhs.time - rhs.time);
|
|
const frames: SeenFrame[] = [];
|
|
for (const frame of all) {
|
|
if (frames.some((each) => each.text === frame.text && frame.time - each.time <= windowMilliseconds)) continue;
|
|
frames.push(frame);
|
|
}
|
|
return frames;
|
|
}
|
|
|
|
/** Consecutive frames: the text, and the time since the one before. */
|
|
export function gaps(frames: readonly SeenFrame[]) {
|
|
return frames.map((frame, index) => ({ text: frame.text, gap: index === 0 ? 0 : frame.time - frames[index - 1]!.time }));
|
|
}
|
|
|
|
/**
|
|
* Each card's packet capture against Java's (the Lauscher's list of the
|
|
* card, as the oracle wrote it: under the node's id for its first card,
|
|
* `id#index` for the others): the same frames in the same order, each at
|
|
* about the same time (both counted from the run's first frame, or from
|
|
* `starts`). `text`
|
|
* puts placeholders in a frame's text (ports, IDs a test cannot predict),
|
|
* `normalized` does so for a card's whole list; with `until` (Java's time),
|
|
* only the frames before it; with `from`, only those from it.
|
|
*/
|
|
/** A card to compare: the key Java's lists have it under, and its port in the engine. */
|
|
export interface CaptureCard {
|
|
readonly key: string;
|
|
readonly portId: string;
|
|
}
|
|
|
|
/** A scenario's cards: its hosts' ports, under the node's id for the first card and `id#index` for the others (ScenarioNetwork). */
|
|
export function scenarioCards(scenario: Scenario): CaptureCard[] {
|
|
return scenario.nodes
|
|
.filter((node) => node.kind !== 'networkSwitch')
|
|
.flatMap((node) => node.ports.map((port, index) => ({ key: index === 0 ? node.id : `${node.id}#${index}`, portId: port.id })));
|
|
}
|
|
|
|
export function expectSameCaptures(
|
|
engine: NetworkRuntimeEngine,
|
|
cardsOrScenario: Scenario | readonly CaptureCard[],
|
|
java: Record<string, JavaFrame[]>,
|
|
options: {
|
|
text?: (text: string) => string;
|
|
normalized?: (frames: SeenFrame[], card: string) => SeenFrame[];
|
|
from?: number | undefined;
|
|
until?: number | undefined;
|
|
tolerance?: number;
|
|
starts?: { java: number; engine: number };
|
|
/** Java's tables of the cards (ScenarioNetwork.writeRows), compared for the frames compared. */
|
|
tables?: JavaTables;
|
|
/** Placeholders in a table cell, beyond those of tableCell. */
|
|
cell?: (text: string) => string;
|
|
} = {},
|
|
): void {
|
|
const text = options.text ?? ((each: string) => each);
|
|
const engineByCard = new Map<string, SeenFrame[]>();
|
|
const capturedByCard = new Map<string, readonly CapturedFrame[]>();
|
|
const cards = 'nodes' in cardsOrScenario ? scenarioCards(cardsOrScenario) : cardsOrScenario;
|
|
for (const { key, portId } of cards) {
|
|
capturedByCard.set(key, engine.capture.frames(portId));
|
|
engineByCard.set(key, capturedByCard.get(key)!.map((each) => ({ time: each.timeMilliseconds, text: text(engineText(each.frame)) })));
|
|
}
|
|
const javaByCard = new Map(Object.entries(java).map(([card, frames]) => [card, frames.map((frame) => ({ time: frame.time, text: text(javaText(frame)) }))]));
|
|
expect([...engineByCard.keys()].sort()).toEqual([...javaByCard.keys()].sort());
|
|
const first = (byCard: Map<string, SeenFrame[]>) => Math.min(...[...byCard.values()].flat().map((frame) => frame.time));
|
|
const engineStart = options.starts?.engine ?? first(engineByCard);
|
|
const javaStart = options.starts?.java ?? first(javaByCard);
|
|
const from = (options.from ?? Number.NEGATIVE_INFINITY) - javaStart;
|
|
const until = (options.until ?? Number.POSITIVE_INFINITY) - javaStart;
|
|
const normalized = options.normalized ?? ((frames: SeenFrame[], _card: string) => frames);
|
|
const window = (frames: SeenFrame[], start: number) =>
|
|
frames.filter((frame) => frame.time - start >= from && frame.time - start < until).map((frame) => ({ time: frame.time - start, text: frame.text }));
|
|
const inWindow = (time: number, start: number) => time - start >= from && time - start < until;
|
|
for (const [card, javaList] of javaByCard) {
|
|
if (options.tables !== undefined) {
|
|
// The rows of the frames in the window, frame by frame.
|
|
const javaNumbers = javaList.flatMap((frame, index) => (inWindow(frame.time, javaStart) ? [index + 1] : []));
|
|
const engineFrames = capturedByCard.get(card)!.filter((each) => inWindow(each.timeMilliseconds, engineStart));
|
|
for (const [view, withAddresses] of [['rows', true], ['layeredRows', false]] as const) {
|
|
const javaRows = options.tables[view]?.[card];
|
|
if (javaRows === undefined) continue;
|
|
const engineRows = captureRows(engineFrames, { withAddresses });
|
|
const cell = (value: string | null) => (value === null ? null : (options.cell ?? ((each: string) => each))(tableCell(value)));
|
|
for (const [index, number] of javaNumbers.entries()) {
|
|
const frame = engineFrames[index];
|
|
const expectedRows = javaRows.filter((row) => row[0] === String(number)).map((row) => row.slice(1).map(cell));
|
|
// A frame a test added where Java errs (JAVA_BUGS) has no table of Java's.
|
|
if (expectedRows.length === 0 && number > Math.max(0, ...javaRows.map((row) => Number(row[0])))) continue;
|
|
const actualRows = engineRows
|
|
.filter((row) => row.number === frame?.number)
|
|
.map((row) => [row.source, row.destination, row.protocol, JAVA_LAYERS[row.layer], captureCommentText(row.comment, JAVA_WORDS, javaNumber)].map(cell));
|
|
expect(actualRows, `${card} ${view} of frame #${index + 1}`).toEqual(expectedRows);
|
|
}
|
|
}
|
|
}
|
|
const expected = normalized(window(javaList, javaStart), card);
|
|
const actual = normalized(window(engineByCard.get(card)!, engineStart), card);
|
|
for (let index = 0; index < Math.max(expected.length, actual.length); index += 1) {
|
|
expect(actual[index]?.text, `${card} frame #${index + 1}`).toBe(expected[index]?.text);
|
|
expect(Math.abs(actual[index]!.time - expected[index]!.time), `${card} time of frame #${index + 1}`).toBeLessThanOrEqual(
|
|
options.tolerance ?? GAP_TOLERANCE_MILLISECONDS,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** A client's free port as "client": Java draws it at random, the engine from its seed. */
|
|
export function withClientPortText(text: string): string {
|
|
return text.replace(/:(\d{5})\b/g, (whole, port: string) => (Number(port) >= 49_152 ? ':client' : whole));
|
|
}
|
|
|
|
/** Java's capture tables per card: number, source, destination, protocol, layer, comment (ScenarioNetwork.writeRows). */
|
|
export interface JavaTables {
|
|
rows?: Record<string, (string | null)[][]>;
|
|
layeredRows?: Record<string, (string | null)[][]>;
|
|
}
|
|
|
|
/** Java's layer names and comment words: its German message bundle, the locale it runs in (Locale.GERMANY). */
|
|
const JAVA_LAYERS: Record<CaptureLayer, string> = { networkAccess: 'Netzzugang', internet: 'Vermittlung', transport: 'Transport', application: 'Anwendung' };
|
|
const JAVA_WORDS = { protocol: 'Protokoll', searchForMac: 'Suche nach MAC f\u00fcr', macIs: 'MAC ist' };
|
|
|
|
/** NumberFormat for Locale.GERMANY: groups of three digits with dots. */
|
|
function javaNumber(value: number): string {
|
|
return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, '.');
|
|
}
|
|
|
|
/**
|
|
* Placeholders every test needs in a table cell: free ports, DNS message IDs,
|
|
* IP packet IDs, link-local addresses. And two departures from Java: its ARP
|
|
* reply to 0.0.0.0 writes the broadcast address in lower case
|
|
* (ARP.sendArpReply), everything else in upper case — the engine always in
|
|
* upper case (better than Java where nothing depends on it); and Java
|
|
* misspells "ICMP Time Exeeded" (a typo, not taken).
|
|
*/
|
|
function tableCell(text: string): string {
|
|
return withClientPortText(text)
|
|
.replaceAll('ff:ff:ff:ff:ff:ff', 'FF:FF:FF:FF:FF:FF')
|
|
.replace('ICMP Time Exeeded (poof)', 'ICMP Time Exceeded (poof)')
|
|
.replace(/^(\d{5})$/, (whole, port: string) => (Number(port) >= 49_152 ? 'client' : whole))
|
|
.replace(/ID=\d+/g, 'ID=x')
|
|
.replace(/\[id=\d+/g, '[id=x')
|
|
.replace(/169\.254\.1\.\d+/g, '169.254.1.x');
|
|
}
|