Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions Sources/Pilot/Pilot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,21 @@ public final class Pilot {
set { stoppedLock.lock(); defer { stoppedLock.unlock() }; _stopped = newValue }
}

private init(start: StartResult, driverHandle: UInt64) {
/// True when this instance booted the process-global embedded daemon and
/// is therefore responsible for stopping it in `stop()`.
private let ownsEmbeddedDaemon: Bool

private init(start: StartResult, driverHandle: UInt64, ownsEmbeddedDaemon: Bool = true) {
self.start = start
self.driverHandle = driverHandle
self.ownsEmbeddedDaemon = ownsEmbeddedDaemon
}

/// Wrap an already-open driver connection (from `PilotConnect`) without
/// booting the embedded daemon. `stop()` closes only the driver handle;
/// the caller keeps ownership of whatever is serving the socket.
internal static func attach(driverHandle: UInt64, start: StartResult) -> Pilot {
Pilot(start: start, driverHandle: driverHandle, ownsEmbeddedDaemon: false)
}

deinit { if !stopped { try? stop() } }
Expand Down Expand Up @@ -154,6 +166,7 @@ public final class Pilot {
guard !stopped else { return }
stopped = true
_ = PilotClose(driverHandle)
guard ownsEmbeddedDaemon else { return }
let resp = try parseJSON(PilotEmbeddedStop())
if let err = resp["error"] as? String { throw Error.rpcFailed(err) }
}
Expand Down Expand Up @@ -207,18 +220,22 @@ public final class Pilot {
}

public func receive() throws -> Datagram {
let resp = try rpc(PilotRecvFrom(driverHandle))
try Pilot.decodeDatagram(try rpc(PilotRecvFrom(driverHandle)))
}

/// Build a `Datagram` from a decoded RecvFrom response body.
/// Split out of `receive()` so the decode contract can be driven
/// with response shapes the wire format cannot produce.
internal static func decodeDatagram(_ resp: [String: Any]) throws -> Datagram {
guard
let src = resp["src_addr"] as? String,
let sportNum = resp["src_port"] as? NSNumber,
let dportNum = resp["dst_port"] as? NSNumber,
sportNum.uint16Value == sportNum.uint64Value,
dportNum.uint16Value == dportNum.uint64Value
let sport = port(from: sportNum),
let dport = port(from: dportNum)
else {
throw Error.invalidResponse("recv: \(resp)")
}
let sport = sportNum.uint16Value
let dport = dportNum.uint16Value
// Go's encoding/json renders []byte as base64.
let data: Data
if let b64 = resp["data"] as? String, let d = Data(base64Encoded: b64) {
Expand All @@ -231,6 +248,16 @@ public final class Pilot {
return Datagram(srcAddr: src, srcPort: sport, dstPort: dport, data: data)
}

/// Narrow an `NSNumber` to `UInt16` only when the value is exactly
/// representable in 16 bits. `NSNumber.uint16Value` wraps silently —
/// 70000 reads back as 4464 and -1 as 65535 — so compare in a single
/// wide signed type first and reject anything out of range.
private static func port(from n: NSNumber) -> UInt16? {
let wide = n.int64Value
guard wide >= 0, wide <= Int64(UInt16.max) else { return nil }
return UInt16(wide)
}

public func trustedPeers() throws -> [[String: Any]] {
let resp = try rpc(PilotTrustedPeers(driverHandle))
return (resp["trusted"] as? [[String: Any]]) ?? []
Expand Down
86 changes: 67 additions & 19 deletions Tests/PilotTests/DatagramTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,28 +69,76 @@ final class DatagramTests: XCTestCase {
XCTAssertEqual(d.count, 4)
XCTAssertEqual(d, Data([0xDE, 0xAD, 0xBE, 0xEF]))
}
}

// MARK: - Port truncation validation (PILOT-119)
// MARK: - Port range validation (PILOT-119)

func testPortTruncationDetection() {
// uint16Value truncates values > 65535 silently.
// A value of 70000 truncates to 4464 (70000 - 65536).
// The receive() path must reject such values.
let n70000 = NSNumber(value: 70000)
XCTAssertNotEqual(n70000.uint16Value, n70000.uint64Value,
"70000 should be detected as truncated")
/// Response body in the shape `receive()` hands to the decoder.
private func recvBody(srcPort: Any, dstPort: Any) -> [String: Any] {
[
"src_addr": "0:0000.0000.AAAA",
"src_port": srcPort,
"dst_port": dstPort,
"data": Data("payload".utf8).base64EncodedString(),
]
}

let n65535 = NSNumber(value: 65535)
XCTAssertEqual(n65535.uint16Value, n65535.uint64Value,
"65535 is max valid port, should not truncate")
func testPortTruncationDetection() throws {
// NSNumber.uint16Value wraps: 70000 reads back as 4464, and -1 as
// 65535. Both must be rejected rather than silently narrowed.
XCTAssertEqual(NSNumber(value: 70000).uint16Value, 4464)
XCTAssertEqual(NSNumber(value: -1).uint16Value, UInt16.max)

for badPort in [70000, 65536, 4_294_967_295, -1] {
let body = recvBody(srcPort: NSNumber(value: badPort), dstPort: 7)
XCTAssertThrowsError(try Pilot.decodeDatagram(body),
"src_port \(badPort) should be rejected") { err in
guard case Pilot.Error.invalidResponse = err else {
return XCTFail("wrong case for \(badPort): \(err)")
}
}

let body2 = recvBody(srcPort: 7, dstPort: NSNumber(value: badPort))
XCTAssertThrowsError(try Pilot.decodeDatagram(body2),
"dst_port \(badPort) should be rejected") { err in
guard case Pilot.Error.invalidResponse = err else {
return XCTFail("wrong case for \(badPort): \(err)")
}
}
}

// Both ends of the valid range survive the round-trip intact.
for goodPort in [0, 1, 7, 65535] {
let dg = try Pilot.decodeDatagram(
recvBody(srcPort: NSNumber(value: goodPort), dstPort: NSNumber(value: goodPort)))
XCTAssertEqual(dg.srcPort, UInt16(goodPort))
XCTAssertEqual(dg.dstPort, UInt16(goodPort))
XCTAssertEqual(dg.srcAddr, "0:0000.0000.AAAA")
XCTAssertEqual(dg.data, Data("payload".utf8))
}
}

let n0 = NSNumber(value: 0)
XCTAssertEqual(n0.uint16Value, n0.uint64Value,
"port 0 is valid, should not truncate")
func testDecodeRejectsMissingAndMistypedFields() {
let cases: [[String: Any]] = [
["src_port": 1, "dst_port": 2], // no src_addr
["src_addr": "0:0.0.0", "dst_port": 2], // no src_port
["src_addr": "0:0.0.0", "src_port": 1], // no dst_port
["src_addr": 42, "src_port": 1, "dst_port": 2], // src_addr not a string
["src_addr": "0:0.0.0", "src_port": "1", "dst_port": 2], // src_port not a number
]
for body in cases {
XCTAssertThrowsError(try Pilot.decodeDatagram(body), "should reject \(body)")
}
}

let nNegative = NSNumber(value: -1)
// intValue == -1, uint64Value would wrap around for negative
XCTAssertNotEqual(Int64(nNegative.intValue), Int64(bitPattern: nNegative.uint64Value),
"negative values should be distinguishable from valid ports")
func testDecodeFallsBackToRawByteArrayAndEmptyData() throws {
var body = recvBody(srcPort: 1, dstPort: 2)
body["data"] = [UInt8]([0xDE, 0xAD])
XCTAssertEqual(try Pilot.decodeDatagram(body).data, Data([0xDE, 0xAD]))

body["data"] = NSNull()
XCTAssertEqual(try Pilot.decodeDatagram(body).data, Data())

body.removeValue(forKey: "data")
XCTAssertEqual(try Pilot.decodeDatagram(body).data, Data())
}
}
15 changes: 11 additions & 4 deletions Tests/PilotTests/ErrorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ final class ErrorTests: XCTestCase {
XCTAssertEqual(e.description, "Pilot invalid response: not a JSON object: ...")
}

func testDataTooLargeDescription() {
let e = Pilot.Error.dataTooLarge(2_147_483_648)
XCTAssertEqual(
e.description,
"Pilot send: data 2147483648 bytes exceeds Int32.max (C ABI limit)")
}

func testEmptyMessageStillRenders() {
XCTAssertEqual(Pilot.Error.startFailed("").description, "Pilot start failed: ")
XCTAssertEqual(Pilot.Error.rpcFailed("").description, "Pilot RPC failed: ")
Expand All @@ -32,8 +39,9 @@ final class ErrorTests: XCTestCase {
Pilot.Error.startFailed("a"),
Pilot.Error.rpcFailed("b"),
Pilot.Error.invalidResponse("c"),
Pilot.Error.dataTooLarge(1),
]
XCTAssertEqual(errs.count, 3)
XCTAssertEqual(errs.count, 4)
for err in errs {
// localizedDescription always works on Swift errors; this just
// proves the conformance compiles and the value can be thrown.
Expand Down Expand Up @@ -81,8 +89,7 @@ final class ErrorTests: XCTestCase {
let a = Pilot.Error.startFailed("x").description
let b = Pilot.Error.rpcFailed("x").description
let c = Pilot.Error.invalidResponse("x").description
XCTAssertNotEqual(a, b)
XCTAssertNotEqual(b, c)
XCTAssertNotEqual(a, c)
let d = Pilot.Error.dataTooLarge(1).description
XCTAssertEqual(Set([a, b, c, d]).count, 4)
}
}
2 changes: 2 additions & 0 deletions Tests/PilotTests/IntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ final class IntegrationTests: XCTestCase {
break // expected
case .rpcFailed(let m):
XCTFail("unexpected rpcFailed: \(m)")
case .dataTooLarge(let n):
XCTFail("unexpected dataTooLarge(\(n)) on the start path")
}
}
}
Expand Down
75 changes: 60 additions & 15 deletions Tests/PilotTests/MockDaemonTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,15 @@
// PilotC bindings end-to-end without booting the embedded Go
// daemon (which requires a real registry + beacon).
//
// Why we don't drive the full `Pilot.*` wrapper:
// Most tests here drive the underlying PilotC C symbols directly:
// `Pilot.start(_:)` boots the embedded Go daemon, which the mock
// does not implement. That protects the FFI boundary — every
// command code the wrapper sends, the mock replies to, and the
// JSON contract holds.
//
// `Pilot.start(_:)` is the only public constructor and it
// unconditionally calls `PilotEmbeddedStart`, which spins up a
// real daemon that dials a registry. The mock daemon only
// implements the local IPC socket, not the registry protocol —
// so there is no way to drive `Pilot.info() / health() / send()`
// end-to-end against the mock without modifying Pilot.swift
// (which the task explicitly forbids).
//
// The next-best thing — and what this file does — is to drive
// the underlying PilotC C symbols directly against the mock. This
// does NOT bump line coverage on Sources/Pilot/Pilot.swift (the
// wrapper methods are bypassed) but it DOES protect the FFI
// boundary: every command code the wrapper sends, the mock
// replies to, and the JSON contract holds.
// The wrapper methods themselves are covered by attaching a `Pilot`
// to the already-open driver handle via `Pilot.attach`, which skips
// the embedded boot. See `testWrapperSendReceiveRoundtrip`.
//
// Tests skip cleanly if `go` is not on PATH or the mock daemon
// source tree cannot be located.
Expand Down Expand Up @@ -140,6 +133,12 @@ final class MockDaemonTests: XCTestCase {
build.executableURL = URL(fileURLWithPath: goPath)
build.arguments = ["build", "-o", outPath, "."]
build.currentDirectoryURL = URL(fileURLWithPath: src)
// mockdaemon is its own module. A go.work higher up the tree that
// lists libpilot but not this nested module makes `go build` resolve
// against the workspace and fail; GOWORK=off keeps it module-local.
var env = ProcessInfo.processInfo.environment
env["GOWORK"] = "off"
build.environment = env
let buildErr = Pipe()
build.standardError = buildErr
try build.run()
Expand Down Expand Up @@ -412,6 +411,52 @@ final class MockDaemonTests: XCTestCase {
/// same Foundation base64 path Pilot.receive() uses to decode the
/// JSON-wrapped data field — proving the format compatibility holds
/// regardless of whether the bytes came from a real or mock daemon.
/// Drives the public wrapper methods — `send()` then `receive()` — over
/// the mock's SendTo→RecvFrom loopback. Unlike the C-symbol tests above,
/// this executes Pilot.swift's own address formatting, size guard, RPC
/// error unwrapping and datagram decoding.
func testWrapperSendReceiveRoundtrip() throws {
let p = attachWrapper()
let payload = Data([0x01, 0x02, 0xFF, 0x00, 0xAB])
try p.send(to: "0:0000.0000.BEEF", port: 7, data: payload)

// receive() blocks on the driver's datagram channel; bound the wait
// so a mock regression fails the test instead of hanging the suite.
let got = expectation(description: "receive")
var received: Pilot.Datagram?
var failure: Swift.Error?
DispatchQueue.global().async {
do { received = try p.receive() } catch { failure = error }
got.fulfill()
}
wait(for: [got], timeout: 10)

if let failure { throw failure }
let dg = try XCTUnwrap(received)
XCTAssertEqual(dg.data, payload)
XCTAssertEqual(dg.srcPort, 0xDEAD) // canned by the mock's loopback
XCTAssertEqual(dg.dstPort, 7)
XCTAssertFalse(dg.srcAddr.isEmpty)
}

/// Empty payloads short-circuit before the C call, so nothing is
/// reflected and a follow-up send is what actually reaches the mock.
func testWrapperSendIgnoresEmptyPayload() throws {
let p = attachWrapper()
XCTAssertNoThrow(try p.send(to: "0:0000.0000.BEEF", port: 7, data: Data()))
}

/// Hand the open driver handle to a `Pilot` instance and clear our own
/// copy, so the wrapper's `stop()`/`deinit` is the only closer.
private func attachWrapper() -> Pilot {
let p = Pilot.attach(
driverHandle: driverHandle,
start: Pilot.StartResult(
address: "0:0000.0000.AAAA", nodeID: 0x12345678, publicKey: "mock"))
driverHandle = 0
return p
}

func testEchoPayloadBase64ContractMatchesWrapper() throws {
let raw = Data([0x01, 0x02, 0xFF, 0x00, 0xAB])
let b64 = raw.base64EncodedString()
Expand Down
4 changes: 4 additions & 0 deletions Tests/PilotTests/StartErrorPathTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ final class StartErrorPathTests: XCTestCase {
break // expected
case .rpcFailed(let m):
XCTFail("unexpected rpcFailed: \(m)")
case .dataTooLarge(let n):
XCTFail("unexpected dataTooLarge(\(n)) on the start path")
}
}
}
Expand Down Expand Up @@ -141,6 +143,8 @@ final class StartErrorPathTests: XCTestCase {
XCTAssertFalse(msg.isEmpty, "empty invalidResponse message")
case .rpcFailed:
XCTFail("unexpected rpcFailed before start completed")
case .dataTooLarge(let n):
XCTFail("unexpected dataTooLarge(\(n)) before start completed")
}
} catch {
XCTFail("wrong error type: \(error)")
Expand Down
Loading