212 lines
9.6 KiB
Swift
212 lines
9.6 KiB
Swift
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)
|
|
}
|
|
}
|