From cb44423d71f5fe088c47c6f0d1b0b0d9c576ed8d Mon Sep 17 00:00:00 2001 From: criseda Date: Thu, 15 Oct 2026 13:17:47 +0100 Subject: [PATCH 1/2] Supply the ARP table to the engine from the app macOS 27 hides the ARP table from children of an ad-hoc signed app but not from the app itself, so Debug builds showed no MAC addresses, manufacturers or ARP-found hosts. Discovery now runs ns with --neighbor-table=parent and answers each neighbors_request on its stdin with the table read in-process (a Swift port of the engine's macos_dump). The engine keeps all logic of what an entry means. Needs the engine release that adds --neighbor-table; until it is pinned, test with fetch-engine.sh --local. --- AGENTS.md | 54 ++++----- CHANGELOG.md | 8 +- .../NetScannerKit/Engine/EngineEvent.swift | 4 + .../Engine/EngineEventDecoder.swift | 7 +- .../NetScannerKit/Engine/EngineRun.swift | 31 ++++- .../NetScannerKit/Engine/NeighborsReply.swift | 44 +++++++ .../NetScannerKit/Engine/ScanEngine.swift | 17 ++- .../NetScannerKit/Engine/ScanRequest.swift | 4 +- .../NetScannerKit/Network/NeighborTable.swift | 114 ++++++++++++++++++ .../EngineEventDecoderTests.swift | 9 +- .../NeighborTableTests.swift | 82 +++++++++++++ .../NetScannerKitTests/ScanEngineTests.swift | 31 ++++- 12 files changed, 361 insertions(+), 44 deletions(-) create mode 100644 Packages/NetScannerKit/Sources/NetScannerKit/Engine/NeighborsReply.swift create mode 100644 Packages/NetScannerKit/Sources/NetScannerKit/Network/NeighborTable.swift create mode 100644 Packages/NetScannerKit/Tests/NetScannerKitTests/NeighborTableTests.swift diff --git a/AGENTS.md b/AGENTS.md index b344c66..cc1f69d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,11 +69,11 @@ to try an unreleased engine. belongs to the app's code signature, so a build signed differently (ad-hoc vs. a team certificate) is a new app to macOS and is asked again. -- **ARP table needs a properly signed app.** On macOS 27 the kernel - hides the ARP table from the `sysctl(NET_RT_FLAGS, RTF_LLINFO)` dump - (it returns 0 bytes, with no error and nothing logged) unless the - process chain is trusted. Measured with a test app and the bundled - `ns` (October 2026): +- **The app reads the ARP table for the engine.** On macOS 27 the + kernel hides the ARP table from the `sysctl(NET_RT_FLAGS, + RTF_LLINFO)` dump (it returns 0 bytes, with no error and nothing + logged) unless the process chain is trusted. Measured with a test app + and the bundled `ns` (October 2026): - The app's **own process** can always read the table, even when it is ad-hoc signed. - **Children of an ad-hoc signed app** (Xcode's "Sign to Run @@ -81,31 +81,27 @@ to try an unreleased engine. get an empty table. That holds for every child: `ns` with any signature, Apple's own `/usr/sbin/arp`, children started through `/bin/sh` or `/usr/bin/env`, and children that disclaim - responsibility. This is why the app shows no MAC addresses today. + responsibility. An Apple Development certificate (free personal + team) does not help either. - **Children of a Developer ID signed, notarized app** that - LaunchServices started can read it. With Postman as the parent, - the unchanged bundled `ns -s … --resolve --json` reported 37 hosts, - every one with a MAC address and manufacturer, read directly and - through `/bin/sh`. - - A non-Apple binary between the responsible app and `ns` hides the - table again, even a Developer ID one (that is why `zig build run` - fails). Keep spawning `ns` directly from the app. - - The reader needs a reverse-DNS code identifier (`ns` keeps - `io.github.criseda.netscanner`; `ns`, `ns-` and the linker's - default all fail). - - Local Network permission is needed for scanning, but it does not - affect this: the ad-hoc app had it and still got an empty table. - - So no engine change or workaround is needed: the Developer ID build - the Homebrew cask needs anyway should get MAC addresses as is. Still - to check once a certificate is available: that a Developer ID build - actually shows MACs (with and without notarization), . An Apple Development - certificate (free personal team) is **not** enough: a build signed - with one, launched from Finder, still got no MAC addresses (October - 2026). If a Developer ID build still fails, fall back to - reading the table in the app's own process through a small C library - from the engine (`dump_neighbors` in `src/c/neighbors.c`), which - works even ad-hoc. + LaunchServices started can read it; a non-Apple binary between the + app and `ns` hides it again. + + So discovery scans run `ns` with `--neighbor-table=parent` (the first + engine release after v1.6.0; see "Neighbor table from the parent" in NetScanner's + `docs/README.md`). Each time `ns` needs the table it prints + `{"type":"neighbors_request","id":N}` and waits up to 2 seconds for + one line on stdin. `EngineRun` answers from the stdout handler (off + the main actor) with `NeighborTable.read()`, a pure Swift port of the + engine's `macos_dump` in `src/c/neighbors.c`: the raw table, every + entry `stale`, no filtering. Everything an entry means (which hosts + it proves, the confirming pings, the manufacturer lookup) stays in + the engine. If the read fails the reply carries an `error` and `ns` + reads the table itself. This works with any signature, so MAC + addresses, manufacturers and ARP-found quiet hosts show in ad-hoc + Debug builds too. Keep `ns` spawned directly by the app, keep its + stdin a pipe the app owns, and keep `ns` signed with a reverse-DNS + identifier (`io.github.criseda.netscanner`) as before. - **Script sandboxing.** `ENABLE_USER_SCRIPT_SANDBOXING` is on. Build phases must declare their inputs and outputs, and tools that write temporary files next to outputs (codesign) fail inside it, which is diff --git a/CHANGELOG.md b/CHANGELOG.md index 40a4734..ee23fcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,8 @@ First version of the native macOS app: finds only this Mac while the permission prompt is pending. Scans wait for the answer, and if access is denied, Discovery explains how to turn it on. +- Shows MAC addresses, manufacturers and quiet hosts found in the ARP + table however the app is signed: macOS 27 hides that table from the + engine but not from the app, so the app reads it and hands it to the + engine (`--neighbor-table=parent`). - Bundles NetScanner v1.6.0 as a universal binary. - -Known issue: on macOS 27 the engine cannot read the ARP table unless -the app is Developer ID signed, so MAC addresses and manufacturers stay -empty in ad-hoc and Apple Development builds. diff --git a/Packages/NetScannerKit/Sources/NetScannerKit/Engine/EngineEvent.swift b/Packages/NetScannerKit/Sources/NetScannerKit/Engine/EngineEvent.swift index c954c8b..d02e998 100644 --- a/Packages/NetScannerKit/Sources/NetScannerKit/Engine/EngineEvent.swift +++ b/Packages/NetScannerKit/Sources/NetScannerKit/Engine/EngineEvent.swift @@ -13,4 +13,8 @@ public enum EngineEvent: Hashable, Sendable { case portScanFinished(openPorts: [UInt16], elapsed: Duration) /// An input error. The engine exits with status 1 right after. case failure(message: String) + /// The engine waits for this app to send it the ARP table + /// (`--neighbor-table=parent`). ``ScanEngine`` answers it, so it + /// never reaches the scan's event stream. + case neighborsRequest(id: Int) } diff --git a/Packages/NetScannerKit/Sources/NetScannerKit/Engine/EngineEventDecoder.swift b/Packages/NetScannerKit/Sources/NetScannerKit/Engine/EngineEventDecoder.swift index 04c1e53..dd5cbd6 100644 --- a/Packages/NetScannerKit/Sources/NetScannerKit/Engine/EngineEventDecoder.swift +++ b/Packages/NetScannerKit/Sources/NetScannerKit/Engine/EngineEventDecoder.swift @@ -23,6 +23,7 @@ public struct EngineEventDecoder: Sendable { /// keeps one forgiving decode path instead of one type per event. private struct RawEvent: Decodable { let type: String + let id: Int? let ip: String? let source: String? let hostname: String? @@ -44,7 +45,7 @@ private struct RawEvent: Decodable { let message: String? enum CodingKeys: String, CodingKey { - case type, ip, source, hostname, mac, vendor, first, last, port, service, iana + case type, id, ip, source, hostname, mac, vendor, first, last, port, service, iana case description, category, phase, done, total, hosts, message case openPorts = "open_ports" case elapsedMs = "elapsed_ms" @@ -91,6 +92,10 @@ private struct RawEvent: Decodable { case "error": return .failure(message: message ?? String(localized: "The scan engine reported an error.", bundle: .module)) + case "neighbors_request": + guard let id else { return nil } + return .neighborsRequest(id: id) + default: return nil } diff --git a/Packages/NetScannerKit/Sources/NetScannerKit/Engine/EngineRun.swift b/Packages/NetScannerKit/Sources/NetScannerKit/Engine/EngineRun.swift index 89ff5ad..d75f211 100644 --- a/Packages/NetScannerKit/Sources/NetScannerKit/Engine/EngineRun.swift +++ b/Packages/NetScannerKit/Sources/NetScannerKit/Engine/EngineRun.swift @@ -9,10 +9,16 @@ import Synchronization /// lost and the exit status is known when deciding success or failure. /// The pipe handlers and the termination handler run on Foundation's /// background queues; all shared state sits behind one mutex. +/// +/// Requests for the ARP table (`neighbors_request`) are answered on +/// stdin from the stdout handler, so never on the main actor; the engine +/// waits for the answer, so nothing else is held up meanwhile. final class EngineRun: Sendable { typealias Continuation = AsyncThrowingStream<[EngineEvent], any Error>.Continuation private let process: Process + private let input = Pipe() + private let readNeighborTable: ScanEngine.NeighborTableReader private let continuation: Continuation private let state = Mutex(State()) private let decoder = EngineEventDecoder() @@ -32,10 +38,14 @@ final class EngineRun: Sendable { var finished = false } - init(executableURL: URL, arguments: [String], continuation: Continuation) { + init( + executableURL: URL, arguments: [String], + readNeighborTable: @escaping ScanEngine.NeighborTableReader, continuation: Continuation + ) { process = Process() process.executableURL = executableURL process.arguments = arguments + self.readNeighborTable = readNeighborTable self.continuation = continuation } @@ -44,7 +54,10 @@ final class EngineRun: Sendable { let stderr = Pipe() process.standardOutput = stdout process.standardError = stderr - process.standardInput = FileHandle.nullDevice + process.standardInput = input + // Writing to an engine that already exited must fail with EPIPE, + // not kill the app with SIGPIPE. + _ = fcntl(input.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1) stdout.fileHandleForReading.readabilityHandler = { [self] handle in readStdout(handle) @@ -61,6 +74,7 @@ final class EngineRun: Sendable { } catch { stdout.fileHandleForReading.readabilityHandler = nil stderr.fileHandleForReading.readabilityHandler = nil + try? input.fileHandleForWriting.close() state.withLock { $0.finished = true } continuation.finish( throwing: EngineError.launchFailed(reason: error.localizedDescription)) @@ -81,6 +95,8 @@ final class EngineRun: Sendable { handle.readabilityHandler = nil let lastLine = state.withLock { $0.splitter.finish() } if let lastLine { deliver([lastLine]) } + // Requests come on stdout, so none can follow. + try? input.fileHandleForWriting.close() update { $0.stdoutClosed = true } return } @@ -111,6 +127,8 @@ final class EngineRun: Sendable { switch decoder.decode(line) { case .failure(let message)?: state.withLock { $0.rejection = $0.rejection ?? message } + case .neighborsRequest(let id)?: + answerNeighborsRequest(id) case let event?: events.append(event) case nil: @@ -120,6 +138,15 @@ final class EngineRun: Sendable { if !events.isEmpty { continuation.yield(events) } } + /// Sends the ARP table, or why it could not be read. A failed write + /// means the engine is gone or stopped listening; it then reads the + /// table itself, so there is nothing to report. + private func answerNeighborsRequest(_ id: Int) { + let table = Result { () throws(NeighborTableError) in try readNeighborTable() } + let reply = NeighborsReply(id: id, table: table) + try? input.fileHandleForWriting.write(contentsOf: reply.line) + } + // MARK: - Completion /// Applies a change, then finishes the stream if that was the last of diff --git a/Packages/NetScannerKit/Sources/NetScannerKit/Engine/NeighborsReply.swift b/Packages/NetScannerKit/Sources/NetScannerKit/Engine/NeighborsReply.swift new file mode 100644 index 0000000..cfd035c --- /dev/null +++ b/Packages/NetScannerKit/Sources/NetScannerKit/Engine/NeighborsReply.swift @@ -0,0 +1,44 @@ +import Foundation + +/// The app's answer to the engine's `neighbors_request`: one line on the +/// engine's stdin carrying the ARP table, or why it could not be read +/// (the engine then reads the table itself). +/// +/// macOS keeps no reachability state in the table, so every entry is +/// sent as `stale`, which is what the engine expects there. +struct NeighborsReply: Encodable, Sendable { + private let type = "neighbors" + let id: Int + let entries: [Entry]? + let error: String? + + struct Entry: Encodable, Sendable { + let ip: String + let mac: String + let state = "stale" + } + + init(id: Int, table: Result<[NeighborEntry], NeighborTableError>) { + self.id = id + switch table { + case .success(let table): + entries = table.map { Entry(ip: $0.ip.description, mac: $0.macAddress) } + error = nil + case .failure(let failure): + entries = nil + error = failure.message + } + } + + /// The reply as one newline-terminated JSON line. + var line: Data { + let encoder = JSONEncoder() + // Sorted keys make the line predictable; the engine does not care. + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + // Encoding plain strings and numbers cannot fail; an empty line + // would only make the engine read the table itself. + var line = (try? encoder.encode(self)) ?? Data() + line.append(UInt8(ascii: "\n")) + return line + } +} diff --git a/Packages/NetScannerKit/Sources/NetScannerKit/Engine/ScanEngine.swift b/Packages/NetScannerKit/Sources/NetScannerKit/Engine/ScanEngine.swift index ef645a7..ccff036 100644 --- a/Packages/NetScannerKit/Sources/NetScannerKit/Engine/ScanEngine.swift +++ b/Packages/NetScannerKit/Sources/NetScannerKit/Engine/ScanEngine.swift @@ -4,12 +4,23 @@ import Foundation /// /// The engine is the single source of truth for scanning: this type only /// launches it with `--json`, turns its output into typed events as they -/// arrive, and stops it when the consumer stops listening. +/// arrive, answers its requests for the ARP table (which macOS may hide +/// from it but not from the app), and stops it when the consumer stops +/// listening. public struct ScanEngine: Sendable { + /// Reads the ARP table for the engine (`--neighbor-table=parent`). + public typealias NeighborTableReader = @Sendable () throws(NeighborTableError) -> [NeighborEntry] + public let executableURL: URL + let readNeighborTable: NeighborTableReader - public init(executableURL: URL) { + /// `readNeighborTable` answers the engine's requests for the ARP + /// table; it is replaceable so tests need no real network. + public init( + executableURL: URL, readNeighborTable: @escaping NeighborTableReader = NeighborTable.read + ) { self.executableURL = executableURL + self.readNeighborTable = readNeighborTable } /// Runs a scan and streams its events. @@ -29,7 +40,7 @@ public struct ScanEngine: Sendable { } let run = EngineRun( executableURL: executableURL, arguments: request.arguments, - continuation: continuation) + readNeighborTable: readNeighborTable, continuation: continuation) continuation.onTermination = { termination in if case .cancelled = termination { run.cancel() } } diff --git a/Packages/NetScannerKit/Sources/NetScannerKit/Engine/ScanRequest.swift b/Packages/NetScannerKit/Sources/NetScannerKit/Engine/ScanRequest.swift index 27fce85..0edf587 100644 --- a/Packages/NetScannerKit/Sources/NetScannerKit/Engine/ScanRequest.swift +++ b/Packages/NetScannerKit/Sources/NetScannerKit/Engine/ScanRequest.swift @@ -10,7 +10,9 @@ public enum ScanRequest: Hashable, Sendable { var arguments = ["-s", options.subnet.description] if options.method == .ping { arguments.append("--ping") } if options.identifyDevices { arguments.append("--resolve") } - return arguments + ["--json"] + // The app reads the ARP table for the engine (see + // `NeighborTable`): macOS may hide it from `ns`. + return arguments + ["--json", "--neighbor-table=parent"] case .portScan(let options): var arguments = ["-p", options.host.description, options.ports.engineArgument] diff --git a/Packages/NetScannerKit/Sources/NetScannerKit/Network/NeighborTable.swift b/Packages/NetScannerKit/Sources/NetScannerKit/Network/NeighborTable.swift new file mode 100644 index 0000000..9acc4a0 --- /dev/null +++ b/Packages/NetScannerKit/Sources/NetScannerKit/Network/NeighborTable.swift @@ -0,0 +1,114 @@ +import Darwin + +/// One resolved entry of the kernel's ARP table. +public struct NeighborEntry: Hashable, Sendable { + public let ip: IPv4 + /// Six bytes, as `aa:bb:cc:dd:ee:ff`. + public let macAddress: String + + public init(ip: IPv4, macAddress: String) { + self.ip = ip + self.macAddress = macAddress + } +} + +/// Why the ARP table could not be read: the `errno` of the failed +/// `sysctl`. +public struct NeighborTableError: Error, Hashable, Sendable { + public let code: Int32 + + public var message: String { String(cString: strerror(code)) } +} + +/// The kernel's ARP table, read in this process. +/// +/// On macOS 27 the kernel hides the table from programs that an ad-hoc +/// signed app starts, `ns` among them, while the app itself can always +/// read it. So the app reads it here and hands it to the engine +/// (`--neighbor-table=parent`), which keeps all the logic of what an +/// entry means. This mirrors `macos_dump` in the engine's +/// `src/c/neighbors.c` and adds nothing to it: no filtering, no +/// manufacturer lookup. +public enum NeighborTable { + /// The table as the kernel reports it. Unresolved entries (nobody + /// answered, so no MAC address) are left out. + public static func read() throws(NeighborTableError) -> [NeighborEntry] { + var mib: [Int32] = [CTL_NET, PF_ROUTE, 0, AF_INET, NET_RT_FLAGS, RTF_LLINFO] + // The table can grow between sizing and reading, so retry with + // slack, as the engine does. + for attempt in 0..<3 { + var needed = 0 + guard sysctl(&mib, UInt32(mib.count), nil, &needed, nil, 0) == 0 else { + throw NeighborTableError(code: errno) + } + var length = needed + needed / 2 + MemoryLayout.size + var buffer = [UInt8](repeating: 0, count: length) + let status = buffer.withUnsafeMutableBytes { bytes in + sysctl(&mib, UInt32(mib.count), bytes.baseAddress, &length, nil, 0) + } + if status == 0 { + return buffer.withUnsafeBytes { entries(inRouteDump: UnsafeRawBufferPointer(rebasing: $0[.. [NeighborEntry] { + var entries: [NeighborEntry] = [] + var offset = 0 + while offset + MemoryLayout.size <= dump.count { + let header = dump.loadUnaligned(fromByteOffset: offset, as: rt_msghdr.self) + let length = Int(header.rtm_msglen) + guard length > 0, offset + length <= dump.count else { break } + let message = UnsafeRawBufferPointer(rebasing: dump[offset.. NeighborEntry? { + let inetOffset = MemoryLayout.size + guard inetOffset + MemoryLayout.size <= message.count else { return nil } + let inet = message.loadUnaligned(fromByteOffset: inetOffset, as: sockaddr_in.self) + guard inet.sin_family == AF_INET else { return nil } + + let linkOffset = inetOffset + roundedUp(Int(inet.sin_len)) + guard linkOffset + MemoryLayout.size <= message.count else { return nil } + let link = message.loadUnaligned(fromByteOffset: linkOffset, as: sockaddr_dl.self) + // Unresolved entries carry no link-layer address: nobody answered. + guard link.sdl_family == AF_LINK, link.sdl_alen == 6 else { return nil } + + // LLADDR: the address follows the interface name in `sdl_data`. + let macOffset = linkOffset + linkDataOffset + Int(link.sdl_nlen) + guard macOffset + 6 <= message.count else { return nil } + let mac = message[macOffset.. Int { + length > 0 ? (length + 3) & ~3 : 4 + } + + /// Where `sdl_data` starts in a `sockaddr_dl` (8 bytes in every SDK). + private static let linkDataOffset = MemoryLayout.offset(of: \.sdl_data) ?? 8 + + private static func hex(_ byte: UInt8) -> String { + let digits = String(byte, radix: 16) + return byte < 0x10 ? "0" + digits : digits + } +} diff --git a/Packages/NetScannerKit/Tests/NetScannerKitTests/EngineEventDecoderTests.swift b/Packages/NetScannerKit/Tests/NetScannerKitTests/EngineEventDecoderTests.swift index 9d4faee..36e3d2e 100644 --- a/Packages/NetScannerKit/Tests/NetScannerKitTests/EngineEventDecoderTests.swift +++ b/Packages/NetScannerKit/Tests/NetScannerKitTests/EngineEventDecoderTests.swift @@ -60,6 +60,11 @@ struct EngineEventDecoderTests { #expect(decode(#"{"type":"error","message":"Invalid IP address"}"#) == .failure(message: "Invalid IP address")) } + @Test func decodesNeighborsRequests() { + #expect(decode(#"{"type":"neighbors_request","id":1}"#) == .neighborsRequest(id: 1)) + #expect(decode(#"{"type":"neighbors_request"}"#) == nil) + } + @Test(arguments: [ "", " ", "NetScanner: warning", "[1,2]", "{not json", #"{"type":"future_event","x":1}"#, #"{"type":"host","ip":"999.1.1.1"}"#, #"{"type":"summary"}"#, #"{"ip":"1.1.1.1"}"#, @@ -92,10 +97,10 @@ struct LineSplitterTests { struct ScanRequestTests { @Test func buildsDiscoveryArguments() throws { let subnet = try Subnet(validating: "192.168.1.0/24") - #expect(ScanRequest.discovery(DiscoveryOptions(subnet: subnet)).arguments == ["-s", "192.168.1.0/24", "--resolve", "--json"]) + #expect(ScanRequest.discovery(DiscoveryOptions(subnet: subnet)).arguments == ["-s", "192.168.1.0/24", "--resolve", "--json", "--neighbor-table=parent"]) #expect( ScanRequest.discovery(DiscoveryOptions(subnet: subnet, method: .ping, identifyDevices: false)).arguments - == ["-s", "192.168.1.0/24", "--ping", "--json"]) + == ["-s", "192.168.1.0/24", "--ping", "--json", "--neighbor-table=parent"]) } @Test func buildsPortScanArguments() { diff --git a/Packages/NetScannerKit/Tests/NetScannerKitTests/NeighborTableTests.swift b/Packages/NetScannerKit/Tests/NetScannerKitTests/NeighborTableTests.swift new file mode 100644 index 0000000..72f9c4e --- /dev/null +++ b/Packages/NetScannerKit/Tests/NetScannerKitTests/NeighborTableTests.swift @@ -0,0 +1,82 @@ +import Darwin +import Foundation +import Testing + +@testable import NetScannerKit + +@Suite("ARP table") +struct NeighborTableTests { + @Test func readsResolvedEntriesFromARouteDump() { + var dump = Data() + dump += routeMessage(ip: (192, 168, 1, 1), interface: "en0", mac: [0xAA, 0xBB, 0xCC, 0x0D, 0x0E, 0x0F]) + // Asked about, nobody answered: no link-layer address. + dump += routeMessage(ip: (192, 168, 1, 7), interface: "en0", mac: []) + dump += routeMessage(ip: (10, 0, 0, 2), interface: "", mac: [0, 1, 2, 3, 4, 5]) + let entries = dump.withUnsafeBytes { NeighborTable.entries(inRouteDump: $0) } + #expect( + entries == [ + NeighborEntry(ip: IPv4(192, 168, 1, 1), macAddress: "aa:bb:cc:0d:0e:0f"), + NeighborEntry(ip: IPv4(10, 0, 0, 2), macAddress: "00:01:02:03:04:05"), + ]) + } + + @Test func stopsAtTruncatedOrEmptyMessages() { + let message = routeMessage(ip: (192, 168, 1, 1), interface: "en0", mac: [1, 2, 3, 4, 5, 6]) + let truncated = message.dropLast(4) + #expect(Data(truncated).withUnsafeBytes { NeighborTable.entries(inRouteDump: $0) }.isEmpty) + let zeroLength = Data(count: MemoryLayout.size) + message + #expect(zeroLength.withUnsafeBytes { NeighborTable.entries(inRouteDump: $0) }.isEmpty) + } + + @Test func readsThisMacsTable() throws { + // The content depends on the network; reading it must not fail. + _ = try NeighborTable.read() + } + + @Test func encodesTheReply() { + let table = [NeighborEntry(ip: IPv4(192, 168, 1, 1), macAddress: "aa:bb:cc:dd:ee:ff")] + #expect( + line(NeighborsReply(id: 1, table: .success(table))) + == #"{"entries":[{"ip":"192.168.1.1","mac":"aa:bb:cc:dd:ee:ff","state":"stale"}],"id":1,"type":"neighbors"}"# + "\n") + #expect( + line(NeighborsReply(id: 2, table: .success([]))) + == #"{"entries":[],"id":2,"type":"neighbors"}"# + "\n") + #expect( + line(NeighborsReply(id: 3, table: .failure(NeighborTableError(code: EPERM)))) + == #"{"error":"Operation not permitted","id":3,"type":"neighbors"}"# + "\n") + } + + // MARK: - Helpers + + private func line(_ reply: NeighborsReply) -> String { + String(decoding: reply.line, as: UTF8.self) + } + + /// One `RTM_GET` message as the kernel lays it out: header, then the + /// destination `sockaddr_in`, then the `sockaddr_dl` with the + /// interface name and the MAC address in `sdl_data`. + private func routeMessage( + ip: (UInt8, UInt8, UInt8, UInt8), interface: String, mac: [UInt8] + ) -> Data { + var inet = sockaddr_in() + inet.sin_len = UInt8(MemoryLayout.size) + inet.sin_family = sa_family_t(AF_INET) + withUnsafeMutableBytes(of: &inet.sin_addr) { $0.copyBytes(from: [ip.0, ip.1, ip.2, ip.3]) } + + var link = sockaddr_dl() + link.sdl_len = UInt8(MemoryLayout.size) + link.sdl_family = sa_family_t(AF_LINK) + link.sdl_nlen = UInt8(interface.utf8.count) + link.sdl_alen = UInt8(mac.count) + withUnsafeMutableBytes(of: &link.sdl_data) { $0.copyBytes(from: Array(interface.utf8) + mac) } + + var header = rt_msghdr() + header.rtm_msglen = UInt16( + MemoryLayout.size + MemoryLayout.size + MemoryLayout.size) + return bytes(of: header) + bytes(of: inet) + bytes(of: link) + } + + private func bytes(of value: T) -> Data { + withUnsafeBytes(of: value) { Data($0) } + } +} diff --git a/Packages/NetScannerKit/Tests/NetScannerKitTests/ScanEngineTests.swift b/Packages/NetScannerKit/Tests/NetScannerKitTests/ScanEngineTests.swift index 58dd05f..a52052c 100644 --- a/Packages/NetScannerKit/Tests/NetScannerKitTests/ScanEngineTests.swift +++ b/Packages/NetScannerKit/Tests/NetScannerKitTests/ScanEngineTests.swift @@ -74,6 +74,33 @@ struct ScanEngineTests { #expect(clock.now - started < .seconds(10)) } + @Test func answersNeighborsRequestsOnStdin() async throws { + // The fake engine sends the reply back on stderr, and fails so + // that it shows up in the error. + let table = [NeighborEntry(ip: IPv4(10, 0, 0, 2), macAddress: "aa:bb:cc:dd:ee:ff")] + let engine = try fakeEngine( + """ + echo '{"type":"neighbors_request","id":1}' + read -r reply + echo "$reply" >&2 + exit 3 + """, neighborTable: table) + let reply = #"{"entries":[{"ip":"10.0.0.2","mac":"aa:bb:cc:dd:ee:ff","state":"stale"}],"id":1,"type":"neighbors"}"# + await #expect(throws: EngineError.failed(status: 3, diagnostics: reply)) { + _ = try await collect(engine) + } + } + + @Test func keepsNeighborsRequestsOutOfTheStream() async throws { + let engine = try fakeEngine( + """ + echo '{"type":"neighbors_request","id":1}' + read -r reply + echo '{"type":"host","ip":"10.0.0.2","source":"arp"}' + """, neighborTable: []) + #expect(try await collect(engine) == [.hostFound(IPv4(10, 0, 0, 2), .arp)]) + } + @Test func readsTheVersion() async throws { let engine = try fakeEngine("echo v9.9.9") #expect(try await engine.version() == "v9.9.9") @@ -90,11 +117,11 @@ struct ScanEngineTests { return events } - private func fakeEngine(_ script: String) throws -> ScanEngine { + private func fakeEngine(_ script: String, neighborTable: [NeighborEntry] = []) throws -> ScanEngine { let url = FileManager.default.temporaryDirectory .appending(path: "fake-ns-\(UUID().uuidString)") try "#!/bin/sh\n\(script)\n".write(to: url, atomically: true, encoding: .utf8) try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path) - return ScanEngine(executableURL: url) + return ScanEngine(executableURL: url, readNeighborTable: { neighborTable }) } } From b02a5675e2304812080ef5a386dd667bec58bbb7 Mon Sep 17 00:00:00 2001 From: criseda Date: Thu, 15 Oct 2026 13:25:47 +0100 Subject: [PATCH 2/2] Bundle NetScanner v1.7.0, which takes the ARP table from the app v1.7.0 is the first engine release with --neighbor-table=parent, which discovery now passes, so pin it (and its release asset checksums). --- AGENTS.md | 4 ++-- CHANGELOG.md | 2 +- Config/Version.xcconfig | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cc1f69d..6a88548 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,8 +87,8 @@ to try an unreleased engine. LaunchServices started can read it; a non-Apple binary between the app and `ns` hides it again. - So discovery scans run `ns` with `--neighbor-table=parent` (the first - engine release after v1.6.0; see "Neighbor table from the parent" in NetScanner's + So discovery scans run `ns` with `--neighbor-table=parent` (engine + v1.7.0 and later; see "Neighbor table from the parent" in NetScanner's `docs/README.md`). Each time `ns` needs the table it prints `{"type":"neighbors_request","id":N}` and waits up to 2 seconds for one line on stdin. `EngineRun` answers from the stdout handler (off diff --git a/CHANGELOG.md b/CHANGELOG.md index ee23fcc..f94542b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,4 +21,4 @@ First version of the native macOS app: table however the app is signed: macOS 27 hides that table from the engine but not from the app, so the app reads it and hands it to the engine (`--neighbor-table=parent`). -- Bundles NetScanner v1.6.0 as a universal binary. +- Bundles NetScanner v1.7.0 as a universal binary. diff --git a/Config/Version.xcconfig b/Config/Version.xcconfig index b87d54e..ed2d34c 100644 --- a/Config/Version.xcconfig +++ b/Config/Version.xcconfig @@ -9,6 +9,6 @@ CURRENT_PROJECT_VERSION = 1 // The bundled ns engine: its NetScanner release tag, and the SHA-256 of // that release's macos-arm64.zip and macos-x86_64.zip. fetch-engine.sh // refuses a download that does not match. -NETSCANNER_ENGINE_VERSION = v1.6.0 -NETSCANNER_ENGINE_SHA256_ARM64 = a74a68164be864834c254f83ea80db47dd7b7e9e60e385bc3095804f8164c893 -NETSCANNER_ENGINE_SHA256_X86_64 = 400e27a3a6d1230ae586b5831880e35336cdd63aec74dfca02448aa3c52d2a57 +NETSCANNER_ENGINE_VERSION = v1.7.0 +NETSCANNER_ENGINE_SHA256_ARM64 = b7209515de3bf364fb6b6d0b76ea17ddee21440e33ba0a537bb6b7b107d8e744 +NETSCANNER_ENGINE_SHA256_X86_64 = 7b05effed2f091a50b6c0592b7589af6a9816ec9673242dc49bc0fcc9d5f27f6