Files
reyna-cli/native/ReynaCLIHost/Tests/ReynaCLIHostTests/SocketServerTests.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

374 lines
18 KiB
Swift

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")
}
}
}