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,373 @@
|
||||
import XCTest
|
||||
import Foundation
|
||||
|
||||
/// TDD tests for Unix-domain-socket server mode (--socket <path>).
|
||||
/// These start the compiled executable in a temp directory using real AF_UNIX sockets.
|
||||
final class SocketServerTests: XCTestCase {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
func hostExecutableURL() throws -> URL {
|
||||
let fm = FileManager.default
|
||||
// Portable candidates only: package-relative .build locations for common triples.
|
||||
let candidates: [String] = [
|
||||
".build/debug/ReynaCLIHost",
|
||||
".build/arm64-apple-macosx/debug/ReynaCLIHost",
|
||||
".build/x86_64-apple-macosx/debug/ReynaCLIHost",
|
||||
]
|
||||
let cwd = fm.currentDirectoryPath
|
||||
var tried: [String] = []
|
||||
for c in candidates {
|
||||
let url = URL(fileURLWithPath: cwd).appendingPathComponent(c)
|
||||
tried.append(url.path)
|
||||
if fm.isExecutableFile(atPath: url.path) { return url }
|
||||
}
|
||||
var parent = URL(fileURLWithPath: cwd)
|
||||
for _ in 0..<6 {
|
||||
let p1 = parent.appendingPathComponent(".build/debug/ReynaCLIHost")
|
||||
tried.append(p1.path)
|
||||
if fm.isExecutableFile(atPath: p1.path) { return p1 }
|
||||
let p2 = parent.appendingPathComponent(".build/arm64-apple-macosx/debug/ReynaCLIHost")
|
||||
tried.append(p2.path)
|
||||
if fm.isExecutableFile(atPath: p2.path) { return p2 }
|
||||
parent = parent.deletingLastPathComponent()
|
||||
}
|
||||
throw NSError(domain: "SocketServerTests", code: 1, userInfo: [NSLocalizedDescriptionKey: "ReynaCLIHost binary not found. Tried:\n" + tried.joined(separator: "\n")])
|
||||
}
|
||||
|
||||
/// Short unique /tmp path to stay under sockaddr_un.sun_path 104-byte limit.
|
||||
/// e.g. /tmp/rh-a1b2c3d4
|
||||
func makeShortUniqueDirChecked() throws -> URL {
|
||||
let fm = FileManager.default
|
||||
for _ in 0..<20 {
|
||||
let hex = String(format: "%08x", UInt32.random(in: 0...UInt32.max))
|
||||
let url = URL(fileURLWithPath: "/tmp/rh-\(hex)")
|
||||
if !fm.fileExists(atPath: url.path) {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return URL(fileURLWithPath: "/tmp/rh-\(String(format: "%08x", UInt32.random(in: 0...UInt32.max)))")
|
||||
}
|
||||
|
||||
final class HostProcess {
|
||||
let process: Process
|
||||
let socketPath: String
|
||||
let tempDir: URL
|
||||
init(process: Process, socketPath: String, tempDir: URL) {
|
||||
self.process = process
|
||||
self.socketPath = socketPath
|
||||
self.tempDir = tempDir
|
||||
}
|
||||
func terminate() {
|
||||
if process.isRunning { process.terminate() }
|
||||
// Give time to cleanup
|
||||
let deadline = Date().addingTimeInterval(2)
|
||||
while process.isRunning && Date() < deadline { usleep(100_000) }
|
||||
if process.isRunning { process.interrupt() }
|
||||
}
|
||||
deinit { terminate() }
|
||||
}
|
||||
|
||||
func startSocketHost(socketPath: String, tempDir: URL) throws -> HostProcess {
|
||||
let exe = try hostExecutableURL()
|
||||
let process = Process()
|
||||
process.executableURL = exe
|
||||
process.arguments = ["--socket", socketPath]
|
||||
let stderr = Pipe()
|
||||
process.standardError = stderr
|
||||
process.standardOutput = Pipe()
|
||||
process.standardInput = Pipe() // keep open, not used
|
||||
try process.run()
|
||||
// Wait for socket to appear (max 5s)
|
||||
let fm = FileManager.default
|
||||
let deadline = Date().addingTimeInterval(5)
|
||||
while Date() < deadline {
|
||||
if fm.fileExists(atPath: socketPath) { break }
|
||||
if !process.isRunning {
|
||||
let data = stderr.fileHandleForReading.readDataToEndOfFile()
|
||||
let s = String(data: data, encoding: .utf8) ?? ""
|
||||
throw NSError(domain: "SocketServerTests", code: 2, userInfo: [NSLocalizedDescriptionKey: "Host exited early. stderr: \(s)"])
|
||||
}
|
||||
// Do not call FileHandle.availableData here: it blocks while a healthy,
|
||||
// silent child keeps stderr open. Poll the socket path instead.
|
||||
usleep(100_000)
|
||||
}
|
||||
if !fm.fileExists(atPath: socketPath) {
|
||||
process.terminate()
|
||||
let data = stderr.fileHandleForReading.readDataToEndOfFile()
|
||||
let s = String(data: data, encoding: .utf8) ?? ""
|
||||
throw NSError(domain: "SocketServerTests", code: 3, userInfo: [NSLocalizedDescriptionKey: "Socket not created at \(socketPath) after timeout. stderr: \(s)"])
|
||||
}
|
||||
return HostProcess(process: process, socketPath: socketPath, tempDir: tempDir)
|
||||
}
|
||||
|
||||
// Low-level socket client: connect, send line, read one line response with timeout
|
||||
func socketRequestResponse(socketPath: String, requestLine: String, timeout: TimeInterval = 3) throws -> String {
|
||||
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { throw NSError(domain: "SocketServerTests", code: 10, userInfo: [NSLocalizedDescriptionKey: "socket() failed: \(String(cString: strerror(errno)))"]) }
|
||||
defer { close(fd) }
|
||||
|
||||
var addr = sockaddr_un()
|
||||
addr.sun_family = sa_family_t(AF_UNIX)
|
||||
let pathBytes = socketPath.utf8
|
||||
guard pathBytes.count < MemoryLayout.size(ofValue: addr.sun_path) else {
|
||||
throw NSError(domain: "SocketServerTests", code: 11, userInfo: [NSLocalizedDescriptionKey: "Socket path too long"])
|
||||
}
|
||||
memset(&addr.sun_path, 0, MemoryLayout.size(ofValue: addr.sun_path))
|
||||
_ = socketPath.withCString { cStr in
|
||||
withUnsafeMutablePointer(to: &addr.sun_path) { dstPtr in
|
||||
dstPtr.withMemoryRebound(to: CChar.self, capacity: 104) { charPtr in
|
||||
strncpy(charPtr, cStr, 103)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let addrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
|
||||
let connectResult = withUnsafePointer(to: &addr) { ptr in
|
||||
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { saddr in
|
||||
connect(fd, saddr, addrLen)
|
||||
}
|
||||
}
|
||||
guard connectResult == 0 else {
|
||||
throw NSError(domain: "SocketServerTests", code: 12, userInfo: [NSLocalizedDescriptionKey: "connect() failed to \(socketPath): \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
|
||||
let toSend = requestLine.hasSuffix("\n") ? requestLine : requestLine + "\n"
|
||||
guard let data = toSend.data(using: .utf8) else { throw NSError(domain: "SocketServerTests", code: 13, userInfo: [NSLocalizedDescriptionKey: "UTF8 encode fail"]) }
|
||||
var sent = 0
|
||||
while sent < data.count {
|
||||
let n = data.withUnsafeBytes { rawBuf in
|
||||
send(fd, rawBuf.baseAddress!.advanced(by: sent), data.count - sent, 0)
|
||||
}
|
||||
if n <= 0 { throw NSError(domain: "SocketServerTests", code: 14, userInfo: [NSLocalizedDescriptionKey: "send() failed: \(String(cString: strerror(errno)))"]) }
|
||||
sent += n
|
||||
}
|
||||
|
||||
var responseData = Data()
|
||||
var buffer = [UInt8](repeating: 0, count: 4096)
|
||||
let start = Date()
|
||||
while true {
|
||||
if Date().timeIntervalSince(start) > timeout {
|
||||
let partial = String(data: responseData, encoding: .utf8) ?? "<binary>"
|
||||
throw NSError(domain: "SocketServerTests", code: 15, userInfo: [NSLocalizedDescriptionKey: "socket read timeout, partial: \(partial)"])
|
||||
}
|
||||
var pfd = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
|
||||
let pr = poll(&pfd, 1, 200) // 200ms
|
||||
if pr < 0 {
|
||||
if errno == EINTR { continue }
|
||||
throw NSError(domain: "SocketServerTests", code: 16, userInfo: [NSLocalizedDescriptionKey: "poll failed: \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
if pr == 0 { continue }
|
||||
let r = recv(fd, &buffer, buffer.count, 0)
|
||||
if r < 0 {
|
||||
if errno == EINTR { continue }
|
||||
throw NSError(domain: "SocketServerTests", code: 17, userInfo: [NSLocalizedDescriptionKey: "recv failed: \(String(cString: strerror(errno)))"])
|
||||
}
|
||||
if r == 0 { break }
|
||||
responseData.append(contentsOf: buffer[0..<r])
|
||||
if let str = String(data: responseData, encoding: .utf8), str.contains("\n") {
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let respString = String(data: responseData, encoding: .utf8) else {
|
||||
throw NSError(domain: "SocketServerTests", code: 18, userInfo: [NSLocalizedDescriptionKey: "response not utf8"])
|
||||
}
|
||||
let firstLine = respString.split(separator: "\n", omittingEmptySubsequences: false).first.map { String($0) } ?? respString
|
||||
return firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func decode(_ line: String) throws -> [String: Any] {
|
||||
guard let data = line.data(using: .utf8),
|
||||
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
throw NSError(domain: "SocketServerTests", code: 20, userInfo: [NSLocalizedDescriptionKey: "Not JSON: \(line)"])
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
// MARK: - Tests (TDD RED first)
|
||||
|
||||
func testSocketHealthRequest() throws {
|
||||
// Prove that --socket mode health request works via real Unix socket.
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate(); try? fm.removeItem(atPath: sockPath) }
|
||||
|
||||
let req = #"{"id":"sock-1","operation":"service.health","arguments":{}}"#
|
||||
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: req)
|
||||
let resp = try decode(respLine)
|
||||
XCTAssertEqual(resp["id"] as? String, "sock-1")
|
||||
XCTAssertEqual(resp["ok"] as? Bool, true)
|
||||
if let result = resp["result"] as? [String: Any] {
|
||||
XCTAssertEqual(result["operation"] as? String, "service.health")
|
||||
XCTAssertFalse((result["protocol_version"] as? String ?? "").isEmpty)
|
||||
} else {
|
||||
XCTFail("Missing result: \(resp)")
|
||||
}
|
||||
}
|
||||
|
||||
func testSocketPermissionsAndParentCreation() throws {
|
||||
// Prove socket file mode 0600 and parent dir 0700, and auto-create parent.
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
// Do NOT create unique; let child subdir also not exist, testing parent creation
|
||||
let nestedParent = unique.appendingPathComponent("a/b/c")
|
||||
let sockPath = nestedParent.appendingPathComponent("reyna.sock").path
|
||||
// Ensure base exists for cleanup tracking but not nested
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate() }
|
||||
|
||||
var isDir: ObjCBool = false
|
||||
XCTAssertTrue(fm.fileExists(atPath: nestedParent.path, isDirectory: &isDir))
|
||||
XCTAssertTrue(isDir.boolValue)
|
||||
|
||||
let attrs = try fm.attributesOfItem(atPath: nestedParent.path)
|
||||
if let posix = attrs[.posixPermissions] as? NSNumber {
|
||||
let perms = posix.uint16Value & 0o777
|
||||
XCTAssertEqual(perms, 0o700, "Parent dir should be 0700, got \(String(perms, radix: 8))")
|
||||
} else {
|
||||
XCTFail("Could not get posixPermissions for parent")
|
||||
}
|
||||
|
||||
// Check socket file mode 0600 and type socket
|
||||
let sockAttrs = try fm.attributesOfItem(atPath: sockPath)
|
||||
if let posix = sockAttrs[.posixPermissions] as? NSNumber {
|
||||
let perms = posix.uint16Value & 0o777
|
||||
XCTAssertEqual(perms, 0o600, "Socket file should be 0600, got \(String(perms, radix: 8))")
|
||||
} else {
|
||||
XCTFail("Could not get posixPermissions for socket")
|
||||
}
|
||||
// Verify it's a socket using lstat mode check
|
||||
var st = stat()
|
||||
XCTAssertEqual(lstat(sockPath, &st), 0, "lstat should succeed")
|
||||
XCTAssertTrue((st.st_mode & S_IFMT) == S_IFSOCK, "File should be a socket")
|
||||
|
||||
// Also ensure owned by current uid
|
||||
XCTAssertEqual(st.st_uid, getuid(), "Socket should be owned by current uid")
|
||||
}
|
||||
|
||||
func testSocketMalformedRequest() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate() }
|
||||
|
||||
// Send malformed JSON
|
||||
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: "not json at all")
|
||||
let resp = try decode(respLine)
|
||||
XCTAssertEqual(resp["ok"] as? Bool, false)
|
||||
if let err = resp["error"] as? [String: Any] {
|
||||
XCTAssertEqual(err["code"] as? String, "invalid_request")
|
||||
} else {
|
||||
XCTFail("Missing error, got \(resp)")
|
||||
}
|
||||
// id should be empty when unrecoverable
|
||||
XCTAssertEqual(resp["id"] as? String, "")
|
||||
|
||||
// Send malformed but with id field to test preservation
|
||||
let respLine2 = try socketRequestResponse(socketPath: sockPath, requestLine: #"{"id":"keep-me","operation":}"#)
|
||||
let resp2 = try decode(respLine2)
|
||||
XCTAssertEqual(resp2["ok"] as? Bool, false)
|
||||
XCTAssertEqual(resp2["id"] as? String, "keep-me")
|
||||
if let err = resp2["error"] as? [String: Any] {
|
||||
XCTAssertEqual(err["code"] as? String, "invalid_request")
|
||||
} else {
|
||||
XCTFail("Missing error for second malformed")
|
||||
}
|
||||
}
|
||||
|
||||
func testSocketOversizedRequestBeyond64KiB() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
let host = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
defer { host.terminate() }
|
||||
|
||||
// Create payload > 64KiB
|
||||
let largeString = String(repeating: "A", count: 70*1024)
|
||||
let req = #"{"id":"big","operation":"service.health","arguments":{},"data":"\#(largeString)"}"#
|
||||
// Must be > 65536 bytes
|
||||
XCTAssertTrue(req.utf8.count > 65536)
|
||||
|
||||
let respLine = try socketRequestResponse(socketPath: sockPath, requestLine: req)
|
||||
let resp = try decode(respLine)
|
||||
XCTAssertEqual(resp["ok"] as? Bool, false, "Oversized should be rejected")
|
||||
if let err = resp["error"] as? [String: Any] {
|
||||
let code = err["code"] as? String ?? ""
|
||||
XCTAssertTrue(code == "invalid_request" || code == "payload_too_large" || code == "request_too_large" || code.contains("too_large") || code.contains("invalid"), "Unexpected error code for oversized: \(code)")
|
||||
} else {
|
||||
XCTFail("Missing error for oversized: \(resp)")
|
||||
}
|
||||
}
|
||||
|
||||
func testSocketCleanupOnTermination() throws {
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
var maybeHost: HostProcess? = try startSocketHost(socketPath: sockPath, tempDir: unique)
|
||||
XCTAssertTrue(fm.fileExists(atPath: sockPath), "Socket should exist while host running")
|
||||
maybeHost?.terminate()
|
||||
maybeHost = nil
|
||||
// Wait a bit for cleanup
|
||||
let deadline = Date().addingTimeInterval(3)
|
||||
while fm.fileExists(atPath: sockPath) && Date() < deadline { usleep(100_000) }
|
||||
XCTAssertFalse(fm.fileExists(atPath: sockPath), "Socket file should be removed on SIGTERM cleanup")
|
||||
}
|
||||
|
||||
func testSocketRefusesNonSocketExistingFile() throws {
|
||||
// If path exists and is regular file owned by uid, should refuse (not unlink unsafe)
|
||||
let fm = FileManager.default
|
||||
let unique = try makeShortUniqueDirChecked()
|
||||
try fm.createDirectory(at: unique, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
defer { try? fm.removeItem(at: unique) }
|
||||
|
||||
let sockPath = unique.appendingPathComponent("reyna.sock").path
|
||||
// Create regular file there
|
||||
fm.createFile(atPath: sockPath, contents: Data("hello".utf8))
|
||||
defer { try? fm.removeItem(atPath: sockPath) }
|
||||
|
||||
// Try start - should fail quickly (exit)
|
||||
let exe = try hostExecutableURL()
|
||||
let process = Process()
|
||||
process.executableURL = exe
|
||||
process.arguments = ["--socket", sockPath]
|
||||
let stderr = Pipe()
|
||||
process.standardError = stderr
|
||||
process.standardOutput = Pipe()
|
||||
try process.run()
|
||||
let deadline = Date().addingTimeInterval(3)
|
||||
while process.isRunning && Date() < deadline { usleep(100_000) }
|
||||
// Process should have exited with error, not be running and not have created socket replacing file
|
||||
var isSocket = false
|
||||
var st = stat()
|
||||
if lstat(sockPath, &st) == 0 {
|
||||
isSocket = (st.st_mode & S_IFMT) == S_IFSOCK
|
||||
}
|
||||
XCTAssertFalse(isSocket, "Should not have replaced regular file with socket")
|
||||
// If process still running, terminate and fail
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
XCTFail("Host should refuse to overwrite regular file and exit, but it is still running")
|
||||
} else {
|
||||
// Should exit non-zero
|
||||
XCTAssertNotEqual(process.terminationStatus, 0, "Should exit non-zero when refusing non-socket file")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user