importProject carries the system software settings into the model the core runs: switch SSID and learning time (an SSID Java would roll comes from the node id, as in Swift), a host's wireless network, manual routes, RIP and the firewall of every router and home router. The Swift oracle writes these settings too; they match on the 13 files Swift reads completely. A router's default gateway — its first interface's, which Java routes to (Weiterleitungstabelle.holeTabelle) — is imported into the router's device configuration. Swift's model drops it; in internetwork_komplett the way back from 141.99.5.10 depends on it. A test pings across three routers in the core, with and without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
446 lines
20 KiB
Swift
446 lines
20 KiB
Swift
// Replays scenario files in the filius-ipad runtime engine and writes what
|
|
// happened: every trace event, the result of every step, and the end state.
|
|
// The TypeScript port replays the same files (packages/core/test/oracle.test.ts)
|
|
// and must produce the same output.
|
|
//
|
|
// Usage: Oracle <directory> — reads <name>.scenario.json, writes <name>.swift.json
|
|
|
|
@testable import FiliusCore
|
|
import Foundation
|
|
|
|
// MARK: - Scenario format (shared with packages/core/test/oracle/scenario.ts)
|
|
|
|
struct ScenarioPort: Decodable { let id: String; let label: String; let mac: String? }
|
|
struct ScenarioNode: Decodable { let id: String; let kind: String; let ports: [ScenarioPort] }
|
|
struct ScenarioLink: Decodable {
|
|
let id: String
|
|
let sourceNode: String; let sourcePort: String
|
|
let targetNode: String; let targetPort: String
|
|
}
|
|
struct ScenarioDevice: Decodable { let ip: String; let mask: String; let gateway: String?; let dns: String? }
|
|
struct ScenarioInterface: Decodable { let node: String; let port: String; let ip: String; let mask: String }
|
|
struct ScenarioRoute: Decodable { let destination: String; let mask: String; let gateway: String; let interface: String }
|
|
struct ScenarioSwitch: Decodable { let ssid: String; let retention: UInt64? }
|
|
struct ScenarioFirewallRule: Decodable {
|
|
let source: String?; let sourceMask: String?
|
|
let destination: String?; let destinationMask: String?
|
|
let port: Int?; let `protocol`: String?; let action: String?
|
|
}
|
|
struct ScenarioFirewall: Decodable {
|
|
let active: Bool?; let defaultPolicy: String?; let dropIcmp: Bool?
|
|
let synOnly: Bool?; let filterUdp: Bool?; let rules: [ScenarioFirewallRule]?
|
|
}
|
|
struct ScenarioStep: Decodable {
|
|
let op: String
|
|
let from: String?; let node: String?; let target: String?
|
|
let ttl: UInt8?; let identifier: UInt16?; let sequence: UInt16?; let maxTtl: UInt8?
|
|
let to: UInt64?; let enabled: Bool?; let percent: Int?
|
|
}
|
|
struct Scenario: Decodable {
|
|
let seed: UInt64
|
|
let speedPercent: Int?
|
|
let nodes: [ScenarioNode]
|
|
let links: [ScenarioLink]
|
|
let devices: [String: ScenarioDevice]?
|
|
let interfaces: [ScenarioInterface]?
|
|
let routes: [String: [ScenarioRoute]]?
|
|
let switches: [String: ScenarioSwitch]?
|
|
let firewalls: [String: ScenarioFirewall]?
|
|
let steps: [ScenarioStep]
|
|
}
|
|
|
|
// MARK: - Building the snapshot
|
|
|
|
func uuid(_ value: String) -> UUID {
|
|
guard let id = UUID(uuidString: value) else { fatalError("not a UUID: \(value)") }
|
|
return id
|
|
}
|
|
|
|
func firewallAction(_ value: String?) -> TopologyFirewallAction {
|
|
value == "accept" ? .accept : .drop
|
|
}
|
|
|
|
func firewallProtocol(_ value: String?) -> TopologyFirewallProtocol {
|
|
switch value {
|
|
case "all": return .all
|
|
case "icmp": return .icmp
|
|
case "udp": return .udp
|
|
default: return .tcp
|
|
}
|
|
}
|
|
|
|
func snapshot(of scenario: Scenario) -> TopologyNetworkRuntimeTopologySnapshot {
|
|
let nodes = scenario.nodes.map { node -> TopologyNetworkRuntimeNodeSnapshot in
|
|
guard let kind = TopologyNodeKind(rawValue: node.kind) else { fatalError("unknown kind \(node.kind)") }
|
|
return TopologyNetworkRuntimeNodeSnapshot(
|
|
id: uuid(node.id),
|
|
kind: kind,
|
|
ports: node.ports.map { TopologyNetworkRuntimePortSnapshot(id: uuid($0.id), label: $0.label, macAddress: $0.mac) }
|
|
)
|
|
}
|
|
let links = scenario.links.map {
|
|
TopologyNetworkRuntimeLinkSnapshot(
|
|
id: uuid($0.id),
|
|
sourceNodeID: uuid($0.sourceNode), sourcePortID: uuid($0.sourcePort),
|
|
targetNodeID: uuid($0.targetNode), targetPortID: uuid($0.targetPort)
|
|
)
|
|
}
|
|
var devices: [UUID: TopologyRuntimeDeviceConfiguration] = [:]
|
|
for (node, device) in scenario.devices ?? [:] {
|
|
devices[uuid(node)] = TopologyRuntimeDeviceConfiguration(
|
|
ipAddress: device.ip, subnetMask: device.mask,
|
|
defaultGateway: device.gateway ?? "", dnsServer: device.dns ?? ""
|
|
)
|
|
}
|
|
var interfaces: [TopologyRuntimeInterfaceKey: TopologyRuntimeInterfaceConfiguration] = [:]
|
|
for item in scenario.interfaces ?? [] {
|
|
interfaces[TopologyRuntimeInterfaceKey(nodeID: uuid(item.node), portID: uuid(item.port))] =
|
|
TopologyRuntimeInterfaceConfiguration(ipAddress: item.ip, subnetMask: item.mask)
|
|
}
|
|
var routes: [UUID: [TopologyRuntimeManualRoute]] = [:]
|
|
for (node, rows) in scenario.routes ?? [:] {
|
|
routes[uuid(node)] = rows.map {
|
|
TopologyRuntimeManualRoute(
|
|
destinationNetwork: $0.destination, subnetMask: $0.mask,
|
|
gateway: $0.gateway, interfaceIPAddress: $0.interface
|
|
)
|
|
}
|
|
}
|
|
var switches: [UUID: TopologySwitchConfiguration] = [:]
|
|
for (node, config) in scenario.switches ?? [:] {
|
|
switches[uuid(node)] = TopologySwitchConfiguration(
|
|
ssid: config.ssid,
|
|
retentionTimeMilliseconds: config.retention ?? TopologySwitchConfiguration.defaultRetentionTimeMilliseconds
|
|
)
|
|
}
|
|
var firewalls: [UUID: TopologyFirewallConfiguration] = [:]
|
|
for (node, config) in scenario.firewalls ?? [:] {
|
|
firewalls[uuid(node)] = TopologyFirewallConfiguration(
|
|
isActive: config.active ?? false,
|
|
defaultPolicy: firewallAction(config.defaultPolicy),
|
|
dropICMP: config.dropIcmp ?? false,
|
|
filterSYNSegmentsOnly: config.synOnly ?? true,
|
|
filterUDP: config.filterUdp ?? true,
|
|
rules: (config.rules ?? []).map {
|
|
TopologyFirewallRule(
|
|
sourceIPAddress: $0.source ?? "", sourceSubnetMask: $0.sourceMask ?? "",
|
|
destinationIPAddress: $0.destination ?? "", destinationSubnetMask: $0.destinationMask ?? "",
|
|
port: $0.port ?? TopologyFirewallRule.allPorts,
|
|
protocolType: firewallProtocol($0.protocol),
|
|
action: firewallAction($0.action)
|
|
)
|
|
}
|
|
)
|
|
}
|
|
return TopologyNetworkRuntimeTopologySnapshot(
|
|
nodes: nodes, links: links,
|
|
deviceConfigurations: devices, interfaceConfigurations: interfaces,
|
|
manualRoutesByNodeID: routes,
|
|
firewallConfigurationsByNodeID: firewalls,
|
|
switchConfigurationsByNodeID: switches
|
|
)
|
|
}
|
|
|
|
// MARK: - Output
|
|
|
|
typealias JSON = [String: Any]
|
|
|
|
func headers(_ fields: [TopologyPacketHeaderField]) -> [[String]] {
|
|
fields.map { [$0.name, $0.value] }
|
|
}
|
|
|
|
func trace(_ event: TopologyPacketTraceEvent) -> JSON {
|
|
var result: JSON = [
|
|
"id": event.id,
|
|
"time": event.timeMilliseconds,
|
|
"node": event.nodeID.uuidString,
|
|
"direction": event.direction.rawValue,
|
|
"layer": event.layer.rawValue,
|
|
"operation": event.operation.rawValue,
|
|
"before": headers(event.beforeHeaders),
|
|
"after": headers(event.afterHeaders),
|
|
]
|
|
if let frame = event.frameIdentity { result["frame"] = frame }
|
|
if let packet = event.packetIdentity { result["packet"] = packet }
|
|
if let interface = event.interfaceID { result["interface"] = interface.uuidString }
|
|
if let detail = event.detail { result["detail"] = detail }
|
|
return result
|
|
}
|
|
|
|
func delivery(_ result: TopologyIPv4DeliveryResult) -> JSON {
|
|
switch result {
|
|
case let .delivered(packet, node): return ["type": "delivered", "packet": packet, "node": node.uuidString]
|
|
case let .icmpError(packet, node, kind):
|
|
return ["type": "icmpError", "packet": packet, "node": node.uuidString, "kind": kind.rawValue]
|
|
case let .dropped(packet, node):
|
|
var json: JSON = ["type": "dropped", "packet": packet]
|
|
if let node { json["node"] = node.uuidString }
|
|
return json
|
|
}
|
|
}
|
|
|
|
func observation(_ item: TopologyRuntimeICMPObservation) -> JSON {
|
|
[
|
|
"kind": item.message.kind.rawValue,
|
|
"identifier": item.message.identifier,
|
|
"sequence": item.message.sequenceNumber,
|
|
"sender": item.packet.senderIPAddress,
|
|
]
|
|
}
|
|
|
|
func run(_ scenario: Scenario) -> JSON {
|
|
var engine = TopologyNetworkRuntimeEngine(seed: scenario.seed)
|
|
if let percent = scenario.speedPercent { engine.setSimulationSpeed(percent: percent) }
|
|
engine.handle(.start(snapshot: snapshot(of: scenario), seed: scenario.seed, initialTimeMilliseconds: 0))
|
|
|
|
var results: [Any] = []
|
|
for step in scenario.steps {
|
|
switch step.op {
|
|
case "ping":
|
|
results.append(delivery(engine.sendICMPEcho(
|
|
fromNodeID: uuid(step.from!), targetIPAddress: step.target!,
|
|
timeToLive: step.ttl ?? 64, identifier: step.identifier ?? 1, sequenceNumber: step.sequence ?? 1
|
|
)))
|
|
case "traceroute":
|
|
results.append(engine.traceICMPEcho(
|
|
fromNodeID: uuid(step.from!), targetIPAddress: step.target!,
|
|
maximumTimeToLive: step.maxTtl ?? 64, identifier: step.identifier ?? 1
|
|
).map(observation))
|
|
case "resolveMac":
|
|
results.append(engine.resolveMACAddress(nodeID: uuid(step.node!), targetIPAddress: step.target!) ?? NSNull())
|
|
case "advance":
|
|
engine.handle(.advance(toMilliseconds: step.to!))
|
|
results.append(engine.state.currentTimeMilliseconds)
|
|
case "packetLoss":
|
|
engine.setGlobalPacketLossEnabled(step.enabled!)
|
|
results.append(engine.state.globalPacketLossEnabled)
|
|
case "sat":
|
|
results.append(engine.switchSATEntries(nodeID: uuid(step.node!)).map {
|
|
["mac": $0.macAddress, "port": $0.portLabel, "updated": $0.updatedAtMilliseconds] as JSON
|
|
})
|
|
case "speed":
|
|
engine.setSimulationSpeed(percent: step.percent!)
|
|
results.append(engine.simulationSpeed.percent)
|
|
default:
|
|
fatalError("unknown step \(step.op)")
|
|
}
|
|
}
|
|
|
|
let state = engine.state
|
|
var arp: JSON = [:]
|
|
for (node, cache) in state.arpCachesByNodeID {
|
|
arp[node.uuidString] = cache.mapValues { $0.macAddress }
|
|
}
|
|
var switchTables: JSON = [:]
|
|
for (node, table) in state.switchForwardingTablesByNodeID {
|
|
switchTables[node.uuidString] = table.mapValues { $0.uuidString }
|
|
}
|
|
var observations: JSON = [:]
|
|
for (node, items) in state.icmpObservationsByNodeID {
|
|
observations[node.uuidString] = items.map(observation)
|
|
}
|
|
return [
|
|
"results": results,
|
|
"traces": state.packetTraces.map(trace),
|
|
"time": state.currentTimeMilliseconds,
|
|
"pendingEvents": state.pendingEvents.count,
|
|
"arp": arp,
|
|
"switchTables": switchTables,
|
|
"icmpObservations": observations,
|
|
"firewallDecisions": state.firewallDecisions.map { decision -> JSON in
|
|
var json: JSON = [
|
|
"packet": decision.packetIdentity, "node": decision.nodeID.uuidString, "accepted": decision.accepted,
|
|
]
|
|
if let rule = decision.ruleIndex { json["rule"] = rule }
|
|
return json
|
|
},
|
|
]
|
|
}
|
|
|
|
// MARK: - .fls corpus
|
|
|
|
/// Imports every .fls under `root` and summarizes what the Swift reader made of it.
|
|
func readCorpus(root: URL) throws -> JSON {
|
|
var result: JSON = [:]
|
|
let enumerator = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)
|
|
var files: [URL] = []
|
|
while let item = enumerator?.nextObject() as? URL {
|
|
if item.pathExtension == "fls" { files.append(item) }
|
|
}
|
|
for file in files.sorted(by: { $0.path < $1.path }) {
|
|
let name = String(file.path.dropFirst(root.path.count + 1))
|
|
do {
|
|
let imported = try TopologyProjectStore.importFiliusArchive(Data(contentsOf: file))
|
|
let state = imported.state
|
|
var kinds: [String: Int] = [:]
|
|
for node in state.graph.nodes { kinds[node.kind.rawValue, default: 0] += 1 }
|
|
result[name] = [
|
|
"version": imported.report.filiusVersion ?? NSNull(),
|
|
"nodes": imported.report.importedNodeCount,
|
|
"links": imported.report.importedLinkCount,
|
|
"documentation": imported.report.importedDocumentationItemCount,
|
|
"skipped": imported.report.skippedNodeCount,
|
|
"warnings": imported.report.warnings,
|
|
"residual": imported.opaqueContent.residualCount,
|
|
"kinds": kinds,
|
|
"state": describeImportedState(state),
|
|
] as JSON
|
|
} catch {
|
|
result[name] = ["error": String(describing: error)] as JSON
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
/// The imported model without its random UUIDs: nodes, ports and links by
|
|
/// index; the IP configurations the runtime starts from; documentation items
|
|
/// with their deterministic ids.
|
|
func describeImportedState(_ state: TopologyEditorState) -> JSON {
|
|
let nodeIndex = Dictionary(uniqueKeysWithValues: state.graph.nodes.enumerated().map { ($1.id, $0) })
|
|
func portIndex(_ nodeID: UUID, _ portID: UUID) -> Int {
|
|
state.graph.node(withID: nodeID)?.ports.firstIndex { $0.id == portID } ?? -1
|
|
}
|
|
let nodes: [JSON] = state.graph.nodes.map { node in
|
|
let device: Any = state.runtimeDeviceConfigurations[node.id].map { configuration -> JSON in
|
|
[
|
|
"ipAddress": configuration.ipAddress,
|
|
"subnetMask": configuration.subnetMask,
|
|
"defaultGateway": configuration.defaultGateway,
|
|
"dnsServer": configuration.dnsServer,
|
|
]
|
|
} ?? NSNull()
|
|
let interfaces: [Any] = node.ports.map { port in
|
|
state.runtimeInterfaceConfigurations[TopologyRuntimeInterfaceKey(nodeID: node.id, portID: port.id)]
|
|
.map { ["ipAddress": $0.ipAddress, "subnetMask": $0.subnetMask] as JSON } ?? NSNull()
|
|
}
|
|
return [
|
|
"kind": node.kind.rawValue,
|
|
"displayName": node.displayName,
|
|
"hostLabelPolicy": node.hostLabelPolicy.rawValue,
|
|
"position": [Double(node.position.x), Double(node.position.y)],
|
|
"ports": node.ports.map { port -> JSON in
|
|
["label": port.label, "mac": port.importedMACAddress ?? NSNull(), "occupied": port.isOccupied]
|
|
},
|
|
"device": device,
|
|
"interfaces": interfaces,
|
|
"system": describeSystem(state, node),
|
|
]
|
|
}
|
|
let links: [[Int]] = state.graph.links.map { link in
|
|
[
|
|
nodeIndex[link.sourceNodeID] ?? -1, portIndex(link.sourceNodeID, link.sourcePortID),
|
|
nodeIndex[link.targetNodeID] ?? -1, portIndex(link.targetNodeID, link.targetPortID),
|
|
]
|
|
}
|
|
let documentation: [JSON] = state.documentationItems.map { item in
|
|
[
|
|
"id": item.id.uuidString,
|
|
"kind": item.kind.rawValue,
|
|
"frame": [item.frame.origin.x, item.frame.origin.y, item.frame.width, item.frame.height].map { Double($0) },
|
|
"text": item.text,
|
|
"color": [item.color.red, item.color.green, item.color.blue, item.color.alpha],
|
|
"fontName": item.fontName,
|
|
"fontSize": Double(item.fontSize),
|
|
"isBold": item.isBold,
|
|
"order": item.order,
|
|
]
|
|
}
|
|
return ["nodes": nodes, "links": links, "documentation": documentation]
|
|
}
|
|
|
|
/// The system software settings of a node in the imported state. A switch
|
|
/// SSID Swift derives from the (random) node id is written as a marker.
|
|
func describeSystem(_ state: TopologyEditorState, _ node: TopologyNode) -> JSON {
|
|
var json: JSON = [:]
|
|
if let configuration = state.switchConfigurationsByNodeID[node.id] {
|
|
let derived = TopologySwitchConfiguration.defaultConfiguration(nodeID: node.id).ssid
|
|
json["switch"] = [
|
|
"ssid": configuration.ssid == derived ? "<from node id>" : configuration.ssid,
|
|
"retentionTimeMilliseconds": Int(configuration.retentionTimeMilliseconds),
|
|
] as JSON
|
|
}
|
|
if let wireless = state.hostWirelessConfigurationsByNodeID[node.id] {
|
|
json["hostWireless"] = ["isEnabled": wireless.isEnabled, "ssid": wireless.ssid] as JSON
|
|
}
|
|
if let routes = state.runtimeManualRoutesByNodeID[node.id] {
|
|
json["manualRoutes"] = routes.map { [$0.destinationNetwork, $0.subnetMask, $0.gateway, $0.interfaceIPAddress] }
|
|
}
|
|
if let rip = state.runtimeRIPEnabledByNodeID[node.id] { json["ripEnabled"] = rip }
|
|
if let firewall = state.runtimeFirewallConfigurationsByNodeID[node.id] {
|
|
json["firewall"] = [
|
|
"isActive": firewall.isActive,
|
|
"defaultPolicy": firewall.defaultPolicy.rawValue,
|
|
"dropICMP": firewall.dropICMP,
|
|
"filterSYNSegmentsOnly": firewall.filterSYNSegmentsOnly,
|
|
"filterUDP": firewall.filterUDP,
|
|
"rules": firewall.rules.map { rule -> [Any] in
|
|
[rule.sourceIPAddress, rule.sourceSubnetMask, rule.destinationIPAddress, rule.destinationSubnetMask,
|
|
rule.port, rule.protocolType.rawValue, rule.action.rawValue]
|
|
},
|
|
] as JSON
|
|
}
|
|
return json
|
|
}
|
|
|
|
// MARK: - Archive path folding
|
|
|
|
struct PathCase: Decodable { let name: String; let first: String; let second: String }
|
|
|
|
/// Whether Swift's archive writer treats two entry paths as the same file.
|
|
/// The folding itself is file-private in Swift; the export reports it as a
|
|
/// duplicate entry when two carried entries collide.
|
|
func foldPaths(_ cases: [PathCase]) -> [JSON] {
|
|
cases.map { item in
|
|
var result: JSON = ["name": item.name]
|
|
// Swift compares strings by canonical equivalence: "é" and "e\u{301}"
|
|
// are one dictionary key, so they never reach the archive writer.
|
|
if item.first == item.second {
|
|
result["outcome"] = "sameKey"
|
|
return result
|
|
}
|
|
do {
|
|
_ = try TopologyProjectStore.exportFiliusArchiveWithReport(
|
|
from: TopologyEditorState(),
|
|
supplementalEntries: [item.first: Data("1".utf8), item.second: Data("2".utf8)]
|
|
)
|
|
result["outcome"] = "distinct"
|
|
} catch let error as TopologyFLSArchiveError {
|
|
result["outcome"] = error.code == .duplicateEntry ? "collide" : error.code.rawValue
|
|
} catch {
|
|
result["outcome"] = "error: \(error)"
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
// MARK: - Main
|
|
|
|
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--path-folding" {
|
|
let cases = try JSONDecoder().decode([PathCase].self, from: Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[2])))
|
|
let data = try JSONSerialization.data(withJSONObject: foldPaths(cases), options: [.prettyPrinted, .sortedKeys])
|
|
try (data + Data("\n".utf8)).write(to: URL(fileURLWithPath: CommandLine.arguments[3]))
|
|
print("path folding: \(cases.count) cases")
|
|
exit(0)
|
|
}
|
|
|
|
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--corpus" {
|
|
let summary = try readCorpus(root: URL(fileURLWithPath: CommandLine.arguments[2]))
|
|
let data = try JSONSerialization.data(withJSONObject: summary, options: [.prettyPrinted, .sortedKeys])
|
|
try (data + Data("\n".utf8)).write(to: URL(fileURLWithPath: CommandLine.arguments[3]))
|
|
print("corpus: \(summary.count) files")
|
|
exit(0)
|
|
}
|
|
|
|
let directory = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? ".")
|
|
let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
|
|
.filter { $0.lastPathComponent.hasSuffix(".scenario.json") }
|
|
.sorted { $0.lastPathComponent < $1.lastPathComponent }
|
|
for file in files {
|
|
let scenario = try JSONDecoder().decode(Scenario.self, from: Data(contentsOf: file))
|
|
let output = run(scenario)
|
|
let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys])
|
|
let name = file.lastPathComponent.replacingOccurrences(of: ".scenario.json", with: ".swift.json")
|
|
try (data + Data("\n".utf8)).write(to: directory.appendingPathComponent(name))
|
|
print("\(name): \((output["traces"] as? [Any])?.count ?? 0) trace events")
|
|
}
|