feat(core): event queue, Ethernet, switch, ARP, IPv4, ICMP, routing and firewall

First slice of the TypeScript port of the filius-ipad runtime engine,
with 29 tests ported from the Swift suite. Parts not ported yet throw
NotPortedError instead of guessing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 19:22:57 +00:00
co-authored by Claude Opus 5.5
parent 7caee33b3d
commit 696334aa5a
29 changed files with 6199 additions and 11 deletions
+150
View File
@@ -0,0 +1,150 @@
// Ported from filius-ipad (GPLv2/GPLv3):
// State/TopologyEditorState.swift (runtime configuration value types)
import type { Id } from '../ids.js';
import { maskAddress, parseIPv4 } from './ipv4.js';
/** The IP settings of a PC or notebook (its single interface) — and a router's gateway. */
export interface DeviceConfiguration {
readonly ipAddress: string;
readonly subnetMask: string;
readonly defaultGateway: string;
readonly dnsServer: string;
}
export function deviceConfiguration(
ipAddress: string,
subnetMask: string,
defaultGateway = '',
dnsServer = '',
): DeviceConfiguration {
return { ipAddress, subnetMask, defaultGateway, dnsServer };
}
/** One interface of a router or gateway. */
export interface InterfaceConfiguration {
readonly ipAddress: string;
readonly subnetMask: string;
}
/** Map key for an interface: node and port. */
export type InterfaceKey = string;
export function interfaceKey(nodeId: Id, portId: Id): InterfaceKey {
return `${nodeId}/${portId}`;
}
/** A row of the manual routing table (Java `Weiterleitungstabelle`). */
export interface ManualRoute {
readonly destinationNetwork: string;
readonly subnetMask: string;
readonly gateway: string;
readonly interfaceIPAddress: string;
}
/** Longest numeric mask wins; on a tie the first row stays. */
export function bestMatchingManualRoute(
targetIPAddress: string,
routes: readonly ManualRoute[],
): ManualRoute | undefined {
const target = parseIPv4(targetIPAddress);
if (target === undefined) return undefined;
let best: ManualRoute | undefined;
let bestMask: number | undefined;
for (const route of routes) {
const destination = parseIPv4(route.destinationNetwork);
const mask = parseIPv4(route.subnetMask);
if (destination === undefined || mask === undefined) continue;
if (destination !== maskAddress(target, mask)) continue;
if (bestMask !== undefined && mask <= bestMask) continue;
bestMask = mask;
best = route;
}
return best;
}
export interface SwitchConfiguration {
readonly ssid: string;
readonly retentionTimeMilliseconds: number;
}
/** Java SwitchFirmware keeps a learned MAC address for five minutes. */
export const DEFAULT_SWITCH_RETENTION_MILLISECONDS = 300_000;
export function switchConfiguration(
ssid: string,
retentionTimeMilliseconds = DEFAULT_SWITCH_RETENTION_MILLISECONDS,
): SwitchConfiguration {
return { ssid, retentionTimeMilliseconds };
}
/** The first six hex digits of the node's UUID, lower case. */
export function defaultSwitchConfiguration(nodeId: Id): SwitchConfiguration {
const compact = nodeId.replaceAll('-', '').toLowerCase();
return switchConfiguration(compact.slice(0, 6));
}
// ── Firewall (Java filius.software.firewall.Firewall, FirewallRule) ─────────
export const FIREWALL_PROTOCOL = { all: -1, icmp: 1, tcp: 6, udp: 17 } as const;
export type FirewallProtocol = keyof typeof FIREWALL_PROTOCOL;
export type FirewallAction = 'drop' | 'accept';
/** Java writes this as the source of a rule that means "any directly connected network". */
export const DIRECTLY_CONNECTED_SOURCE_MARKER = '999.999.999.999';
export const ALL_PORTS = -1;
export interface FirewallRule {
readonly sourceIPAddress: string;
readonly sourceSubnetMask: string;
readonly destinationIPAddress: string;
readonly destinationSubnetMask: string;
/** A port number or ALL_PORTS. */
readonly port: number;
readonly protocol: FirewallProtocol;
readonly action: FirewallAction;
}
export function firewallRule(fields: Partial<FirewallRule> = {}): FirewallRule {
return {
sourceIPAddress: fields.sourceIPAddress ?? '',
sourceSubnetMask: fields.sourceSubnetMask ?? '',
destinationIPAddress: fields.destinationIPAddress ?? '',
destinationSubnetMask: fields.destinationSubnetMask ?? '',
port: fields.port ?? ALL_PORTS,
protocol: fields.protocol ?? 'tcp',
action: fields.action ?? 'drop',
};
}
export interface FirewallConfiguration {
readonly isActive: boolean;
readonly defaultPolicy: FirewallAction;
readonly dropIcmp: boolean;
readonly filterSynSegmentsOnly: boolean;
readonly filterUdp: boolean;
readonly rules: readonly FirewallRule[];
}
/** What a router or gateway has without a firewall configured: inactive. */
export function firewallConfiguration(fields: Partial<FirewallConfiguration> = {}): FirewallConfiguration {
return {
isActive: fields.isActive ?? false,
defaultPolicy: fields.defaultPolicy ?? 'drop',
dropIcmp: fields.dropIcmp ?? false,
filterSynSegmentsOnly: fields.filterSynSegmentsOnly ?? true,
filterUdp: fields.filterUdp ?? true,
rules: fields.rules ?? [],
};
}
/** What Java installs as the personal firewall of a PC: active, drop by default. */
export const JAVA_PERSONAL_FIREWALL_DEFAULTS: FirewallConfiguration = firewallConfiguration({
isActive: true,
defaultPolicy: 'drop',
dropIcmp: false,
filterSynSegmentsOnly: true,
filterUdp: true,
rules: [],
});
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
// Ported from filius-ipad (GPLv2/GPLv3):
// State/TopologyNetworkRuntimeEngine.swift (parseIPv4, sameSubnet)
// State/TopologyEditorState.swift (TopologyRuntimeManualRoute.parseIPv4)
//
// Two parsers exist on purpose, as in the Swift source: the runtime reads an
// octet the way Swift's `UInt8(String)` does, the route editor the way Java
// FILIUS validates its table cells (see routeTable.ts).
/**
* Swift `UInt8(segment)`: an optional sign, then decimal digits, value 0…255.
* Leading zeros are allowed ("007" is 7), whitespace is not.
*/
function parseSwiftOctet(segment: string): number | undefined {
if (!/^[+-]?[0-9]+$/.test(segment)) return undefined;
const value = Number(segment);
if (!Number.isInteger(value) || value < 0 || value > 255) return undefined;
return Object.is(value, -0) ? 0 : value;
}
/** Dotted quad to an unsigned 32-bit number, or undefined. */
export function parseIPv4(value: string): number | undefined {
const segments = value.split('.');
if (segments.length !== 4) return undefined;
let address = 0;
for (const segment of segments) {
const octet = parseSwiftOctet(segment);
if (octet === undefined) return undefined;
address = ((address << 8) | octet) >>> 0;
}
return address;
}
export function formatIPv4(address: number): string {
return [24, 16, 8, 0].map((shift) => String((address >>> shift) & 0xff)).join('.');
}
export function sameSubnet(lhs: string, rhs: string, mask: string): boolean {
const left = parseIPv4(lhs);
const right = parseIPv4(rhs);
const netmask = parseIPv4(mask);
if (left === undefined || right === undefined || netmask === undefined) return false;
return ((left & netmask) >>> 0) === ((right & netmask) >>> 0);
}
/** `address & mask` as an unsigned number. */
export function maskAddress(address: number, mask: number): number {
return (address & mask) >>> 0;
}
+124
View File
@@ -0,0 +1,124 @@
// Ported from filius-ipad (GPLv2/GPLv3):
// State/TopologyNetworkRuntimeEngine.swift ("Java-observable packet and route models")
// Frames and packets are typed values, not bytes — what the Java packet viewer
// shows, not what a real wire would carry.
export interface EthernetFrame {
readonly identity: number;
readonly sourceMacAddress: string;
readonly destinationMacAddress: string;
readonly payload: EthernetPayload;
}
export type EthernetPayload =
| { readonly kind: 'arp'; readonly packet: ArpPacket }
| { readonly kind: 'ipv4'; readonly packet: IPv4Packet };
export type ArpOperation = 'request' | 'reply';
export interface ArpPacket {
readonly operation: ArpOperation;
readonly senderMacAddress: string;
readonly senderIPAddress: string;
readonly targetMacAddress: string;
readonly targetIPAddress: string;
}
export const IPV4_PROTOCOL = { icmp: 1, tcp: 6, udp: 17 } as const;
export type IPv4Protocol = keyof typeof IPV4_PROTOCOL;
export interface IPv4Packet {
readonly identity: number;
readonly senderIPAddress: string;
readonly receiverIPAddress: string;
/** 0…255 */
readonly timeToLive: number;
readonly protocol: IPv4Protocol;
readonly payload: IPv4Payload;
}
export type IPv4Payload =
| { readonly kind: 'icmp'; readonly message: IcmpMessage }
| { readonly kind: 'tcp'; readonly segment: TcpSegment }
| { readonly kind: 'udp'; readonly datagram: UdpDatagram };
/** The same packet one hop further: identity kept, TTL lowered, never below 0. */
export function forwardingClone(packet: IPv4Packet, decrementingTtlBy = 1): IPv4Packet {
return {
...packet,
timeToLive: decrementingTtlBy >= packet.timeToLive ? 0 : packet.timeToLive - decrementingTtlBy,
};
}
export type IcmpMessageKind =
| 'echoRequest'
| 'echoReply'
| 'destinationNetworkUnreachable'
| 'destinationHostUnreachable'
| 'timeExceeded';
export interface IcmpMessage {
readonly kind: IcmpMessageKind;
/** 0…65535 */
readonly identifier: number;
/** 0…65535 */
readonly sequenceNumber: number;
readonly data: Uint8Array;
readonly embeddedOriginalPacket?: IPv4Packet;
}
export function icmpMessage(
kind: IcmpMessageKind,
fields: Partial<Omit<IcmpMessage, 'kind'>> = {},
): IcmpMessage {
const message: IcmpMessage = {
kind,
identifier: fields.identifier ?? 0,
sequenceNumber: fields.sequenceNumber ?? 0,
data: fields.data ?? new Uint8Array(),
};
return fields.embeddedOriginalPacket === undefined
? message
: { ...message, embeddedOriginalPacket: fields.embeddedOriginalPacket };
}
export const TCP_FLAG = {
finish: 1 << 0,
synchronize: 1 << 1,
reset: 1 << 2,
push: 1 << 3,
acknowledgement: 1 << 4,
} as const;
export type TcpSocketState =
| 'CLOSED'
| 'LISTEN'
| 'SYN_RCVD'
| 'SYN_SENT'
| 'ESTABLISHED'
| 'CLOSE_WAIT'
| 'LAST_ACK'
| 'FIN_WAIT_1'
| 'FIN_WAIT_2'
| 'CLOSING'
| 'TIME_WAIT';
export interface TcpSegment {
readonly sourcePort: number;
readonly destinationPort: number;
readonly sequenceNumber: number;
readonly acknowledgementNumber: number;
/** Bit set of TCP_FLAG values. */
readonly flags: number;
readonly payload: Uint8Array;
}
export interface UdpDatagram {
readonly sourcePort: number;
readonly destinationPort: number;
readonly payload: Uint8Array;
}
export function packetIdentityInFrame(frame: EthernetFrame): number | undefined {
return frame.payload.kind === 'ipv4' ? frame.payload.packet.identity : undefined;
}
+58
View File
@@ -0,0 +1,58 @@
// Ported from filius-ipad (GPLv2/GPLv3):
// State/TopologyNetworkRuntimeEngine.swift (TopologyJavaCompatibleRandom)
// A bit-exact java.util.Random: the 48-bit linear congruential generator
// Java FILIUS draws its RIP beacon jitter and ephemeral ports from. BigInt
// keeps the 48-bit arithmetic exact; a double would lose the low bits.
const MULTIPLIER = 0x5deece66dn;
const ADDEND = 0xbn;
const MASK = (1n << 48n) - 1n;
const INT32_MAX = 2147483647n;
export class JavaCompatibleRandom {
private state: bigint;
constructor(seed: bigint | number) {
this.state = (BigInt.asUintN(64, BigInt(seed)) ^ MULTIPLIER) & MASK;
}
/** The internal 48-bit state, for tests and for copying a generator. */
get seed(): bigint {
return this.state;
}
clone(): JavaCompatibleRandom {
const copy = new JavaCompatibleRandom(0);
copy.state = this.state;
return copy;
}
next(bits: number): number {
if (!(bits > 0 && bits <= 32)) throw new RangeError(`bits out of range: ${bits}`);
this.state = (this.state * MULTIPLIER + ADDEND) & MASK;
return Number(this.state >> BigInt(48 - bits));
}
nextInt(upperBound: number): number {
if (!(upperBound > 0)) throw new RangeError(`upperBound must be positive: ${upperBound}`);
const bound = BigInt(upperBound);
if ((upperBound & -upperBound) === upperBound) {
return Number((bound * BigInt(this.next(31))) >> 31n);
}
for (;;) {
const bits = BigInt(this.next(31));
const value = bits % bound;
if (bits - value + (bound - 1n) <= INT32_MAX) return Number(value);
}
}
nextFloat(): number {
return Math.fround(this.next(24) / (1 << 24));
}
/** Java RIP: 30 s · (f/3 + 0.84), truncated — somewhere between 25.2 s and 35.2 s. */
nextRipJitterMilliseconds(): number {
const javaFloat = this.nextFloat();
return Math.trunc(30_000.0 * (javaFloat / 3.0 + 0.84));
}
}
+222
View File
@@ -0,0 +1,222 @@
// Ported from filius-ipad (GPLv2/GPLv3):
// State/TopologyJavaRouteTable.swift (row projection, validation, drafts)
// Java counterpart: filius.software.vermittlungsschicht.Weiterleitungstabelle
// (holeTabelle: the order of the generated rows).
// The Swift file also carries table geometry for SwiftUI; that belongs to the
// editor and is not ported here.
import { formatIPv4, maskAddress } from './ipv4.js';
import type { InterfaceConfiguration, ManualRoute } from './config.js';
export type RouteOrigin = 'localHost' | 'connected' | 'localhost' | 'manual' | 'defaultGateway' | 'rip';
export interface RouteRow {
readonly destinationNetwork: string;
readonly subnetMask: string;
readonly nextHop: string;
readonly interfaceIPAddress: string;
readonly origin: RouteOrigin;
readonly isEditable: boolean;
readonly metric?: number;
readonly expiresAtMilliseconds?: number;
}
export type RouteTableColumn = 'destination' | 'subnetMask' | 'nextGateway' | 'interface';
/**
* A Java table octet: digits only, leading zeros allowed, at most three
* significant digits, 0…255 ("001" is valid, " 1" and "+1" are not).
*/
function parseJavaOctet(segment: string): number | undefined {
if (segment.length === 0 || !/^[0-9]+$/.test(segment)) return undefined;
const significant = segment.replace(/^0+/, '');
if (significant.length === 0) return 0;
if (significant.length > 3) return undefined;
const value = Number(significant);
return value <= 255 ? value : undefined;
}
function parseJavaIPv4(value: string): number | undefined {
const segments = value.split('.');
if (segments.length !== 4) return undefined;
let address = 0;
for (const segment of segments) {
const octet = parseJavaOctet(segment);
if (octet === undefined) return undefined;
address = ((address << 8) | octet) >>> 0;
}
return address;
}
export function isValidJavaIPAddress(value: string): boolean {
return parseJavaIPv4(value) !== undefined;
}
/**
* FILIUS uses `musterSubNetz`, a dotted-decimal syntax check. It deliberately
* accepts non-contiguous masks in the route editor.
*/
export function isValidJavaSubnetMask(value: string): boolean {
return parseJavaIPv4(value) !== undefined;
}
export function isValidRouteCell(value: string, column: RouteTableColumn): boolean {
return column === 'subnetMask' ? isValidJavaSubnetMask(value) : isValidJavaIPAddress(value);
}
function sameNetwork(lhs: string, rhs: string, mask: string): boolean {
const left = parseJavaIPv4(lhs);
const right = parseJavaIPv4(rhs);
const netmask = parseJavaIPv4(mask);
if (left === undefined || right === undefined || netmask === undefined) return false;
return maskAddress(left, netmask) === maskAddress(right, netmask);
}
function networkAddress(ipAddress: string, subnetMask: string): string | undefined {
const address = parseJavaIPv4(ipAddress);
const mask = parseJavaIPv4(subnetMask);
if (address === undefined || mask === undefined) return undefined;
return formatIPv4(maskAddress(address, mask));
}
/**
* The static table as Java shows it: the interfaces' own addresses (newest
* first), their networks, loopback, the manual rows, then the default route.
*/
export function routeTableRows(
interfaceConfigurations: readonly InterfaceConfiguration[],
manualRoutes: readonly ManualRoute[],
defaultGateway: string | undefined,
): RouteRow[] {
const validInterfaces = interfaceConfigurations.filter(
(configuration) =>
parseJavaIPv4(configuration.ipAddress) !== undefined &&
parseJavaIPv4(configuration.subnetMask) !== undefined,
);
const result: RouteRow[] = manualRoutes.map((route) => ({
destinationNetwork: route.destinationNetwork,
subnetMask: route.subnetMask,
nextHop: route.gateway,
interfaceIPAddress: route.interfaceIPAddress,
origin: 'manual',
isEditable: true,
}));
result.unshift({
destinationNetwork: '127.0.0.0',
subnetMask: '255.0.0.0',
nextHop: '127.0.0.1',
interfaceIPAddress: '127.0.0.1',
origin: 'localhost',
isEditable: false,
});
for (const configuration of validInterfaces) {
const network = networkAddress(configuration.ipAddress, configuration.subnetMask);
if (network === undefined) continue;
result.unshift({
destinationNetwork: network,
subnetMask: configuration.subnetMask,
nextHop: configuration.ipAddress,
interfaceIPAddress: configuration.ipAddress,
origin: 'connected',
isEditable: false,
});
}
for (const configuration of validInterfaces) {
result.unshift({
destinationNetwork: configuration.ipAddress,
subnetMask: '255.255.255.255',
nextHop: '127.0.0.1',
interfaceIPAddress: '127.0.0.1',
origin: 'localHost',
isEditable: false,
});
}
const trimmedGateway = defaultGateway?.trim() ?? '';
const primaryInterface = validInterfaces[0];
if (parseJavaIPv4(trimmedGateway) !== undefined && primaryInterface !== undefined) {
let selectedInterface = primaryInterface.ipAddress;
for (const configuration of validInterfaces) {
if (sameNetwork(trimmedGateway, configuration.ipAddress, configuration.subnetMask)) {
selectedInterface = configuration.ipAddress;
}
}
result.push({
destinationNetwork: '0.0.0.0',
subnetMask: '0.0.0.0',
nextHop: trimmedGateway,
interfaceIPAddress: selectedInterface,
origin: 'defaultGateway',
isEditable: false,
});
}
return result;
}
export function visibleRouteRows(rows: readonly RouteRow[], showAllEntries: boolean): RouteRow[] {
return showAllEntries ? [...rows] : rows.filter((row) => row.isEditable);
}
/** One row being edited in the route editor. */
export interface RouteDraft {
readonly id: string;
destinationNetwork: string;
subnetMask: string;
nextGateway: string;
interfaceIPAddress: string;
isPendingInsertion: boolean;
}
export function blankRouteDraft(id: string): RouteDraft {
return {
id,
destinationNetwork: '',
subnetMask: '',
nextGateway: '',
interfaceIPAddress: '',
isPendingInsertion: true,
};
}
export function routeDraftFromRoute(id: string, route: ManualRoute): RouteDraft {
return {
id,
destinationNetwork: route.destinationNetwork,
subnetMask: route.subnetMask,
nextGateway: route.gateway,
interfaceIPAddress: route.interfaceIPAddress,
isPendingInsertion: false,
};
}
/** The route a draft stands for — only once all four cells are valid. */
export function manualRouteFromDraft(draft: RouteDraft): ManualRoute | undefined {
if (
!isValidRouteCell(draft.destinationNetwork, 'destination') ||
!isValidRouteCell(draft.subnetMask, 'subnetMask') ||
!isValidRouteCell(draft.nextGateway, 'nextGateway') ||
!isValidRouteCell(draft.interfaceIPAddress, 'interface')
) {
return undefined;
}
return {
destinationNetwork: draft.destinationNetwork,
subnetMask: draft.subnetMask,
gateway: draft.nextGateway,
interfaceIPAddress: draft.interfaceIPAddress,
};
}
/** At most one blank row waits at the end of the editor. */
export function appendingBlankDraftIfNeeded(
drafts: readonly RouteDraft[],
newId: () => string,
): RouteDraft[] {
if (drafts.some((draft) => draft.isPendingInsertion)) return [...drafts];
return [...drafts, blankRouteDraft(newId())];
}
+90
View File
@@ -0,0 +1,90 @@
// Ported from filius-ipad (GPLv2/GPLv3):
// State/TopologyNetworkRuntimeEngine.swift (TopologyNetworkRuntimeTopologySnapshot)
// What the runtime starts from: the topology and every configuration, frozen
// at the moment the simulation starts. Later protocol slices add their
// configuration maps here (DHCP, NAT, remote link, wireless).
import { compareIds, type Id } from '../ids.js';
import type { NodeKind } from '../model/node.js';
import { canonicalMacAddress } from '../model/node.js';
import type {
DeviceConfiguration,
FirewallConfiguration,
InterfaceConfiguration,
InterfaceKey,
ManualRoute,
SwitchConfiguration,
} from './config.js';
export interface PortSnapshot {
readonly id: Id;
readonly label: string;
/** Canonical, or undefined for a port whose MAC address is derived from its id. */
readonly macAddress?: string;
}
export function portSnapshot(id: Id, label: string, macAddress?: string): PortSnapshot {
const canonical = canonicalMacAddress(macAddress);
return canonical === undefined ? { id, label } : { id, label, macAddress: canonical };
}
export interface NodeSnapshot {
readonly id: Id;
readonly kind: NodeKind;
readonly ports: readonly PortSnapshot[];
}
export interface LinkSnapshot {
readonly id: Id;
readonly sourceNodeId: Id;
readonly sourcePortId: Id;
readonly targetNodeId: Id;
readonly targetPortId: Id;
}
export interface TopologySnapshot {
/** Sorted by id (Swift `uuidString` order). */
readonly nodes: readonly NodeSnapshot[];
/** Sorted by id (Swift `uuidString` order). */
readonly links: readonly LinkSnapshot[];
readonly deviceConfigurations: ReadonlyMap<Id, DeviceConfiguration>;
readonly interfaceConfigurations: ReadonlyMap<InterfaceKey, InterfaceConfiguration>;
readonly manualRoutesByNodeId: ReadonlyMap<Id, readonly ManualRoute[]>;
readonly ripEnabledByNodeId: ReadonlyMap<Id, boolean>;
readonly firewallConfigurationsByNodeId: ReadonlyMap<Id, FirewallConfiguration>;
readonly switchConfigurationsByNodeId: ReadonlyMap<Id, SwitchConfiguration>;
}
export interface TopologySnapshotInput {
nodes: readonly NodeSnapshot[];
links: readonly LinkSnapshot[];
deviceConfigurations?: ReadonlyMap<Id, DeviceConfiguration>;
interfaceConfigurations?: ReadonlyMap<InterfaceKey, InterfaceConfiguration>;
manualRoutesByNodeId?: ReadonlyMap<Id, readonly ManualRoute[]>;
ripEnabledByNodeId?: ReadonlyMap<Id, boolean>;
firewallConfigurationsByNodeId?: ReadonlyMap<Id, FirewallConfiguration>;
switchConfigurationsByNodeId?: ReadonlyMap<Id, SwitchConfiguration>;
}
export function topologySnapshot(input: TopologySnapshotInput): TopologySnapshot {
return {
nodes: [...input.nodes].sort((lhs, rhs) => compareIds(lhs.id, rhs.id)),
links: [...input.links].sort((lhs, rhs) => compareIds(lhs.id, rhs.id)),
deviceConfigurations: input.deviceConfigurations ?? new Map(),
interfaceConfigurations: input.interfaceConfigurations ?? new Map(),
manualRoutesByNodeId: input.manualRoutesByNodeId ?? new Map(),
ripEnabledByNodeId: input.ripEnabledByNodeId ?? new Map(),
firewallConfigurationsByNodeId: input.firewallConfigurationsByNodeId ?? new Map(),
switchConfigurationsByNodeId: input.switchConfigurationsByNodeId ?? new Map(),
};
}
/** The same snapshot with some fields replaced (Swift rebuilds it field by field). */
export function withSnapshot(
snapshot: TopologySnapshot,
changes: Partial<TopologySnapshotInput>,
): TopologySnapshot {
return topologySnapshot({ ...snapshot, ...changes });
}
export const EMPTY_TOPOLOGY_SNAPSHOT: TopologySnapshot = topologySnapshot({ nodes: [], links: [] });
+33
View File
@@ -0,0 +1,33 @@
// Ported from filius-ipad (GPLv2/GPLv3):
// State/TopologyNetworkRuntimeEngine.swift (TopologyNetworkRuntimeSpeed)
// Java counterpart: filius.hardware.Verbindung (MIN_VERZOEGERUNG_IN_MILLIS,
// verzoegerungsFaktor) and GUIMainMenu.updateLatency.
export const MINIMUM_SPEED_PERCENT = 1;
export const MAXIMUM_SPEED_PERCENT = 100;
export const DEFAULT_SPEED_PERCENT = 90;
export const JAVA_MINIMUM_LINK_DELAY_MILLISECONDS = 5;
export class RuntimeSpeed {
readonly percent: number;
constructor(percent: number = DEFAULT_SPEED_PERCENT) {
this.percent = Math.min(MAXIMUM_SPEED_PERCENT, Math.max(MINIMUM_SPEED_PERCENT, Math.trunc(percent)));
}
/** Java's delay factor: 1 at full speed, 100 at the slowest. */
get javaDelayFactor(): number {
return MAXIMUM_SPEED_PERCENT - this.percent + 1;
}
/** How long a frame travels over one cable. */
get linkTransmissionDelayMilliseconds(): number {
return JAVA_MINIMUM_LINK_DELAY_MILLISECONDS * this.javaDelayFactor;
}
/** A base delay scaled like a cable, or undefined past the safe integer range. */
scaledLinkDelay(baseMilliseconds: number): number | undefined {
const delay = baseMilliseconds * this.javaDelayFactor;
return Number.isSafeInteger(delay) ? delay : undefined;
}
}
+58
View File
@@ -0,0 +1,58 @@
// Ported from filius-ipad (GPLv2/GPLv3):
// State/TopologyNetworkRuntimeEngine.swift (packet trace model)
// Java counterpart: filius.rahmenprogramm.nachrichten.Lauscher — here every
// step a frame or packet takes is one trace event; the packet viewer groups them.
import type { Id } from '../ids.js';
export type TraceDirection = 'inbound' | 'outbound' | 'local';
export type TraceLayer = 'application' | 'transport' | 'network' | 'dataLink' | 'physical';
export type TraceOperation =
| 'created'
| 'sent'
| 'received'
| 'forwarded'
| 'accepted'
| 'dropped'
| 'rewritten'
| 'retransmitted'
| 'compatibilityAdapter';
export interface HeaderField {
readonly name: string;
readonly value: string;
}
export interface TraceEvent {
readonly id: number;
readonly timeMilliseconds: number;
readonly frameIdentity?: number;
readonly packetIdentity?: number;
readonly nodeId: Id;
readonly interfaceId?: Id;
readonly direction: TraceDirection;
readonly layer: TraceLayer;
readonly operation: TraceOperation;
readonly beforeHeaders: readonly HeaderField[];
readonly afterHeaders: readonly HeaderField[];
readonly detail?: string;
}
/** How many trace events the runtime keeps, and how many it drops at once when full. */
export interface TraceRetentionPolicy {
readonly maximumEventCount: number;
readonly evictionBatchSize: number;
}
export function traceRetentionPolicy(maximumEventCount: number, evictionBatchSize: number): TraceRetentionPolicy {
if (!(maximumEventCount > 0)) throw new RangeError('Packet-trace retention must keep at least one event');
if (!(evictionBatchSize > 0)) throw new RangeError('Packet-trace eviction must remove at least one event');
if (evictionBatchSize > maximumEventCount) {
throw new RangeError('Packet-trace eviction cannot exceed the retention limit');
}
return { maximumEventCount, evictionBatchSize };
}
export const PRODUCTION_TRACE_RETENTION = traceRetentionPolicy(10_000, 1_000);