Files
mdg-filius/tools/swift-oracle/Sources/Oracle/main.swift
T
julian.kiedaischandClaude Opus 5.5 cf47cf8f9d feat(fls): import DHCP, port forwarding and modem settings
importProject carries the DHCP client switch and server settings (hosts
and home routers), a home router's port forwarding rows and a modem's
pairing into the model; the core gains the types, ported from Swift.
The Swift oracle writes them too, and they match on the 13 files Swift
reads completely. Configuration text is now trimmed throughout, as Swift
trims every value it reads (routes and firewall rules included).

Outside those files Swift switches every DHCP server of the Internet
examples off: their "aktiv" refers to a shared Boolean it cannot follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:09:08 +00:00

469 lines
21 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 client = state.runtimeDHCPClientConfigurationsByNodeID[node.id] { json["dhcpClient"] = client.isEnabled }
if let server = state.runtimeDHCPServerConfigurationsByNodeID[node.id] {
json["dhcpServer"] = [
"isActive": server.isActive,
"lowerBoundIPAddress": server.lowerBoundIPAddress,
"upperBoundIPAddress": server.upperBoundIPAddress,
"gatewayIPAddress": server.gatewayIPAddress,
"dnsServerIPAddress": server.dnsServerIPAddress,
"useOwnSettings": server.useOwnSettings,
// The assignments' ids are random.
"staticAssignments": server.staticAssignments.map { [$0.macAddress, $0.ipAddress] },
] as JSON
}
if let rows = state.runtimePortForwardingRowsByNodeID[node.id] {
json["portForwarding"] = rows.map { [$0.protocolValue, $0.publicPortValue, $0.lanIPAddress, $0.lanPortValue] }
}
if let link = state.remoteLinkConfigurationsByNodeID[node.id] {
json["remoteLink"] = [
"pairIdentifier": link.pairIdentifier,
"latencyMilliseconds": Int(link.latencyMilliseconds),
"isEnabled": link.isEnabled,
] as JSON
}
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")
}