Files
reyna-cli/native/ReynaCLIHost/Sources/ReynaCLIHostCore/SocketServer.swift
T
Adolfo Reyna 9fd04b0ce4 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.
2026-08-03 20:27:54 -04:00

346 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}