import XCTest import Foundation /// TDD tests for Unix-domain-socket server mode (--socket ). /// 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.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) ?? "" 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.. [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") } } }