// 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 — reads .scenario.json, writes .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: - Main 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") }