Any network scanner eventually runs into the same problem: the things it discovers are not all the same kind of thing, but the panel has one column for them.
This tutorial is about writing the layer that fixes that — in Swift, with tests, in a way you can drop into your own scanner.
What You Will Build
A DiscoveryCleanup module that:
- distinguishes an address, a name, an identifier, and a protocol artefact
- refuses to show an address as a hostname
- recognises hardware identifiers wearing a name’s clothes
- sanitises advertised names (DNS-SD, NetBIOS) instead of discarding them
- survives link-local IPv6 and DHCP underscores
- comes with a test target that catches regressions
By the end, your scanner will report devices rather than debris.
The Core Idea: A Name Is a Claim
The single most useful reframe in this area:
An address is a fact. A name is a claim. A blob is a protocol artefact. A device id is an identifier.
Your scanner is a fact-collector. Almost everything it touches is a fact — a MAC, an IP, a port, a service. Names are the odd one out: they are assertions made by a device about itself, and devices lie, guess, borrow other devices’ names, and sometimes emit raw hardware identifiers in their place.
So the cleanup layer’s job is to grade assertions, not to collect data.
Step 1: Address Recognition
Start with the simplest test. Use inet_pton — it is the system parser, it is correct, and it handles the edge cases (leading zeros, IPv4-mapped IPv6) that a hand-rolled split-and-check always gets wrong:
import Foundation
import Darwin
enum DiscoveryCleanup {
/// True when the string is a valid IPv4 address.
static func isIPv4(_ value: String) -> Bool {
var addr = in_addr()
return inet_pton(AF_INET, value, &addr) == 1
}
/// True when the string is a valid IPv6 address.
static func isIPv6(_ value: String) -> Bool {
var addr = in6_addr()
return inet_pton(AF_INET6, value, &addr) == 1
}
}
Note the asymmetry with Swift’s own IPv4Address type: inet_pton rejects octal and shorthand forms, which is what you want. 127.1 is not an address anyone on your LAN is using.
Usable vs. Valid
Valid is not the same as usable. Some addresses belong to the network, not to a device:
/// True when the IPv4 address could belong to a host on the LAN.
static func isUsableIPv4(_ address: String) -> Bool {
guard isIPv4(address) else { return false }
let first = UInt32(address.split(separator: ".").first ?? "") ?? 0
if first == 0 { return false } // 0.0.0.0 — this network
if first == 127 { return false } // loopback
if first >= 224 { return false } // multicast, reserved, broadcast
return true
}
That last check — first >= 224 — is what removes the limited broadcast address (255.255.255.255) that the routing table cheerfully lists beside every real route, and the multicast range that the IPv6 neighbour probe answers from.
Now the counter-intuitive one:
/// True when the IPv6 address could belong to a host.
///
/// The unspecified address and loopback never identify a device.
/// A link-local address does — it is simply bound to the
/// interface that saw it — so it survives here.
static func isUsableIPv6(_ address: String) -> Bool {
guard isIPv6(address) else { return false }
let lower = address.lowercased()
return lower != "::" && lower != "::1"
}
Link-local IPv6 survives on purpose. fe80::… is scoped to the link that saw it, and that is exactly why it identifies a host. If you reject it for being “not globally routable”, you discard real devices — often the only address some of them have. Reject only :: (unspecified) and ::1 (loopback).
Step 2: The Two Standards
Here is the design decision that took me the longest, and it is the one most worth stealing.
You have two kinds of name, and they need opposite treatment:
| Authority name | Advertised name | |
|---|---|---|
| Examples | PTR record, DHCP lease, certificate subject | DNS-SD instance, NetBIOS label |
| Intended to be a hostname? | Yes | No |
| Treatment | Strict — accept or discard | Lenient — sanitise and show |
| May contain odd characters? | No | Yes, and often does |
The temptation is to use one filter for both. It cannot work. A DNS-SD instance name is supposed to be weird, and a PTR record is supposed to be a hostname, so they need opposite thresholds.
The strict one: isPlausibleHostname
/// Characters that cannot appear in a DNS name.
///
/// A DNS-SD instance name may contain almost anything, so this
/// list is not a "is it a legal name" check — it is the set of
/// characters that mark a string as *something else entirely*:
/// a URL, an IPv6 literal, a NetBIOS label separator, a
/// Windows path.
private static let illegalInName: Set<Character> = [
":", "/", "\\", "@", "\"", "'", ",", ";", "<", ">", "[", "]",
"{", "}", "|", "=", "?", "*", "%", "$", "&", "!", "#", "~",
"`", "^", " ", "\t",
]
static func isPlausibleHostname(_ value: String) -> Bool {
let name = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, name.count <= 253 else { return false }
// Control characters never belong in a name. The NetBIOS
// label separator (\x1a) is the common one.
guard name.unicodeScalars.allSatisfy({ $0.value >= 0x20 && $0.value != 0x7F })
else { return false }
// A name is not an address.
guard !isIPv4(name), !isIPv6(name) else { return false }
guard !name.contains(where: { illegalInName.contains($0) }) else { return false }
// Every dot-separated label must be a sane label.
for label in name.split(separator: ".", omittingEmptySubsequences: false) {
guard !label.isEmpty, label.count <= 63 else { return false }
guard label.first != "-", label.last != "-" else { return false }
}
return true
}
Three details worth calling out:
- The control-character check comes before the illegal-character check, because
\x1ais not in your illegal set — it is a control character, and it needs its own rule. (The NetBIOS separator is handled separately anyway; see below.) isIPv4(name)/isIPv6(name)— this is the check that kills the resolver that answers with the address itself. The panel already shows the address, so showing it twice is noise.omittingEmptySubsequences: false— sobad..labelis caught. The defaulttruewould silently drop the empty label and let it through.
The identifier check: looksLikeIdentifier
Some devices advertise their hardware address, or a hash of it, instead of a name. Real examples from the wire: 0508CDC004AA3082 and 0508CDC004AA3082-FFFFFFEFFFFFFFFF.
/// True when the name is really a device identifier.
static func looksLikeIdentifier(_ value: String) -> Bool {
// Split on both separators: a device id is often a MAC, or a
// MAC plus a suffix ("0508CDC004AA3082-FFFFFFEFFFFFFFFF").
let components = value.split(whereSeparator: { $0 == "." || $0 == "-" })
for component in components {
let text = String(component)
guard text.count >= 12 else { continue }
guard text.allSatisfy({ $0.isHexDigit }) else { continue }
return true
}
return false
}
The length threshold of 12 is deliberate. Short hex strings are legitimately part of names (cafe, beef, dead), but 12 or more hex characters is a MAC or a hash, not a word.
Split on both . and -. This is the bug I nearly shipped. The MAC-plus-suffix form is two hex components separated by a hyphen; splitting only on . would have left it as one long blob and passed the allSatisfy check anyway, but it would also have missed aabb.ccdd.eeff written in the other common MAC notation. Splitting on both catches all of them.
Step 3: The Lenient Side — Advertised Names
DNS-SD
A DNS-SD instance name is a blob:
amzn.dmgr:11FC5C0EC6A28FFEDAA52A8ACE6F4E93:xnVDMM+4u1:558619
The human-recognisable part is the first dot-separated label — amzn in this case. Everything after it is decoration.
/// The human-recognisable part of a DNS-SD instance name.
static func dnsSDLabel(_ value: String) -> String {
value.split(separator: ".").first.map(String.init) ?? value
}
NetBIOS
A NetBIOS name is MAC@NAME, where the name half uses \x1a as its word separator:
327EFEE148D8@ANNMARIE␚MACBOOK␚PRO
The separator stands in for a space, not a dot:
/// The name half of a NetBIOS label, with the \x1a separator
/// rendered as a space.
static func netbiosLabel(_ value: String?) -> String? {
guard let value else { return nil }
guard let separator = value.firstIndex(of: "@") else { return nil }
let name = String(value[value.index(after: separator)...])
.replacingOccurrences(of: "\u{1A}", with: " ")
.trimmingCharacters(in: .whitespaces)
return name.isEmpty ? nil : name
}
Why a space and not a dot? A dot would split the label into separate DNS labels, and the first one — ANNMARIE — is not the name anybody recognises. ANNMARIE MACBOOK is.
And the address half is dropped entirely, because the MAC is already in its own column:
327EFEE148D8@PRINTER → PRINTER
327EFEE148D8@ → nil
The gate every advertised name passes
/// The display name for an advertised label, if it deserves one.
static func advertisedName(_ value: String?) -> String? {
guard let value else { return nil }
let cleaned = readableLabel(value)
guard !cleaned.isEmpty else { return nil }
guard !looksLikeIdentifier(cleaned) else { return nil }
return cleaned
}
/// A string with its control characters rendered as spaces.
static func readableLabel(_ value: String) -> String {
String(value.map { $0.unicodeScalars.contains(where: { $0.value < 0x20 })
? " " : Character($0) }.joined())
}
advertisedName applies the identifier check one final time. A device id is not a name, and showing 327EFEE148D8 in the hostname column is worse than showing nothing at all.
Step 4: The Entry Point
Tie it together with a function the UI can call directly:
/// The hostname a row should carry, or `nil` when the candidate
/// is not a name.
static func sanitizeHostname(_ value: String?) -> String? {
guard let value else { return nil }
let name = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard isPlausibleHostname(name) else { return nil }
guard !looksLikeIdentifier(name) else { return nil }
return name
}
/// A best-effort name for any discovery technique.
static func hostname(from value: String) -> String? {
if let authority = sanitizeHostname(value) { return authority }
if let dnsSD = advertisedName(dnsSDLabel(value)) { return dnsSD }
return nil
}
Order matters: try the strict path first, fall back to the lenient one.
Step 5: Enforce It In The Store, Not Just The View
This is the step that makes the fix real.
Filtering the output is the visible half. The half that actually matters is the persistent inventory, because that is where garbage becomes permanent:
- Run 1 discovers a hostname of
0508CDC004AA3082. - It is filtered out of the panel — good.
- It is written to the store — bad.
- Run 2 reads the store back, sees a row that “already passed” at some point, and shows it.
The store is the enforcement point, because it is what makes yesterday’s mistake look like today’s fact. Your store’s write path must call the same sanitizeHostname / advertisedName functions the view does.
A rule enforced in eleven places is a rule enforced in none of them. Put it in one module and call it from every producer and every persistence layer.
Step 6: Write The Tests
The rules above are the kind of thing that rots silently. Someone refactors a scanner, loosens one check, and garbage starts appearing in the inventory a month later with no error anywhere.
So: a real test target.
import XCTest
@testable import PortieForcie
final class DiscoveryCleanupTests: XCTestCase {
func testValidIPv4AndIPv6AreRecognised() {
XCTAssertTrue(DiscoveryCleanup.isIPv4("192.168.1.254"))
XCTAssertTrue(DiscoveryCleanup.isIPv6("fe80::1c2b:3d4e:5f60:7a8b"))
XCTAssertFalse(DiscoveryCleanup.isIPv4("192.168.1.256"))
XCTAssertFalse(DiscoveryCleanup.isIPv4("192.168.1"))
}
func testNetworkAddressesAreNotUsable() {
XCTAssertFalse(DiscoveryCleanup.isUsableIPv4("0.0.0.0"))
XCTAssertFalse(DiscoveryCleanup.isUsableIPv4("127.0.0.1"))
XCTAssertFalse(DiscoveryCleanup.isUsableIPv4("255.255.255.255"))
XCTAssertTrue(DiscoveryCleanup.isUsableIPv4("192.168.1.254"))
}
func testLinkLocalIPv6Survives() {
XCTAssertTrue(DiscoveryCleanup.isUsableIPv6("fe80::1c2b:3d4e:5f60:7a8b"))
XCTAssertFalse(DiscoveryCleanup.isUsableIPv6("::"))
XCTAssertFalse(DiscoveryCleanup.isUsableIPv6("::1"))
}
func testAddressesAreNeverHostnames() {
XCTAssertNil(DiscoveryCleanup.sanitizeHostname("192.168.1.254"))
XCTAssertNil(DiscoveryCleanup.sanitizeHostname("fe80::1c2b:3d4e:5f60:7a8b"))
}
func testHardwareIdentifiersAreRejected() {
XCTAssertNil(DiscoveryCleanup.sanitizeHostname("0508CDC004AA3082"))
XCTAssertNil(DiscoveryCleanup.sanitizeHostname("0508CDC004AA3082-FFFFFFEFFFFFFFFF"))
XCTAssertTrue(DiscoveryCleanup.looksLikeIdentifier("327EFEE148D8"))
}
func testDNSSDInstanceNamesReduceToFirstLabel() {
let blob = "amzn.dmgr:11FC5C0EC6A28FFEDAA52A8ACE6F4E93:xnVDMM+4u1:558619"
XCTAssertEqual(DiscoveryCleanup.dnsSDLabel(blob), "amzn")
XCTAssertEqual(DiscoveryCleanup.hostname(from: blob), "amzn")
}
func testNetBIOSSeparatorBecomesASpace() {
XCTAssertEqual(DiscoveryCleanup.netbiosLabel("327EFEE148D8@ANNMARIE\u{1A}MACBOOK"),
"ANNMARIE MACBOOK")
}
func testUnderscoresInLeaseNamesAreAllowed() {
XCTAssertEqual(DiscoveryCleanup.sanitizeHostname("MYDEVICE_01"), "MYDEVICE_01")
}
}
Add the target
.testTarget(
name: "YourAppTests",
dependencies: ["YourApp"],
path: "Tests/YourAppTests"
)
Note dependencies: ["YourApp"] — and remember that an executable target’s types cannot be imported by another target. If your app is an executable (a GUI app is), you cannot @testable import it directly.
The fix is to move the code you want to test into a small library target and depend on that from both the app and the tests:
.target(
name: "YourAppCore",
path: "Sources/YourAppCore"
),
.executableTarget(
name: "YourApp",
dependencies: ["YourAppCore"],
path: "Sources/YourApp"
),
.testTarget(
name: "YourAppTests",
dependencies: ["YourAppCore"],
path: "Tests/YourAppTests"
)
This is the right shape anyway: DiscoveryCleanup is pure logic with no UI dependency, and a library target says so.
Step 7: Verify Against Reality
Now the part most tutorials skip.
Do not verify by counting devices. A shrinking count is exactly what over-aggressive cleanup also looks like. If your filter is too strict, the test looks identical to success: fewer rows.
Verify two things separately:
The count. Every real host the sweep found must still be in the output. The right test is not “fewer” — it is “the same real hosts, minus the debris”. Run your scanner against a known-good reference build and diff the two lists, host by host.
The rules. The test target above, run on every change:
swift test
Common Mistakes
A short list of things that will bite you:
| Mistake | Why it hurts |
|---|---|
| Rejecting link-local IPv6 | Discards real devices — often their only address |
| Rejecting underscores | Kills DHCP lease names like MYDEVICE_01 |
Splitting on . only |
Misses MAC-SUFFIX identifiers |
Using omittingEmptySubsequences: true |
bad..label slips through |
| Only filtering the view | The store re-injects the garbage on the next run |
| Checking “fewer devices” as success | Over-aggressive filtering looks identical |
| One filter for names and labels | Impossible — they need opposite thresholds |
The Takeaway
The general principle underneath all of this:
A tool that produces plausible output is more dangerous than a tool that produces no output, because plausible output is trusted.
Your scanner’s job is to collect facts. Names are claims, and claims need grading. Put the grading in one module, call it from every producer and every persistence layer, and give it a test target so it cannot rot.
Your panel should show devices, not debris.
Related: Teaching a Network Scanner to Stop Lying — the story behind these rules, and the two tests that failed correctly.
Comments
Leave a message below. Your comment saves to your browser.