From 66a0813e44cddd4910237ce08531ee378434da65 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 16 Dec 2025 10:41:24 +0100 Subject: [PATCH] test(macos): guard FileHandle read APIs --- .../ControlSocketServerTests.swift | 5 +- .../FileHandleLegacyAPIGuardTests.swift | 155 ++++++++++++++++++ .../FileHandleSafeReadTests.swift | 47 ++++++ .../ClawdisIPCTests/WebChatTunnelTests.swift | 11 +- 4 files changed, 215 insertions(+), 3 deletions(-) create mode 100644 apps/macos/Tests/ClawdisIPCTests/FileHandleLegacyAPIGuardTests.swift create mode 100644 apps/macos/Tests/ClawdisIPCTests/FileHandleSafeReadTests.swift diff --git a/apps/macos/Tests/ClawdisIPCTests/ControlSocketServerTests.swift b/apps/macos/Tests/ClawdisIPCTests/ControlSocketServerTests.swift index 3ba020044..bf5292b38 100644 --- a/apps/macos/Tests/ClawdisIPCTests/ControlSocketServerTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/ControlSocketServerTests.swift @@ -22,11 +22,12 @@ import Testing return nil } - let data = stderr.fileHandleForReading.readDataToEndOfFile() + let data = stderr.fileHandleForReading.readToEndSafely() guard let text = String(data: data, encoding: .utf8) else { return nil } for line in text.split(separator: "\n") { if line.hasPrefix("TeamIdentifier=") { - let raw = String(line.dropFirst("TeamIdentifier=".count)).trimmingCharacters(in: .whitespacesAndNewlines) + let raw = String(line.dropFirst("TeamIdentifier=".count)) + .trimmingCharacters(in: .whitespacesAndNewlines) return raw == "not set" ? nil : raw } } diff --git a/apps/macos/Tests/ClawdisIPCTests/FileHandleLegacyAPIGuardTests.swift b/apps/macos/Tests/ClawdisIPCTests/FileHandleLegacyAPIGuardTests.swift new file mode 100644 index 000000000..4b870cbc2 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/FileHandleLegacyAPIGuardTests.swift @@ -0,0 +1,155 @@ +import Foundation +import Testing + +@Suite struct FileHandleLegacyAPIGuardTests { + @Test func sourcesAvoidLegacyNonThrowingFileHandleReadAPIs() throws { + let testFile = URL(fileURLWithPath: #filePath) + let packageRoot = testFile + .deletingLastPathComponent() // ClawdisIPCTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // apps/macos + + let sourcesRoot = packageRoot.appendingPathComponent("Sources") + let swiftFiles = try Self.swiftFiles(under: sourcesRoot) + + var offenders: [String] = [] + for file in swiftFiles { + let raw = try String(contentsOf: file, encoding: .utf8) + let stripped = Self.stripCommentsAndStrings(from: raw) + + if stripped.contains("readDataToEndOfFile(") || stripped.contains(".availableData") { + offenders.append(file.path) + } + } + + if !offenders.isEmpty { + let message = "Found legacy FileHandle reads in:\n" + offenders.joined(separator: "\n") + throw NSError( + domain: "FileHandleLegacyAPIGuardTests", + code: 1, + userInfo: [NSLocalizedDescriptionKey: message]) + } + } + + private static func swiftFiles(under root: URL) throws -> [URL] { + let fm = FileManager.default + guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey]) else { + return [] + } + + var files: [URL] = [] + for case let url as URL in enumerator { + guard url.pathExtension == "swift" else { continue } + files.append(url) + } + return files + } + + private static func stripCommentsAndStrings(from source: String) -> String { + enum Mode { + case code + case lineComment + case blockComment(depth: Int) + case string(quoteCount: Int) // 1 = ", 3 = """ + } + + var mode: Mode = .code + var out = "" + out.reserveCapacity(source.count) + + var index = source.startIndex + func peek(_ offset: Int) -> Character? { + guard + let i = source.index(index, offsetBy: offset, limitedBy: source.endIndex), + i < source.endIndex + else { return nil } + return source[i] + } + + while index < source.endIndex { + let ch = source[index] + + switch mode { + case .code: + if ch == "/", peek(1) == "/" { + out.append(" ") + index = source.index(index, offsetBy: 2) + mode = .lineComment + continue + } + if ch == "/", peek(1) == "*" { + out.append(" ") + index = source.index(index, offsetBy: 2) + mode = .blockComment(depth: 1) + continue + } + if ch == "\"" { + let triple = (peek(1) == "\"") && (peek(2) == "\"") + out.append(triple ? " " : " ") + index = source.index(index, offsetBy: triple ? 3 : 1) + mode = .string(quoteCount: triple ? 3 : 1) + continue + } + out.append(ch) + index = source.index(after: index) + + case .lineComment: + if ch == "\n" { + out.append(ch) + index = source.index(after: index) + mode = .code + } else { + out.append(" ") + index = source.index(after: index) + } + + case let .blockComment(depth): + if ch == "/", peek(1) == "*" { + out.append(" ") + index = source.index(index, offsetBy: 2) + mode = .blockComment(depth: depth + 1) + continue + } + if ch == "*", peek(1) == "/" { + out.append(" ") + index = source.index(index, offsetBy: 2) + let newDepth = depth - 1 + mode = newDepth > 0 ? .blockComment(depth: newDepth) : .code + continue + } + out.append(ch == "\n" ? "\n" : " ") + index = source.index(after: index) + + case let .string(quoteCount): + if ch == "\\", quoteCount == 1 { + // Skip escaped character in normal strings. + out.append(" ") + index = source.index(after: index) + if index < source.endIndex { + out.append(" ") + index = source.index(after: index) + } + continue + } + if ch == "\"" { + if quoteCount == 3, peek(1) == "\"", peek(2) == "\"" { + out.append(" ") + index = source.index(index, offsetBy: 3) + mode = .code + continue + } + if quoteCount == 1 { + out.append(" ") + index = source.index(after: index) + mode = .code + continue + } + } + out.append(ch == "\n" ? "\n" : " ") + index = source.index(after: index) + } + } + + return out + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/FileHandleSafeReadTests.swift b/apps/macos/Tests/ClawdisIPCTests/FileHandleSafeReadTests.swift new file mode 100644 index 000000000..e3ad96f67 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/FileHandleSafeReadTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import Clawdis + +@Suite struct FileHandleSafeReadTests { + @Test func readToEndSafelyReturnsEmptyForClosedHandle() { + let pipe = Pipe() + let handle = pipe.fileHandleForReading + try? handle.close() + + let data = handle.readToEndSafely() + #expect(data.isEmpty) + } + + @Test func readSafelyUpToCountReturnsEmptyForClosedHandle() { + let pipe = Pipe() + let handle = pipe.fileHandleForReading + try? handle.close() + + let data = handle.readSafely(upToCount: 16) + #expect(data.isEmpty) + } + + @Test func readToEndSafelyReadsPipeContents() { + let pipe = Pipe() + let writeHandle = pipe.fileHandleForWriting + writeHandle.write(Data("hello".utf8)) + try? writeHandle.close() + + let data = pipe.fileHandleForReading.readToEndSafely() + #expect(String(data: data, encoding: .utf8) == "hello") + } + + @Test func readSafelyUpToCountReadsIncrementally() { + let pipe = Pipe() + let writeHandle = pipe.fileHandleForWriting + writeHandle.write(Data("hello world".utf8)) + try? writeHandle.close() + + let readHandle = pipe.fileHandleForReading + let first = readHandle.readSafely(upToCount: 5) + let second = readHandle.readSafely(upToCount: 32) + + #expect(String(data: first, encoding: .utf8) == "hello") + #expect(String(data: second, encoding: .utf8) == " world") + } +} diff --git a/apps/macos/Tests/ClawdisIPCTests/WebChatTunnelTests.swift b/apps/macos/Tests/ClawdisIPCTests/WebChatTunnelTests.swift index 39cf802c9..ee34db386 100644 --- a/apps/macos/Tests/ClawdisIPCTests/WebChatTunnelTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/WebChatTunnelTests.swift @@ -6,6 +6,15 @@ import Darwin import Foundation @Suite struct WebChatTunnelTests { + @Test func drainStderrDoesNotCrashWhenHandleClosed() { + let pipe = Pipe() + let handle = pipe.fileHandleForReading + try? handle.close() + + let drained = WebChatTunnel._testDrainStderr(handle) + #expect(drained.isEmpty) + } + @Test func portIsFreeDetectsIPv4Listener() { var fd = socket(AF_INET, SOCK_STREAM, 0) #expect(fd >= 0) @@ -57,7 +66,7 @@ import Foundation free = true break } - usleep(10_000) // 10ms + usleep(10000) // 10ms } #expect(free == true) }