From e2042dedcdc3b0b9b168d19dd1da38a327cfc15f Mon Sep 17 00:00:00 2001 From: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:10:41 +0100 Subject: [PATCH] Treat an empty successful ARP cache as no address match (#1363) Signed-off-by: Sylvester Kaczmarek <16242628+sylvesterkaczmarek@users.noreply.github.com> --- .../tart/MACAddressResolver/ARPCache.swift | 15 ++-- Tests/TartTests/ARPCacheTests.swift | 71 +++++++++++++++++++ 2 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 Tests/TartTests/ARPCacheTests.swift diff --git a/Sources/tart/MACAddressResolver/ARPCache.swift b/Sources/tart/MACAddressResolver/ARPCache.swift index b2c983e..0b69c3c 100644 --- a/Sources/tart/MACAddressResolver/ARPCache.swift +++ b/Sources/tart/MACAddressResolver/ARPCache.swift @@ -41,9 +41,9 @@ struct ARPCacheInternalError: Error, CustomStringConvertible { struct ARPCache { let arpCommandOutput: Data - init() throws { + init(arpCommandURL: URL = URL(fileURLWithPath: "/usr/sbin/arp")) throws { let process = Process.init() - process.executableURL = URL.init(fileURLWithPath: "/usr/sbin/arp") + process.executableURL = arpCommandURL process.arguments = ["-an"] let pipe = Pipe() @@ -53,9 +53,7 @@ struct ARPCache { try process.run() - guard let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() else { - throw ARPCommandYieldedInvalidOutputError(explanation: "empty output") - } + let arpCommandOutput = try pipe.fileHandleForReading.readToEnd() ?? Data() process.waitUntilExit() @@ -69,9 +67,12 @@ struct ARPCache { } func ResolveMACAddress(macAddress: MACAddress) throws -> IPv4Address? { - let lines = String(decoding: arpCommandOutput, as: UTF8.self) + let output = String(decoding: arpCommandOutput, as: UTF8.self) .trimmingCharacters(in: .whitespacesAndNewlines) - .components(separatedBy: "\n") + if output.isEmpty { + return nil + } + let lines = output.components(separatedBy: "\n") // Based on https://opensource.apple.com/source/network_cmds/network_cmds-606.40.2/arp.tproj/arp.c.auto.html let regex = try NSRegularExpression(pattern: #"^.* \((?.*)\) at (?.*) on (?.*) .*$"#) diff --git a/Tests/TartTests/ARPCacheTests.swift b/Tests/TartTests/ARPCacheTests.swift new file mode 100644 index 0000000..0ad974f --- /dev/null +++ b/Tests/TartTests/ARPCacheTests.swift @@ -0,0 +1,71 @@ +import XCTest +import Foundation +import Network +@testable import tart + +final class ARPCacheTests: XCTestCase { + private let mac = MACAddress(fromString: "00:11:22:33:44:55")! + + private func cache(_ output: String, exitCode: Int = 0) throws -> ARPCache { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let command = directory.appendingPathComponent("arp-fixture") + let quoted = output.replacingOccurrences(of: "'", with: "'\"'\"'") + try "#!/bin/sh\nprintf '%s' '\(quoted)'\nexit \(exitCode)\n".write(to: command, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: command.path) + return try ARPCache(arpCommandURL: command) + } + + func testEmptySuccessfulOutputHasNoAddress() throws { + XCTAssertNil(try cache("").ResolveMACAddress(macAddress: mac)) + } + + func testWhitespaceSuccessfulOutputHasNoAddress() throws { + for output in ["\n", " \t\r\n", "\n\n"] { + XCTAssertNil(try cache(output).ResolveMACAddress(macAddress: mac)) + } + } + + func testEmptyOutputPreservesCommandFailure() throws { + XCTAssertThrowsError(try cache("", exitCode: 7)) { error in + XCTAssertEqual((error as? ARPCommandFailedError)?.terminationStatus, 7) + } + } + + func testNonemptyOutputPreservesCommandFailure() throws { + XCTAssertThrowsError(try cache("synthetic failure", exitCode: 9)) { error in + XCTAssertEqual((error as? ARPCommandFailedError)?.terminationStatus, 9) + } + } + + func testValidEntryResolvesAddress() throws { + let value = try cache("? (192.0.2.8) at 0:11:22:33:44:55 on en0 ifscope [ethernet]\n") + XCTAssertEqual(try value.ResolveMACAddress(macAddress: mac), IPv4Address("192.0.2.8")) + } + + func testIncompleteAndOtherEntriesDoNotMatch() throws { + let value = try cache(""" + ? (192.0.2.9) at (incomplete) on en0 ifscope [ethernet] + ? (192.0.2.10) at aa:bb:cc:dd:ee:ff on en0 ifscope [ethernet] + """) + XCTAssertNil(try value.ResolveMACAddress(macAddress: mac)) + } + + func testMalformedNonemptyOutputStillFails() throws { + XCTAssertThrowsError(try cache("synthetic invalid entry").ResolveMACAddress(macAddress: mac)) { + XCTAssertTrue($0 is ARPCommandYieldedInvalidOutputError) + } + } + + func testInvalidAddressesStillFail() throws { + for line in [ + "? (not-an-ip) at 0:11:22:33:44:55 on en0 ifscope [ethernet]", + "? (192.0.2.8) at not-a-mac on en0 ifscope [ethernet]", + ] { + XCTAssertThrowsError(try cache(line).ResolveMACAddress(macAddress: mac)) { + XCTAssertTrue($0 is ARPCommandYieldedInvalidOutputError) + } + } + } +}