engine.ts ports Swift's "DHCP server and automatic client": an active server listens on port 67 from the start and offers a static assignment before its range, round from the address after the last one offered; offers last 10 s, dynamic leases a day, and a server whose offer another server beat blacklists that address for a while. A client starts as an event: DISCOVER, OFFER, a check by ARP, DECLINE, REQUEST, ACK or NAK, 2.5 s per wait, and after ten failures its old address back. The snapshot carries the DHCP settings of clients and servers. Four scenarios match the Swift engine. One Swift error is named in the oracle test: a client run directly is advanced a second time, to FINISH, by the server's answer within its own DISCOVER, and the outer run then writes back its stale copy of the status (Swift's value types); the address and every trace agree, only the reported status is wrong in Swift. Swift's three DHCP engine tests are ported, with tests for stopping and the message format. 15 mutation probes, 14 caught; the other concerns the home router, which runs only once NAT is ported. Two departures from Java remain, to be fixed next: from the event queue the client's ARP check does not wait, so an address conflict is never found, and each answer that wakes the client makes it send its state's message again (a second DISCOVER and REQUEST, the second answered with NAK). Java waits a round trip and sends once per attempt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
893 lines
43 KiB
Swift
893 lines
43 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 CryptoKit
|
|
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?
|
|
// UDP: a socket is named by the id bindUdp returned.
|
|
let port: UInt16?; let localIp: String?; let remoteIp: String?; let remotePort: UInt16?
|
|
let socket: String?; let payload: String?; let timeout: UInt64?
|
|
// TCP: the payload repeated this many times, for messages longer than a segment.
|
|
let repeatCount: Int?; let localPort: UInt16?
|
|
}
|
|
struct ScenarioDHCPServer: Decodable {
|
|
let active: Bool?; let lower: String?; let upper: String?
|
|
let gateway: String?; let dns: String?; let own: Bool?
|
|
/// Pairs of MAC and IP address.
|
|
let staticAssignments: [[String]]?
|
|
}
|
|
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 dhcpServers: [String: ScenarioDHCPServer]?
|
|
let dhcpClients: [String]?
|
|
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)
|
|
)
|
|
}
|
|
)
|
|
}
|
|
var dhcpServers: [UUID: TopologyDHCPServerConfiguration] = [:]
|
|
for (node, config) in scenario.dhcpServers ?? [:] {
|
|
dhcpServers[uuid(node)] = TopologyDHCPServerConfiguration(
|
|
isActive: config.active ?? false,
|
|
lowerBoundIPAddress: config.lower ?? "0.0.0.0", upperBoundIPAddress: config.upper ?? "0.0.0.0",
|
|
gatewayIPAddress: config.gateway ?? "0.0.0.0", dnsServerIPAddress: config.dns ?? "0.0.0.0",
|
|
useOwnSettings: config.own ?? false,
|
|
staticAssignments: (config.staticAssignments ?? []).map {
|
|
TopologyDHCPStaticAssignment(macAddress: $0[0], ipAddress: $0[1])
|
|
}
|
|
)
|
|
}
|
|
var dhcpClients: [UUID: TopologyDHCPClientConfiguration] = [:]
|
|
for node in scenario.dhcpClients ?? [] { dhcpClients[uuid(node)] = TopologyDHCPClientConfiguration(isEnabled: true) }
|
|
return TopologyNetworkRuntimeTopologySnapshot(
|
|
nodes: nodes, links: links,
|
|
deviceConfigurations: devices, interfaceConfigurations: interfaces,
|
|
manualRoutesByNodeID: routes,
|
|
dhcpClientConfigurationsByNodeID: dhcpClients,
|
|
dhcpServerConfigurationsByNodeID: dhcpServers,
|
|
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 dhcpStatus(_ status: TopologyRuntimeDHCPClientStatus) -> JSON {
|
|
[
|
|
"state": status.state.rawValue, "errors": status.errorCount,
|
|
"server": status.selectedServerIPAddress ?? NSNull(), "offered": status.offeredIPAddress ?? NSNull(),
|
|
"succeeded": status.succeeded,
|
|
]
|
|
}
|
|
|
|
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)
|
|
case "bindUdp":
|
|
results.append(engine.bindUDPSocket(
|
|
nodeID: uuid(step.node!), localPort: step.port, localIPAddress: step.localIp,
|
|
remoteIPAddress: step.remoteIp, remotePort: step.remotePort
|
|
)?.uuidString ?? NSNull())
|
|
case "sendUdp":
|
|
results.append(engine.sendUDP(
|
|
socketID: uuid(step.socket!), payload: Data((step.payload ?? "").utf8),
|
|
destinationIPAddress: step.target, destinationPort: step.port
|
|
).map(delivery) ?? NSNull())
|
|
case "receiveUdp":
|
|
let received = step.timeout.map { engine.receiveUDP(socketID: uuid(step.socket!), timeoutMilliseconds: $0) }
|
|
?? engine.receiveUDP(socketID: uuid(step.socket!))
|
|
results.append(received.map { item -> JSON in
|
|
[
|
|
"sender": item.senderIPAddress, "receiver": item.receiverIPAddress,
|
|
"sourcePort": item.datagram.sourcePort, "destinationPort": item.datagram.destinationPort,
|
|
"payload": String(decoding: item.datagram.payload, as: UTF8.self), "at": item.receivedAtMilliseconds,
|
|
]
|
|
} ?? NSNull())
|
|
case "openTcpServer":
|
|
results.append(engine.openTCPServerSocket(
|
|
nodeID: uuid(step.node!), localPort: step.port!, localIPAddress: step.localIp
|
|
)?.uuidString ?? NSNull())
|
|
case "openTcpClient":
|
|
results.append(engine.openTCPClientSocket(
|
|
nodeID: uuid(step.node!), remoteIPAddress: step.target!, remotePort: step.port!,
|
|
localIPAddress: step.localIp, localPort: step.localPort
|
|
)?.uuidString ?? NSNull())
|
|
case "connectTcp":
|
|
results.append(String(describing: engine.connectTCPWithResult(socketID: uuid(step.socket!))))
|
|
case "sendTcp":
|
|
let text = String(repeating: step.payload ?? "", count: step.repeatCount ?? 1)
|
|
results.append(engine.sendTCP(socketID: uuid(step.socket!), payload: Data(text.utf8)))
|
|
case "receiveTcp":
|
|
results.append(engine.receiveTCP(socketID: uuid(step.socket!)).map { String(decoding: $0, as: UTF8.self) } ?? NSNull())
|
|
case "acceptedTcp":
|
|
results.append((engine.state.tcpAcceptedSocketIDsByListenerID[uuid(step.socket!)] ?? []).map(\.uuidString))
|
|
case "peerTcp":
|
|
results.append(engine.peerTCPSocketID(socketID: uuid(step.socket!))?.uuidString ?? NSNull())
|
|
case "cleanTcp":
|
|
results.append(engine.cleanClosedTCPSocket(socketID: uuid(step.socket!)))
|
|
case "runDhcp":
|
|
results.append(engine.runDHCPClient(nodeID: uuid(step.node!)).map(dhcpStatus) ?? NSNull())
|
|
case "closeSocket":
|
|
engine.closeSocket(socketID: uuid(step.socket!))
|
|
results.append(engine.state.socketsByID[uuid(step.socket!)] == nil)
|
|
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,
|
|
"devices": Dictionary(uniqueKeysWithValues: state.topologySnapshot.deviceConfigurations.map { node, device in
|
|
(node.uuidString, [device.ipAddress, device.subnetMask, device.defaultGateway, device.dnsServer])
|
|
}),
|
|
"dhcp": [
|
|
"clients": Dictionary(uniqueKeysWithValues: state.dhcpClientStatusesByNodeID.map { ($0.key.uuidString, dhcpStatus($0.value)) }),
|
|
"leases": state.dhcpLeasesByIPAddress.sorted { $0.key < $1.key }.map { key, lease -> [Any] in
|
|
[key, lease.clientMACAddress, lease.ipAddress, lease.expiresAtMilliseconds.map { $0 as Any } ?? NSNull()]
|
|
},
|
|
"offers": state.dhcpOffersByIPAddress.sorted { $0.key < $1.key }.map { key, offer -> [Any] in
|
|
[key, offer.clientMACAddress, offer.expiresAtMilliseconds]
|
|
},
|
|
"blacklist": Dictionary(uniqueKeysWithValues: state.dhcpBlacklistByServerNodeID.map { node, entries in
|
|
(node.uuidString, entries.sorted { $0.key < $1.key }.map { [$0.key, $0.value] as [Any] })
|
|
}),
|
|
"servers": Dictionary(uniqueKeysWithValues: state.dhcpServerSocketIDsByNodeID.map { ($0.key.uuidString, $0.value.uuidString) }),
|
|
] as JSON,
|
|
"sockets": state.socketsByID.values.sorted { $0.id.uuidString < $1.id.uuidString }.map { socket -> JSON in
|
|
[
|
|
"id": socket.id.uuidString, "node": socket.nodeID.uuidString, "protocol": socket.protocolKind.rawValue,
|
|
"localIp": socket.localIPAddress ?? NSNull(), "localPort": socket.localPort,
|
|
"remoteIp": socket.remoteIPAddress ?? NSNull(), "remotePort": socket.remotePort.map { $0 as Any } ?? NSNull(),
|
|
"queued": state.udpReceiveQueuesBySocketID[socket.id]?.count ?? 0,
|
|
"tcpState": socket.tcpState?.rawValue ?? NSNull(),
|
|
"parent": socket.parentListenerSocketID?.uuidString ?? NSNull(),
|
|
"session": state.tcpSessionsByID[socket.id].map { session -> JSON in
|
|
[
|
|
"attempts": session.sendAttempts, "next": session.nextSendSequenceNumber,
|
|
"remote": session.remoteSequenceNumber, "pending": session.lastSentSegment != nil,
|
|
"assembly": session.receiveAssembly.count, "messages": session.receivedMessages.count,
|
|
]
|
|
} ?? NSNull(),
|
|
]
|
|
},
|
|
"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),
|
|
"fileSystem": state.virtualFileSystemsByNodeID[node.id].map { describeFileEntries($0.allEntries()) } ?? NSNull(),
|
|
]
|
|
}
|
|
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 programs = state.runtimeInstalledProgramsByNodeID[node.id] {
|
|
json["installedPrograms"] = programs.map(\.rawValue).sorted()
|
|
}
|
|
if let web = state.runtimeWebServerConfigurationsByNodeID[node.id] {
|
|
json["webServer"] = [
|
|
"port": web.port,
|
|
"documentRoot": web.documentRoot,
|
|
"virtualHosts": web.virtualHostConfiguration != nil,
|
|
] as JSON
|
|
}
|
|
if let gnutella = state.runtimeGnutellaConfigurationsByNodeID[node.id] {
|
|
json["gnutella"] = ["maximumKnownPeers": gnutella.maximumKnownPeers] as JSON
|
|
}
|
|
if let dns = state.runtimeDNSServerConfigurationsByNodeID[node.id] {
|
|
json["dnsServer"] = [
|
|
"recursiveResolutionEnabled": dns.recursiveResolutionEnabled,
|
|
"forwardingServerIPAddress": dns.forwardingServerIPAddress ?? NSNull(),
|
|
"records": dns.typedRecords.map { dnsRecordJSON($0) },
|
|
] as JSON
|
|
}
|
|
if let client = state.runtimeEmailClientConfigurationsByNodeID[node.id] {
|
|
json["emailClient"] = [
|
|
"pop3Host": client.pop3Host, "pop3Port": client.pop3Port,
|
|
"smtpHost": client.smtpHost, "smtpPort": client.smtpPort,
|
|
"username": client.username, "password": client.password,
|
|
"name": client.name, "email": client.email,
|
|
"inbox": client.inbox.map(emailMessageJSON),
|
|
"sent": client.sent.map(emailMessageJSON),
|
|
"drafts": client.drafts.map(emailMessageJSON),
|
|
"nextMessageID": Int(client.nextMessageID),
|
|
] as JSON
|
|
}
|
|
if let server = state.runtimeEmailServerConfigurationsByNodeID[node.id] {
|
|
json["emailServer"] = [
|
|
"domain": server.domain, "pop3Port": server.pop3Port,
|
|
"accounts": server.accounts.map { account in
|
|
[
|
|
"username": account.username, "password": account.password, "name": account.name,
|
|
"mailbox": account.mailbox.map(emailMessageJSON),
|
|
] as JSON
|
|
},
|
|
"nextMessageID": Int(server.nextMessageID),
|
|
] as JSON
|
|
}
|
|
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: - Virtual file system
|
|
|
|
/// One step of a file system case (packages/core/test/vfs.cases.json).
|
|
struct FileSystemStep: Decodable {
|
|
let op: String
|
|
let path: String?
|
|
let to: String?
|
|
let name: String?
|
|
let text: String?
|
|
let size: Int?
|
|
let byte: UInt8?
|
|
let mediaType: String?
|
|
let recursive: Bool?
|
|
let overwrite: Bool?
|
|
let entries: [FileSystemCaseEntry]?
|
|
}
|
|
|
|
struct FileSystemCaseEntry: Decodable { let path: String; let kind: String; let text: String? }
|
|
struct FileSystemCase: Decodable { let name: String; let steps: [FileSystemStep] }
|
|
|
|
func describeFileSystemError(_ error: Error) -> JSON {
|
|
guard let error = error as? TopologyVirtualFileSystemError else {
|
|
return ["error": "other", "values": [String(describing: error)]]
|
|
}
|
|
func result(_ code: String, _ values: [Any] = []) -> JSON { ["error": code, "values": values] }
|
|
switch error {
|
|
case let .pathMustBeAbsolute(path): return result("pathMustBeAbsolute", [path])
|
|
case let .pathEscapesRoot(path): return result("pathEscapesRoot", [path])
|
|
case let .invalidPathComponent(component): return result("invalidPathComponent", [component])
|
|
case let .itemAlreadyExists(path): return result("itemAlreadyExists", [path])
|
|
case let .caseInsensitiveSiblingCollision(existing, attempted):
|
|
return result("caseInsensitiveSiblingCollision", [existing, attempted])
|
|
case let .itemNotFound(path): return result("itemNotFound", [path])
|
|
case let .parentDirectoryNotFound(path): return result("parentDirectoryNotFound", [path])
|
|
case let .expectedDirectory(path): return result("expectedDirectory", [path])
|
|
case let .expectedFile(path): return result("expectedFile", [path])
|
|
case let .expectedTextFile(path): return result("expectedTextFile", [path])
|
|
case let .expectedImageFile(path): return result("expectedImageFile", [path])
|
|
case let .directoryNotEmpty(path): return result("directoryNotEmpty", [path])
|
|
case .cannotMutateRoot: return result("cannotMutateRoot")
|
|
case let .cannotMoveDirectoryIntoItself(source, destination):
|
|
return result("cannotMoveDirectoryIntoItself", [source, destination])
|
|
case let .fileSizeQuotaExceeded(path, actual, limit): return result("fileSizeQuotaExceeded", [path, actual, limit])
|
|
case let .deviceSizeQuotaExceeded(actual, limit): return result("deviceSizeQuotaExceeded", [actual, limit])
|
|
case let .deviceEntryQuotaExceeded(actual, limit): return result("deviceEntryQuotaExceeded", [actual, limit])
|
|
case let .totalSizeQuotaExceeded(actual, limit): return result("totalSizeQuotaExceeded", [actual, limit])
|
|
case let .totalEntryQuotaExceeded(actual, limit): return result("totalEntryQuotaExceeded", [actual, limit])
|
|
}
|
|
}
|
|
|
|
func describeFileEntries(_ entries: [TopologyVirtualFileEntry]) -> [JSON] {
|
|
entries.map { entry in
|
|
var json: JSON = ["path": entry.path]
|
|
switch entry.content {
|
|
case .directory: json["kind"] = "directory"
|
|
case let .text(value): json["kind"] = "text"; json["text"] = value
|
|
case let .binary(data, mediaType):
|
|
json["kind"] = "binary"; json["bytes"] = data.count; json["mediaType"] = mediaType ?? NSNull()
|
|
json["sha256"] = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
|
case let .image(data, mediaType):
|
|
json["kind"] = "image"; json["bytes"] = data.count; json["mediaType"] = mediaType
|
|
json["sha256"] = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
return json
|
|
}
|
|
}
|
|
|
|
func runFileSystemCases(_ cases: [FileSystemCase]) -> [JSON] {
|
|
cases.map { item in
|
|
var fileSystem = TopologyVirtualFileSystem()
|
|
var results: [JSON] = []
|
|
for step in item.steps {
|
|
do {
|
|
let path = step.path ?? "/"
|
|
switch step.op {
|
|
case "createDirectory": try fileSystem.createDirectory(at: path, recursive: step.recursive ?? false)
|
|
case "writeText": try fileSystem.writeTextFile(at: path, text: step.text ?? "", overwrite: step.overwrite ?? true)
|
|
case "writeBinary":
|
|
try fileSystem.writeBinaryFile(
|
|
at: path, data: Data(repeating: step.byte ?? 0, count: step.size ?? 0),
|
|
mediaType: step.mediaType, overwrite: step.overwrite ?? true)
|
|
case "writeImage":
|
|
try fileSystem.writeImageFile(
|
|
at: path, data: Data(repeating: step.byte ?? 0, count: step.size ?? 0),
|
|
mediaType: step.mediaType ?? "image/png", overwrite: step.overwrite ?? true)
|
|
case "copy": try fileSystem.copyItem(at: path, to: step.to ?? "/", overwrite: step.overwrite ?? false)
|
|
case "move": try fileSystem.moveItem(at: path, to: step.to ?? "/", overwrite: step.overwrite ?? false)
|
|
case "rename": results.append(["ok": try fileSystem.renameItem(at: path, to: step.name ?? "")]); continue
|
|
case "delete": try fileSystem.deleteItem(at: path, recursive: step.recursive ?? false)
|
|
case "entry": results.append(["ok": describeFileEntries([try fileSystem.entry(at: path)])[0]]); continue
|
|
case "entries": results.append(["ok": describeFileEntries(try fileSystem.entries(in: path))]); continue
|
|
case "text": results.append(["ok": try fileSystem.textFile(at: path)]); continue
|
|
case "contains": results.append(["ok": fileSystem.contains(path)]); continue
|
|
case "normalize": results.append(["ok": try TopologyVirtualFileSystem.normalizedAbsolutePath(path)]); continue
|
|
case "default": fileSystem = .defaultForDevice()
|
|
case "fromEntries":
|
|
fileSystem = try TopologyVirtualFileSystem(entries: (step.entries ?? []).map { entry in
|
|
TopologyVirtualFileEntry(
|
|
path: entry.path,
|
|
content: entry.kind == "directory" ? .directory : .text(entry.text ?? ""))
|
|
})
|
|
default: results.append(["error": "unknown step \(step.op)"]); continue
|
|
}
|
|
results.append(["ok": true])
|
|
} catch {
|
|
results.append(describeFileSystemError(error))
|
|
}
|
|
}
|
|
return ["name": item.name, "results": results, "entries": describeFileEntries(fileSystem.allEntries())]
|
|
}
|
|
}
|
|
|
|
// MARK: - String semantics
|
|
|
|
struct StringCases: Decodable {
|
|
let trim: [String]
|
|
let lowercased: [String]
|
|
let equal: [[String]]
|
|
let sort: [[String]]
|
|
let base64: [String]
|
|
let controlCharacters: [String]
|
|
let pathExtension: [String]
|
|
}
|
|
|
|
/// What Swift makes of strings where JavaScript differs (packages/core/test/strings.cases.json).
|
|
func measureStrings(_ cases: StringCases) -> JSON {
|
|
[
|
|
"trim": cases.trim.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) },
|
|
"lowercased": cases.lowercased.map { $0.lowercased() },
|
|
"equal": cases.equal.map { $0[0] == $0[1] },
|
|
"sort": cases.sort.map { $0.sorted() },
|
|
"base64": cases.base64.map { text -> Any in
|
|
Data(base64Encoded: text, options: .ignoreUnknownCharacters).map { Array($0) } ?? NSNull()
|
|
},
|
|
"controlCharacters": cases.controlCharacters.map { text in
|
|
text.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) })
|
|
},
|
|
"pathExtension": cases.pathExtension.map { ($0 as NSString).pathExtension },
|
|
// The whole sets, scalar by scalar, as ranges [first, last].
|
|
"whitespacesAndNewlinesSet": scalarRanges(in: .whitespacesAndNewlines),
|
|
"controlCharactersSet": scalarRanges(in: .controlCharacters),
|
|
]
|
|
}
|
|
|
|
func scalarRanges(in set: CharacterSet) -> [[Int]] {
|
|
var ranges: [[Int]] = []
|
|
for value in 0...0x10FFFF {
|
|
guard let scalar = Unicode.Scalar(value), set.contains(scalar) else { continue }
|
|
if let last = ranges.last, last[1] == value - 1 {
|
|
ranges[ranges.count - 1][1] = value
|
|
} else {
|
|
ranges.append([value, value])
|
|
}
|
|
}
|
|
return ranges
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
func emailAddressJSON(_ address: TopologyRuntimeEmailAddress) -> [Any] {
|
|
[address.name ?? NSNull(), address.mailAddress]
|
|
}
|
|
|
|
func emailMessageJSON(_ message: TopologyRuntimeEmailMessage) -> JSON {
|
|
[
|
|
"id": Int(message.id),
|
|
"from": emailAddressJSON(message.from),
|
|
"to": message.to.map(emailAddressJSON),
|
|
"cc": message.cc.map(emailAddressJSON),
|
|
"bcc": message.bcc.map(emailAddressJSON),
|
|
"subject": message.subject,
|
|
"body": message.body,
|
|
"deliveryIdentity": message.deliveryIdentity ?? NSNull(),
|
|
"receivedAtMilliseconds": message.receivedAtMilliseconds.map { Int($0) } ?? NSNull(),
|
|
"isNew": message.isNew,
|
|
"isMarkedForDeletion": message.isMarkedForDeletion,
|
|
"isSent": message.isSent,
|
|
]
|
|
}
|
|
|
|
// MARK: - /dns/hosts cases (packages/fls/test/dns-hosts.cases.txt)
|
|
|
|
func dnsRecordJSON(_ record: TopologyDNSResourceRecord) -> [Any] {
|
|
[record.name.rawValue, record.type.rawValue, Int(record.ttlSeconds), record.target]
|
|
}
|
|
|
|
/// One case per line: a whole file (hosts, e-mail accounts), ASCII with Java-style unicode
|
|
/// escapes (backslash, u, four hex digits) — the file the Java oracle reads.
|
|
func readDNSHostsCases(_ url: URL) throws -> [(line: String, text: String)] {
|
|
let lines = try String(contentsOf: url, encoding: .ascii).components(separatedBy: "\n")
|
|
return lines.dropLast().map { line in
|
|
var units: [UInt16] = []
|
|
var rest = Substring(line)
|
|
while let first = rest.first {
|
|
if rest.hasPrefix("\\u"), rest.count >= 6, let unit = UInt16(rest.dropFirst(2).prefix(4), radix: 16) {
|
|
units.append(unit)
|
|
rest = rest.dropFirst(6)
|
|
} else {
|
|
units.append(contentsOf: String(first).utf16)
|
|
rest = rest.dropFirst()
|
|
}
|
|
}
|
|
return (line, String(decoding: units, as: UTF16.self))
|
|
}
|
|
}
|
|
|
|
// 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] == "--vfs" {
|
|
let cases = try JSONDecoder().decode([FileSystemCase].self, from: Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[2])))
|
|
let output = runFileSystemCases(cases)
|
|
let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys])
|
|
try (data + Data("\n".utf8)).write(to: URL(fileURLWithPath: CommandLine.arguments[3]))
|
|
print("vfs: \(output.count) cases")
|
|
exit(0)
|
|
}
|
|
|
|
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--strings" {
|
|
let cases = try JSONDecoder().decode(StringCases.self, from: Data(contentsOf: URL(fileURLWithPath: CommandLine.arguments[2])))
|
|
let data = try JSONSerialization.data(withJSONObject: measureStrings(cases), options: [.prettyPrinted, .sortedKeys])
|
|
try (data + Data("\n".utf8)).write(to: URL(fileURLWithPath: CommandLine.arguments[3]))
|
|
print("strings: measured")
|
|
exit(0)
|
|
}
|
|
|
|
if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--dns-hosts" {
|
|
let cases = try readDNSHostsCases(URL(fileURLWithPath: CommandLine.arguments[2]))
|
|
let output: [[Any]] = cases.map { [$0.line, TopologyDNSHostsFileCodec.records(from: $0.text).map { dnsRecordJSON($0) }] }
|
|
let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys])
|
|
try (data + Data("\n".utf8)).write(to: URL(fileURLWithPath: CommandLine.arguments[3]))
|
|
print("dns hosts: \(cases.count) cases")
|
|
exit(0)
|
|
}
|
|
|
|
if CommandLine.arguments.count == 5, CommandLine.arguments[1] == "--email-files" {
|
|
let server = CommandLine.arguments[2] == "server"
|
|
let cases = try readDNSHostsCases(URL(fileURLWithPath: CommandLine.arguments[3]))
|
|
let output: [[Any]] = cases.map { item in
|
|
do {
|
|
if server {
|
|
let configuration = try TopologyRuntimeEmailStorage.decodeJavaServer(item.text)
|
|
return [item.line, configuration.accounts.map { account in
|
|
[
|
|
"username": account.username, "password": account.password, "name": account.name,
|
|
"mailbox": account.mailbox.map(emailMessageJSON),
|
|
] as JSON
|
|
}]
|
|
}
|
|
let client = try TopologyRuntimeEmailStorage.decodeJavaClient(item.text)
|
|
return [item.line, [
|
|
"pop3Host": client.pop3Host, "pop3Port": client.pop3Port,
|
|
"smtpHost": client.smtpHost, "smtpPort": client.smtpPort,
|
|
"username": client.username, "password": client.password,
|
|
"name": client.name, "email": client.email,
|
|
] as JSON]
|
|
} catch {
|
|
return [item.line, "!\(error)"]
|
|
}
|
|
}
|
|
let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys])
|
|
try (data + Data("\n".utf8)).write(to: URL(fileURLWithPath: CommandLine.arguments[4]))
|
|
print("email \(CommandLine.arguments[2]) files: \(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")
|
|
}
|