This commit is contained in:
@@ -50,6 +50,12 @@ public enum LocalNetworkPolicy {
|
||||
/// The domain is written with `sudo`, so which preferences directory it
|
||||
/// lands in depends on whether that `sudo` preserved `HOME`. Rather than
|
||||
/// guess at the host's sudoers configuration, check each candidate.
|
||||
///
|
||||
/// In practice the first candidate is where it lands, and `/var/root` is
|
||||
/// mode 700 — so an unprivileged process cannot read back what it just
|
||||
/// wrote. That is what ``Status/unreadablePaths`` exists to report, and
|
||||
/// what `RunnerHost`'s `LocalNetworkPermission.observedStatus()` works
|
||||
/// around by re-reading as root.
|
||||
public static func preferenceCandidates() -> [String] {
|
||||
[
|
||||
"/var/root/Library/Preferences/\(domain).plist",
|
||||
@@ -112,47 +118,103 @@ public enum LocalNetworkPolicy {
|
||||
/// Whether at least one entry covers the whole vmnet range.
|
||||
public let coversGuestRange: Bool
|
||||
|
||||
/// Candidate files this process was refused permission to read.
|
||||
///
|
||||
/// Not the same as "absent". `sudo defaults write` normally lands in
|
||||
/// `/var/root/Library/Preferences`, which is mode 700, so an ordinary
|
||||
/// user is refused before it can find out whether the file is even
|
||||
/// there. An empty ``allowlist`` with a non-empty `unreadablePaths`
|
||||
/// means *unknown*, not *unconfigured*, and must not be reported as
|
||||
/// the latter.
|
||||
public let unreadablePaths: [String]
|
||||
|
||||
/// Whether anything is configured at all.
|
||||
public var isConfigured: Bool { !allowlist.isEmpty }
|
||||
|
||||
public init(allowlist: [String], sourcePaths: [String], coversGuestRange: Bool) {
|
||||
/// Whether nothing was found and something could not be read, so the
|
||||
/// answer is genuinely unknown without administrator rights.
|
||||
public var isIndeterminate: Bool { allowlist.isEmpty && !unreadablePaths.isEmpty }
|
||||
|
||||
public init(
|
||||
allowlist: [String],
|
||||
sourcePaths: [String],
|
||||
coversGuestRange: Bool,
|
||||
unreadablePaths: [String] = []
|
||||
) {
|
||||
self.allowlist = allowlist
|
||||
self.sourcePaths = sourcePaths
|
||||
self.coversGuestRange = coversGuestRange
|
||||
self.unreadablePaths = unreadablePaths
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the host's current allowlist.
|
||||
/// Reads the host's current allowlist with this process's own privileges.
|
||||
///
|
||||
/// Best effort and never fatal: an unreadable or absent preferences file
|
||||
/// simply reads as "no allowlist".
|
||||
/// Best effort and never fatal: an absent preferences file reads as "no
|
||||
/// allowlist", and one that exists but cannot be opened is recorded in
|
||||
/// ``Status/unreadablePaths`` rather than being mistaken for absent.
|
||||
public static func status() -> Status {
|
||||
var found: [String] = []
|
||||
var sources: [String] = []
|
||||
var sources: [(path: String, data: Data)] = []
|
||||
var unreadable: [String] = []
|
||||
|
||||
for path in preferenceCandidates() {
|
||||
guard let data = FileManager.default.contents(atPath: path),
|
||||
let plist = try? PropertyListSerialization.propertyList(
|
||||
from: data, options: [], format: nil) as? [String: Any]
|
||||
else { continue }
|
||||
|
||||
var contributed = false
|
||||
for key in keys {
|
||||
for entry in (plist[key] as? [String] ?? []) where !found.contains(entry) {
|
||||
found.append(entry)
|
||||
contributed = true
|
||||
}
|
||||
if let data = FileManager.default.contents(atPath: path) {
|
||||
sources.append((path, data))
|
||||
} else if access(path, R_OK) != 0, errno == EACCES {
|
||||
// Refused, not missing — including when the refusal is on a
|
||||
// parent directory, which is exactly the /var/root case.
|
||||
unreadable.append(path)
|
||||
}
|
||||
if contributed { sources.append(path) }
|
||||
}
|
||||
|
||||
return status(fromContentsOf: sources, unreadablePaths: unreadable)
|
||||
}
|
||||
|
||||
/// Builds a ``Status`` from preferences files already read, however they
|
||||
/// were obtained.
|
||||
///
|
||||
/// Split out from ``status()`` so the privileged read-back in `RunnerHost`
|
||||
/// — which has to shell out to `sudo` to see root's copy — shares this
|
||||
/// parsing rather than reimplementing it.
|
||||
public static func status(
|
||||
fromContentsOf sources: [(path: String, data: Data)],
|
||||
unreadablePaths: [String] = []
|
||||
) -> Status {
|
||||
var found: [String] = []
|
||||
var paths: [String] = []
|
||||
|
||||
for source in sources {
|
||||
let fresh = entries(inPreferences: source.data).filter { !found.contains($0) }
|
||||
guard !fresh.isEmpty else { continue }
|
||||
found.append(contentsOf: fresh)
|
||||
paths.append(source.path)
|
||||
}
|
||||
|
||||
return Status(
|
||||
allowlist: found,
|
||||
sourcePaths: sources,
|
||||
coversGuestRange: found.contains(where: coversVMNetRange)
|
||||
sourcePaths: paths,
|
||||
coversGuestRange: found.contains(where: coversVMNetRange),
|
||||
unreadablePaths: unreadablePaths
|
||||
)
|
||||
}
|
||||
|
||||
/// Every allowlist entry in one preferences file, across both keys, in the
|
||||
/// order encountered and without duplicates. Unparseable data reads empty.
|
||||
public static func entries(inPreferences data: Data) -> [String] {
|
||||
guard
|
||||
let plist = try? PropertyListSerialization.propertyList(
|
||||
from: data, options: [], format: nil) as? [String: Any]
|
||||
else { return [] }
|
||||
|
||||
var found: [String] = []
|
||||
for key in keys {
|
||||
for entry in (plist[key] as? [String] ?? []) where !found.contains(entry) {
|
||||
found.append(entry)
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/// Subnets pre-authorized for local network access on this host, if any.
|
||||
public static func allowlist() -> [String] { status().allowlist }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user