import Foundation import Testing @testable import RunnerCore /// Tests for ``LocalNetworkPolicy`` — the arithmetic behind the Local Network /// subnet allowlist. /// /// The bug these exist for: an allowlist entry that covers *today's* guest /// subnet but not tomorrow's. vmnet picks its NAT subnet at runtime and steps /// to the next free /24 when one is taken, so `192.168.64.0/24` works right up /// until the day a second VM host appears on the machine — and then every guest /// connection fails with "No route to host" with the allowlist still looking /// perfectly configured. @Suite("LocalNetworkPolicy") struct LocalNetworkPolicyTests { // MARK: - Coverage @Test("an entry must cover the whole vmnet span, not just its first /24") func coverageIsAllOrNothing() { // The exact span, and anything wider. #expect(LocalNetworkPolicy.coversVMNetRange("192.168.64.0/18")) #expect(LocalNetworkPolicy.coversVMNetRange("192.168.0.0/16")) #expect(LocalNetworkPolicy.coversVMNetRange("192.168.0.0/8")) #expect(LocalNetworkPolicy.coversVMNetRange("0.0.0.0/0")) // The trap: contains 192.168.64.x, but not 192.168.65.x. #expect(!LocalNetworkPolicy.coversVMNetRange("192.168.64.0/24")) #expect(!LocalNetworkPolicy.coversVMNetRange("192.168.64.0/19")) // Adjacent but disjoint. #expect(!LocalNetworkPolicy.coversVMNetRange("192.168.128.0/18")) #expect(!LocalNetworkPolicy.coversVMNetRange("10.0.0.0/8")) } @Test("the RFC 1918 default covers the guest range") func defaultSubnetsCoverGuests() { #expect(LocalNetworkPolicy.defaultSubnets.contains(where: LocalNetworkPolicy.coversVMNetRange)) } @Test("a prefix is required for coverage; a bare address is a /32") func bareAddressIsASingleHost() { #expect(!LocalNetworkPolicy.coversVMNetRange("192.168.64.1")) #expect(!LocalNetworkPolicy.coversVMNetRange("192.168.64.1/32")) } @Test("host bits below the prefix do not change the block") func hostBitsAreMaskedOff() { // 192.168.70.5/18 and 192.168.64.0/18 are the same block. #expect(LocalNetworkPolicy.coversVMNetRange("192.168.70.5/18")) } // MARK: - Rejecting what macOS would silently ignore @Test("malformed, IPv6 and hostname entries are rejected, not crashed on") func garbageIsRejected() { for entry in [ "", "/", "/24", "192.168.64.0/", "192.168.64.0/33", "192.168.64.0/-1", "192.168.64", "192.168.64.0.1", "192.168.256.0/18", "192.168.64.x/18", "fd00::/8", "::/0", "localhost", "example.com/24", "192.168.64.0/18/24", ] { #expect(!LocalNetworkPolicy.isValidSubnet(entry), "\(entry) should be rejected") #expect(!LocalNetworkPolicy.coversVMNetRange(entry), "\(entry) should not cover") } } @Test("well-formed entries validate") func goodEntriesValidate() { for entry in ["0.0.0.0/0", "10.0.0.0/8", "192.168.64.0/24", "192.168.64.1", "255.255.255.255/32"] { #expect(LocalNetworkPolicy.isValidSubnet(entry), "\(entry) should validate") } } @Test("ipv4Value packs octets most-significant first") func addressPacking() { #expect(LocalNetworkPolicy.ipv4Value("0.0.0.0") == 0) #expect(LocalNetworkPolicy.ipv4Value("192.168.64.0") == 0xC0A8_4000) #expect(LocalNetworkPolicy.ipv4Value("192.168.127.255") == 0xC0A8_7FFF) #expect(LocalNetworkPolicy.ipv4Value("255.255.255.255") == 0xFFFF_FFFF) #expect(LocalNetworkPolicy.ipv4Value("192.168.64") == nil) #expect(LocalNetworkPolicy.ipv4Value("192.168.64.256") == nil) } // MARK: - What gets written @Test("both interface keys are written, each with the subnets as separate arguments") func writeArgumentsCoverBothKeys() { let subnets = ["10.0.0.0/8", "192.168.0.0/16"] let commands = LocalNetworkPolicy.writeArguments(subnets: subnets) #expect(commands.count == 2) #expect(commands[0] == ["write", LocalNetworkPolicy.domain, LocalNetworkPolicy.ethernetKey, "-array", "10.0.0.0/8", "192.168.0.0/16"]) #expect(commands[1] == ["write", LocalNetworkPolicy.domain, LocalNetworkPolicy.wifiKey, "-array", "10.0.0.0/8", "192.168.0.0/16"]) // Each subnet is its own argv element. Joined into one string, macOS // would read the whole thing as a single unparseable entry and grant // nothing — while `defaults read` still showed something plausible. for command in commands { #expect(!command.contains { $0.contains(" ") }) } } @Test("the shell rendering quotes anything a shell would reinterpret") func shellRenderingIsSafe() { let lines = LocalNetworkPolicy.writeCommandLines(subnets: ["10.0.0.0/8", "a b; rm -rf /"]) #expect(lines.count == 2) for line in lines { #expect(line.hasPrefix("sudo defaults write \(LocalNetworkPolicy.domain) ")) // Plain CIDR stays readable; the hostile entry gets quoted. #expect(line.contains(" 10.0.0.0/8 ")) #expect(line.contains("'a b; rm -rf /'")) } } // MARK: - Reading the host back @Test("status reads the live host without throwing and stays self-consistent") func statusIsSelfConsistent() { // Cannot assert the host's actual configuration — this suite runs on // developer machines and in CI guests alike. What must hold either way // is that the derived flags agree with the entries. let status = LocalNetworkPolicy.status() #expect(status.isConfigured == !status.allowlist.isEmpty) #expect(status.coversGuestRange == status.allowlist.contains(where: LocalNetworkPolicy.coversVMNetRange)) if status.allowlist.isEmpty { #expect(status.sourcePaths.isEmpty) } #expect(Set(status.allowlist).count == status.allowlist.count, "entries should be deduplicated") } @Test("all three candidate preference paths are checked") func candidatePathsCoverBothSudoOutcomes() { let candidates = LocalNetworkPolicy.preferenceCandidates() // `sudo defaults write` lands in root's preferences or the invoking // user's depending on whether sudo preserved HOME, so both must be // checked — plus the system-wide location. #expect(candidates.contains("/var/root/Library/Preferences/\(LocalNetworkPolicy.domain).plist")) #expect(candidates.contains("/Library/Preferences/\(LocalNetworkPolicy.domain).plist")) #expect(candidates.contains(NSHomeDirectory() + "/Library/Preferences/\(LocalNetworkPolicy.domain).plist")) } // MARK: - Parsing preferences /// A preferences file carrying `entries` under both allowlist keys. private func preferences(_ entries: [String]) throws -> Data { try PropertyListSerialization.data( fromPropertyList: [ LocalNetworkPolicy.ethernetKey: entries, LocalNetworkPolicy.wifiKey: entries, "UnrelatedKey": "ignored", ], format: .xml, options: 0) } @Test("both keys are read, and the same entry in both is not counted twice") func entriesAreUnionedAcrossKeys() throws { let data = try preferences(["10.0.0.0/8", "192.168.0.0/16"]) #expect(LocalNetworkPolicy.entries(inPreferences: data) == ["10.0.0.0/8", "192.168.0.0/16"]) } @Test("data that is not a preferences file reads as empty rather than throwing") func unparseablePreferencesReadEmpty() { #expect(LocalNetworkPolicy.entries(inPreferences: Data("not a plist".utf8)).isEmpty) #expect(LocalNetworkPolicy.entries(inPreferences: Data()).isEmpty) } @Test("only files that contribute an entry are named as sources") func sourcePathsNameOnlyContributingFiles() throws { let empty = try preferences([]) let real = try preferences(["192.168.0.0/16"]) // The same entries again: a second copy of a value already seen adds // nothing, so its path must not be reported as a source. let duplicate = try preferences(["192.168.0.0/16"]) let status = LocalNetworkPolicy.status(fromContentsOf: [ ("/first.plist", empty), ("/second.plist", real), ("/third.plist", duplicate), ]) #expect(status.allowlist == ["192.168.0.0/16"]) #expect(status.sourcePaths == ["/second.plist"]) #expect(status.coversGuestRange) } @Test("an unreadable candidate makes the answer unknown, not unconfigured") func unreadableCandidatesAreIndeterminate() throws { // The real case: `sudo defaults write` lands in /var/root, which is // mode 700, so an ordinary user is refused before it can learn whether // the file is even there. Reporting that as "no allowlist" is how a // successful grant gets called a failure. let blind = LocalNetworkPolicy.status( fromContentsOf: [], unreadablePaths: ["/var/root/Library/Preferences/x.plist"]) #expect(!blind.isConfigured) #expect(blind.isIndeterminate) // Nothing found and nothing refused really is unconfigured. let empty = LocalNetworkPolicy.status(fromContentsOf: []) #expect(!empty.isConfigured) #expect(!empty.isIndeterminate) // Something found outweighs a refusal elsewhere: the answer is known. let found = LocalNetworkPolicy.status( fromContentsOf: [("/a.plist", try preferences(["10.0.0.0/8"]))], unreadablePaths: ["/var/root/Library/Preferences/x.plist"]) #expect(found.isConfigured) #expect(!found.isIndeterminate) } }