feat(privacy-host): add signed native calendar contacts and reminders host
Add the owner-only AF_UNIX Reyna CLI privacy host, strict signed-app installation, and typed native routing for Calendar, Contacts, and Reminders.\n\nAdd bounded system-status paths and config-only direct local-service wrappers. Preserve MacMiniMCP pending explicit cutover approval.\n\nApple Notes is intentionally deferred: no native Notes operations, Apple Events declaration, or Automation helper are included; legacy Notes handling remains untouched.
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import CSignalSupport
|
||||
|
||||
// Pure decision extracted for unit testing.
|
||||
func isPeerAuthorized(peerUID: uid_t, currentUID: uid_t) -> Bool {
|
||||
return peerUID == currentUID
|
||||
}
|
||||
|
||||
// MARK: - Path validation
|
||||
// Trust boundary: see SocketPathValidation.swift for full documentation.
|
||||
// Tiered model:
|
||||
// 1) platform allowlist root-owned (uid 0, no g/o write except tmp)
|
||||
// 2) user-owned intermediates: uid current, no g/o *write* (mode & 022 == 0) -> allows 0700/0750/0755, rejects 0770/0777
|
||||
// 3) dedicated runtime (immediate socket parent): uid current, mode & 077 == 0 -> requires 0700 family only.
|
||||
// Socket itself 0600.
|
||||
|
||||
// MARK: - Reused single-component validator (authoritative)
|
||||
// NOTE: this is the ONLY place allowed to decide if an existing component is safe.
|
||||
// It must stay in sync with validateParentChainPureResultProvider logic.
|
||||
func validateExistingComponentLiveOrThrow(path: String, info: LStatInfo, currentUID: uid_t, isDedicatedRuntimeParent: Bool = false) throws {
|
||||
// Centralized call to shared validation in SocketPathValidation
|
||||
try validateSingleLStatInfoOrThrow(path: path, info: info, currentUID: currentUID, isDedicatedRuntimeParent: isDedicatedRuntimeParent)
|
||||
}
|
||||
|
||||
func ensureParentDirectories(for socketPath: String) throws {
|
||||
// Authoritative validation reused; fail-closed on lstat errors
|
||||
try rejectIfDotComponentsPure(in: socketPath)
|
||||
try validateExistingParentChainLive(for: socketPath)
|
||||
|
||||
let fm = FileManager.default
|
||||
let url = URL(fileURLWithPath: socketPath)
|
||||
let parent = url.deletingLastPathComponent()
|
||||
let parentPath = parent.path
|
||||
if parentPath.isEmpty { return }
|
||||
|
||||
let comps = parent.pathComponents
|
||||
var cur = ""
|
||||
for comp in comps {
|
||||
if comp == "/" {
|
||||
cur = "/"
|
||||
continue
|
||||
}
|
||||
if cur == "/" {
|
||||
cur = "/" + comp
|
||||
} else if cur.isEmpty {
|
||||
cur = comp
|
||||
} else {
|
||||
cur = cur + "/" + comp
|
||||
}
|
||||
|
||||
switch liveLStatResultProvider(path: cur) {
|
||||
case .absent:
|
||||
// Create missing component with 0700 – privacy preserving. Even intermediates now get 0700.
|
||||
do {
|
||||
try fm.createDirectory(atPath: cur, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700])
|
||||
chmod(cur, 0o700)
|
||||
} catch {
|
||||
// mkdir race: re-lstat and revalidate rather than assuming missing
|
||||
switch liveLStatResultProvider(path: cur) {
|
||||
case .absent:
|
||||
throw error
|
||||
case .failed(let ec):
|
||||
throw NSError(domain: "SocketServer", code: 22, userInfo: [NSLocalizedDescriptionKey: "lstat failed for \(cur): \(String(cString: strerror(ec)))"])
|
||||
case .present(let info):
|
||||
do {
|
||||
let isDedicated = (cur == parentPath)
|
||||
try validateSingleLStatInfoOrThrow(path: cur, info: info, currentUID: getuid(), isDedicatedRuntimeParent: isDedicated)
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
case .failed(let ec):
|
||||
throw NSError(domain: "SocketServer", code: 22, userInfo: [NSLocalizedDescriptionKey: "lstat failed for \(cur): \(String(cString: strerror(ec)))"])
|
||||
case .present(let info):
|
||||
// Reuse authoritative single-component validator (no duplicated policy)
|
||||
let isDedicated = (cur == parentPath)
|
||||
try validateSingleLStatInfoOrThrow(path: cur, info: info, currentUID: getuid(), isDedicatedRuntimeParent: isDedicated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func safeUnlinkIfStaleSocket(at path: String) throws {
|
||||
var st = stat()
|
||||
let r = lstat(path, &st)
|
||||
if r != 0 {
|
||||
if errno == ENOENT { return }
|
||||
throw NSError(domain: "SocketServer", code: 11, userInfo: [NSLocalizedDescriptionKey: "lstat failed for \(path): \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
let isSock = (st.st_mode & S_IFMT) == S_IFSOCK
|
||||
if !isSock {
|
||||
throw NSError(domain: "SocketServer", code: 12, userInfo: [NSLocalizedDescriptionKey: "Refusing to unlink: \(path) exists and is not a socket"])
|
||||
}
|
||||
if st.st_uid != getuid() {
|
||||
throw NSError(domain: "SocketServer", code: 13, userInfo: [NSLocalizedDescriptionKey: "Refusing to unlink: socket at \(path) not owned by current uid"])
|
||||
}
|
||||
if unlink(path) != 0 && errno != ENOENT {
|
||||
throw NSError(domain: "SocketServer", code: 14, userInfo: [NSLocalizedDescriptionKey: "Failed to unlink stale socket \(path): \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
}
|
||||
|
||||
private let kMaxRequestBytes = 64 * 1024
|
||||
private let kClientRecvTimeoutSec = 5
|
||||
|
||||
private func makeErrorResponse(id: String, code: String, message: String) -> Data? {
|
||||
let err = ErrorPayload(code: code, message: message)
|
||||
let resp = Response(id: id, ok: false, result: nil, error: err)
|
||||
guard let json = try? JSONEncoder().encode(resp),
|
||||
let str = String(data: json, encoding: .utf8) else { return nil }
|
||||
return (str + "\n").data(using: .utf8)
|
||||
}
|
||||
|
||||
private func processRequestData(_ data: Data) -> Data? {
|
||||
if data.isEmpty { return nil }
|
||||
if let s = String(data: data, encoding: .utf8),
|
||||
s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return nil
|
||||
}
|
||||
if let req = try? JSONDecoder().decode(Request.self, from: data) {
|
||||
let resp = dispatch(request: req)
|
||||
guard let json = try? JSONEncoder().encode(resp),
|
||||
let str = String(data: json, encoding: .utf8) else { return nil }
|
||||
return (str + "\n").data(using: .utf8)
|
||||
} else {
|
||||
let recovered = extractRecoverableId(from: data) ?? ""
|
||||
return makeErrorResponse(id: recovered, code: "invalid_request", message: "Invalid request JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func runSocketServer(socketPath: String) -> Never {
|
||||
if !socketPath.hasPrefix("/") {
|
||||
fputs("error: --socket path must be absolute: \(socketPath)\n", stderr)
|
||||
Darwin.exit(2)
|
||||
}
|
||||
if socketPath.utf8.count >= 104 {
|
||||
fputs("error: --socket path too long\n", stderr)
|
||||
Darwin.exit(2)
|
||||
}
|
||||
|
||||
do {
|
||||
try ensureParentDirectories(for: socketPath)
|
||||
try safeUnlinkIfStaleSocket(at: socketPath)
|
||||
} catch {
|
||||
fputs("error: \(error.localizedDescription)\n", stderr)
|
||||
Darwin.exit(3)
|
||||
}
|
||||
|
||||
// Store for signal cleanup in C
|
||||
socketPath.withCString { cStr in
|
||||
reyna_store_socket_path(cStr)
|
||||
}
|
||||
reyna_install_signal_handlers()
|
||||
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
if fd < 0 {
|
||||
fputs("error: socket() failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
Darwin.exit(4)
|
||||
}
|
||||
_ = fcntl(fd, F_SETFD, FD_CLOEXEC)
|
||||
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
|
||||
_ = socketPath.withCString { cStr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { dst in
|
||||
dst.withMemoryRebound(to: CChar.self, capacity: 104) { p in
|
||||
strncpy(p, cStr, 103)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let oldMask = umask(0o077)
|
||||
let bindRes = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in
|
||||
bind(fd, saddr, socklen_t(MemoryLayout<sockaddr_un>.size))
|
||||
}
|
||||
}
|
||||
umask(oldMask)
|
||||
|
||||
if bindRes != 0 {
|
||||
fputs("error: bind() \(socketPath): \(String(cString: strerror(errno)))\n", stderr)
|
||||
close(fd)
|
||||
Darwin.exit(5)
|
||||
}
|
||||
|
||||
if chmod(socketPath, 0o600) != 0 {
|
||||
fputs("warning: chmod 0600 failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
}
|
||||
|
||||
if listen(fd, 32) != 0 {
|
||||
fputs("error: listen() failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
close(fd)
|
||||
reyna_cleanup_socket_sync()
|
||||
Darwin.exit(6)
|
||||
}
|
||||
|
||||
// Main loop: one client at a time, one request per connection
|
||||
while true {
|
||||
let cfd = accept(fd, nil, nil)
|
||||
if cfd < 0 {
|
||||
if errno == EINTR { continue }
|
||||
fputs("error: accept() failed: \(String(cString: strerror(errno)))\n", stderr)
|
||||
break
|
||||
}
|
||||
|
||||
// --- Peer credential check (macOS getpeereid) ---
|
||||
var peerEuid: uid_t = 0
|
||||
var peerEgid: gid_t = 0
|
||||
if getpeereid(cfd, &peerEuid, &peerEgid) != 0 {
|
||||
// If we cannot obtain peer credentials, reject
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
if !isPeerAuthorized(peerUID: peerEuid, currentUID: getuid()) {
|
||||
if let d = makeErrorResponse(id: "", code: "unauthorized", message: "Peer UID not authorized") {
|
||||
_ = d.withUnsafeBytes { p in send(cfd, p.baseAddress!, p.count, 0) }
|
||||
}
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set receive timeout to bound slow clients
|
||||
var tv = timeval()
|
||||
tv.tv_sec = kClientRecvTimeoutSec
|
||||
tv.tv_usec = 0
|
||||
setsockopt(cfd, SOL_SOCKET, SO_RCVTIMEO, &tv, socklen_t(MemoryLayout<timeval>.size))
|
||||
|
||||
var buf = Data()
|
||||
buf.reserveCapacity(8192)
|
||||
var tmp = [UInt8](repeating: 0, count: 4096)
|
||||
var exceeded = false
|
||||
var gotAny = false
|
||||
var timedOut = false
|
||||
|
||||
// Poll-based timeout additionally enforced
|
||||
while true {
|
||||
// Wait for data with timeout
|
||||
var pfd = pollfd(fd: cfd, events: Int16(POLLIN), revents: 0)
|
||||
let pollTimeoutMs: Int32 = Int32(kClientRecvTimeoutSec * 1000)
|
||||
let pr = poll(&pfd, 1, pollTimeoutMs)
|
||||
if pr < 0 {
|
||||
if errno == EINTR { continue }
|
||||
break
|
||||
}
|
||||
if pr == 0 {
|
||||
// timeout
|
||||
timedOut = true
|
||||
break
|
||||
}
|
||||
let n = recv(cfd, &tmp, tmp.count, 0)
|
||||
if n < 0 {
|
||||
if errno == EINTR { continue }
|
||||
if errno == EWOULDBLOCK || errno == EAGAIN {
|
||||
timedOut = true
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
if n == 0 { break }
|
||||
gotAny = true
|
||||
|
||||
// Oversized handling with newline-in-same-chunk fix
|
||||
if buf.count + n > kMaxRequestBytes {
|
||||
// Look for newline in the new chunk
|
||||
var newlineIdx: Int? = nil
|
||||
for i in 0..<n {
|
||||
if tmp[i] == 0x0A {
|
||||
newlineIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if let nl = newlineIdx {
|
||||
// First line length = buf.count + nl (excluding newline char itself)
|
||||
let firstLineLen = buf.count + nl
|
||||
if firstLineLen <= kMaxRequestBytes {
|
||||
// Accept up to newline and ignore rest
|
||||
buf.append(contentsOf: tmp[0..<nl])
|
||||
// Break to process – we have a complete line within limit
|
||||
break
|
||||
} else {
|
||||
exceeded = true
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// No newline in this chunk and would exceed -> oversized
|
||||
exceeded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
buf.append(contentsOf: tmp[0..<n])
|
||||
if buf.contains(0x0A) { break }
|
||||
}
|
||||
|
||||
if timedOut {
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
if !gotAny && !exceeded {
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
if exceeded {
|
||||
if let d = makeErrorResponse(id: "", code: "payload_too_large", message: "Request exceeds 64KiB limit") {
|
||||
_ = d.withUnsafeBytes { p in send(cfd, p.baseAddress!, p.count, 0) }
|
||||
}
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
let lineData: Data
|
||||
if let idx = buf.firstIndex(of: 0x0A) {
|
||||
lineData = buf.prefix(upTo: idx)
|
||||
} else {
|
||||
lineData = buf
|
||||
}
|
||||
|
||||
if lineData.count > kMaxRequestBytes {
|
||||
if let d = makeErrorResponse(id: "", code: "payload_too_large", message: "Request exceeds 64KiB limit") {
|
||||
_ = d.withUnsafeBytes { p in send(cfd, p.baseAddress!, p.count, 0) }
|
||||
}
|
||||
close(cfd)
|
||||
continue
|
||||
}
|
||||
|
||||
if let resp = processRequestData(lineData) {
|
||||
_ = resp.withUnsafeBytes { p in
|
||||
var sent = 0
|
||||
while sent < resp.count {
|
||||
let n = send(cfd, p.baseAddress!.advanced(by: sent), resp.count - sent, 0)
|
||||
if n <= 0 { break }
|
||||
sent += n
|
||||
}
|
||||
}
|
||||
}
|
||||
close(cfd)
|
||||
}
|
||||
|
||||
close(fd)
|
||||
reyna_cleanup_socket_sync()
|
||||
Darwin.exit(0)
|
||||
}
|
||||
Reference in New Issue
Block a user