From ef9e484d41a37503e851b1c227bda8dff1311f9d Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Fri, 15 May 2026 14:26:36 +0200 Subject: [PATCH 1/2] SwiftLint: adopt as pre-commit hook and CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a shareable pre-commit hook under scripts/hooks/ that auto-fixes staged .swift files, refuses to run when staged files have unstaged edits, and strict-lints what remains. scripts/install-hooks.sh wires core.hooksPath -> scripts/hooks (one-time per clone). CI runs the same Homebrew SwiftLint on macos-latest so the binary and rule set match the local hook — no version drift between dev and CI. Path filter scopes the workflow to .swift / .swiftlint.yml / the workflow file itself. Minimal .swiftlint.yml at the root includes Sources + Tests; ShellKit is a library-only package so there's no app target to list. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/swiftlint.yml | 30 ++++++++++++++++++++++++++++++ .swiftlint.yml | 7 +++++++ scripts/hooks/pre-commit | 33 +++++++++++++++++++++++++++++++++ scripts/install-hooks.sh | 5 +++++ 4 files changed, 75 insertions(+) create mode 100644 .github/workflows/swiftlint.yml create mode 100644 .swiftlint.yml create mode 100755 scripts/hooks/pre-commit create mode 100755 scripts/install-hooks.sh diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml new file mode 100644 index 0000000..e015c94 --- /dev/null +++ b/.github/workflows/swiftlint.yml @@ -0,0 +1,30 @@ +--- +name: SwiftLint +on: + pull_request: + paths: + - '.github/workflows/swiftlint.yml' + - '.swiftlint.yml' + - '**/*.swift' + push: + branches: + - main + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + lint: + # macOS + Homebrew SwiftLint keeps CI aligned with the local + # pre-commit hook (scripts/hooks/pre-commit) — same binary, same + # rule set, no version drift between dev and CI. + runs-on: macos-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - name: Install SwiftLint + run: brew install swiftlint + - name: SwiftLint version + run: swiftlint --version + - name: Lint + run: swiftlint lint --strict --quiet diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..cdd670f --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,7 @@ +included: + - Sources + - Tests + +excluded: + - .build + - .swiftpm diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit new file mode 100755 index 0000000..1ec24e4 --- /dev/null +++ b/scripts/hooks/pre-commit @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# SwiftLint auto-fix + strict lint on staged .swift files. +set -euo pipefail + +[ -n "${CI:-}" ] && exit 0 # CI lints separately; this is for local enforcement. + +if ! command -v swiftlint >/dev/null 2>&1; then + echo "swiftlint not found — install via 'brew install swiftlint' (or skip with --no-verify)" >&2 + exit 1 +fi + +staged=() +while IFS= read -r -d '' file; do + case "$file" in + *.swift) staged+=("$file") ;; + esac +done < <(git diff --cached --name-only --diff-filter=ACMR -z) + +[ ${#staged[@]} -eq 0 ] && exit 0 + +# Refuse if any staged Swift file also has unstaged edits — otherwise +# `swiftlint --fix` + `git add` would silently sweep working-tree +# changes into the commit. This is the single most important safety rail. +dirty=$(git diff --name-only -- "${staged[@]}") +if [ -n "$dirty" ]; then + echo "pre-commit: staged Swift files have unstaged changes — stage or stash first:" >&2 + printf ' %s\n' $dirty >&2 + exit 1 +fi + +swiftlint lint --fix --quiet --force-exclude "${staged[@]}" +git add -- "${staged[@]}" +swiftlint lint --strict --quiet --force-exclude "${staged[@]}" diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..0bbd69a --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(git rev-parse --show-toplevel)" +git config core.hooksPath scripts/hooks +echo "core.hooksPath -> scripts/hooks" From cd86aaed6ba76147754c5b72d858f70817a65ad8 Mon Sep 17 00:00:00 2001 From: Oliver Drobnik Date: Fri, 15 May 2026 14:26:54 +0200 Subject: [PATCH 2/2] SwiftLint: clear initial violations to pass --strict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 132 → 0. Auto-fix cleared the easy 24 (commas, braces, trailing newlines). Remaining 108 broken down per the iBash field-notes triage: - 78 identifier_name: all local-variable renames — no public-API parameter labels touched (every short-name violation is on a let, closure param, or case binding). Common renames: sb → sandbox, rc → result, h → lower/headStr, i/v → index/value, n → count, bp/ok → buffer/success, e → entry. - 15 optional_data_string_conversion: library streaming-decode sites get surgical `// swiftlint:disable:next` with reasons — the rule's preferred failable form would drop bytes on partial UTF-8 chunks, which is wrong for InputSource/OutputSink contracts. Test sites switched to the rule's preferred `String(bytes:encoding:) ?? ""` form (cleaner than a disable per call site). - 3 large_tuple: new local structs PrivateIP.IPv4Address (4-octet return shape) and HostInfo.UnameInfo (5-field uname result). - 2 function_body_length: DefaultProcessLauncher.launch split into four private helpers (collectInputBytes, subprocessExecutable, subprocessEnvironment, resolveWorkingDirectory). HostInfo.real split into realUserAndFull + realSupplementaryGroups. - 5 function_parameter_count: surgical disables on the launcher protocol + 4 conformers. The 7-arg signature mirrors POSIX exec (program + args + env + cwd + stdin + stdout + stderr); bundling into a struct is a breaking protocol change for every ShellKit consumer for no behavioural gain. - 1 cyclomatic_complexity: PrivateIP.isPrivateIPv4 refactored into a top-half that handles whole-first-octet ranges and a private isReservedIPv4OctetPair helper for the octet-pair ranges (link- local, 172.16/12, 192.168/16, 192.0/24, CGNAT). No disable. - 3 for_where, 1 nesting (Entry.State, disabled with reason), remaining mechanical rewrites. `swift build` clean, `swift test` 33/33 passing, `swiftlint --strict` exit 0. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/ShellKit/Command/BinCatalog.swift | 20 +-- Sources/ShellKit/Command/ClosureCommand.swift | 3 +- .../ShellKit/Environment/Environment.swift | 11 +- Sources/ShellKit/IO/InputSource.swift | 30 ++-- Sources/ShellKit/IO/OutputSink.swift | 11 +- Sources/ShellKit/Network/NetworkConfig.swift | 11 +- Sources/ShellKit/Network/NetworkFetcher.swift | 22 +-- Sources/ShellKit/Network/PrivateIP.swift | 148 ++++++++++-------- Sources/ShellKit/Network/SecureFetcher.swift | 33 ++-- Sources/ShellKit/Network/URLAllowList.swift | 21 ++- Sources/ShellKit/Process/ChainLauncher.swift | 3 + .../Process/DefaultProcessLauncher.swift | 91 ++++++----- Sources/ShellKit/Process/Executable.swift | 4 +- Sources/ShellKit/Process/HostInfo.swift | 111 +++++++------ .../ShellKit/Process/ProcessLauncher.swift | 7 +- Sources/ShellKit/Process/ProcessTable.swift | 25 +-- .../Process/SandboxedDenyLauncher.swift | 3 + .../ShellKit/Sandbox/Sandbox+Factories.swift | 6 +- Sources/ShellKit/Shell+Static.swift | 25 ++- .../ShellKitTests/ProcessLauncherTests.swift | 22 +-- Tests/ShellKitTests/ShellTests.swift | 18 +-- 21 files changed, 354 insertions(+), 271 deletions(-) diff --git a/Sources/ShellKit/Command/BinCatalog.swift b/Sources/ShellKit/Command/BinCatalog.swift index 5a19100..35d3025 100644 --- a/Sources/ShellKit/Command/BinCatalog.swift +++ b/Sources/ShellKit/Command/BinCatalog.swift @@ -31,15 +31,15 @@ public enum BinCatalog { /// Map of command name → canonical absolute path. Names not in /// this map are treated as shell-only built-ins. public static let knownPaths: [String: String] = { - var m: [String: String] = [:] + var paths: [String: String] = [:] // /bin — the small set of commands macOS keeps in /bin. for name in [ "bash", "cat", "chmod", "cp", "dash", "date", "dd", "df", "echo", "expr", "hostname", "kill", "link", "ln", "ls", "mkdir", "mv", "ps", "pwd", "realpath", "rm", "rmdir", - "sh", "sleep", "stty", "sync", "test", "[", "unlink", - ] { m[name] = "/bin/\(name)" } + "sh", "sleep", "stty", "sync", "test", "[", "unlink" + ] { paths[name] = "/bin/\(name)" } // /usr/bin — everything else we ship that would normally be // installed there on macOS. The trailing group (`clear`, @@ -60,8 +60,8 @@ public enum BinCatalog { "timeout", "touch", "tr", "tree", "true", "truncate", "uname", "unexpand", "uniq", "wait", "wc", "which", "whoami", "xargs", "xattr", "xxd", "yes", - "clear", "open", "pbcopy", "pbpaste", "say", - ] { m[name] = "/usr/bin/\(name)" } + "clear", "open", "pbcopy", "pbpaste", "say" + ] { paths[name] = "/usr/bin/\(name)" } // Third-party commonly-installed utilities (Homebrew / // user-installed). We slot them under /usr/local/bin so @@ -85,9 +85,9 @@ public enum BinCatalog { // backing logic. The catalog entry is here so // `which swift-js` reports `/usr/local/bin/swift-js` // rather than dropping back to "not found". - "swift", "swift-script", "swift-js", "node", "bun", + "swift", "swift-script", "swift-js", "node", "bun" ] { - m[name] = "/usr/local/bin/\(name)" + paths[name] = "/usr/local/bin/\(name)" } // Embedder-supplied CLIs that ship as primary system tools @@ -96,12 +96,12 @@ public enum BinCatalog { // on whether the host shell registers it; this entry just // declares where it WOULD live on a hypothetical "real" // install. - m["coder"] = "/usr/bin/coder" + paths["coder"] = "/usr/bin/coder" // `curl` ships at /usr/bin/curl on macOS. - m["curl"] = "/usr/bin/curl" + paths["curl"] = "/usr/bin/curl" - return m + return paths }() /// All directories that contain at least one entry. Drives the diff --git a/Sources/ShellKit/Command/ClosureCommand.swift b/Sources/ShellKit/Command/ClosureCommand.swift index 6ba7a63..290ca81 100644 --- a/Sources/ShellKit/Command/ClosureCommand.swift +++ b/Sources/ShellKit/Command/ClosureCommand.swift @@ -23,8 +23,7 @@ public struct ClosureCommand: Command { private let body: @Sendable ([String]) async throws -> ExitStatus public init(name: String, - body: @Sendable @escaping ([String]) async throws -> ExitStatus) - { + body: @Sendable @escaping ([String]) async throws -> ExitStatus) { self.name = name self.body = body } diff --git a/Sources/ShellKit/Environment/Environment.swift b/Sources/ShellKit/Environment/Environment.swift index 0d2fcf1..808a544 100644 --- a/Sources/ShellKit/Environment/Environment.swift +++ b/Sources/ShellKit/Environment/Environment.swift @@ -25,8 +25,7 @@ public struct Environment: Hashable, Sendable { public init(variables: [String: String] = [:], arrays: [String: BashArray] = [:], associativeArrays: [String: [String: String]] = [:], - workingDirectory: String = FileManager.default.currentDirectoryPath) - { + workingDirectory: String = FileManager.default.currentDirectoryPath) { self.variables = variables self.arrays = arrays self.associativeArrays = associativeArrays @@ -75,7 +74,7 @@ public struct Environment: Hashable, Sendable { "SHELL": "/bin/sh", "TERM": "dumb", "LANG": "C.UTF-8", - "LC_ALL": "C.UTF-8", + "LC_ALL": "C.UTF-8" ] return Environment(variables: vars, workingDirectory: workingDirectory) @@ -98,7 +97,7 @@ public struct BashArray: Hashable, Sendable { /// Build a dense array from a Swift `[String]` — indices 0..count-1. public init(dense values: [String]) { var dict: [Int: String] = [:] - for (i, v) in values.enumerated() { dict[i] = v } + for (index, value) in values.enumerated() { dict[index] = value } self.entries = dict } @@ -124,8 +123,8 @@ public struct BashArray: Hashable, Sendable { /// appends starting at 0, matching bash. public mutating func append(_ values: [String]) { var next = (entries.keys.max() ?? -1) + 1 - for v in values { - entries[next] = v + for value in values { + entries[next] = value next += 1 } } diff --git a/Sources/ShellKit/IO/InputSource.swift b/Sources/ShellKit/IO/InputSource.swift index 5e83ae5..8c2ffa6 100644 --- a/Sources/ShellKit/IO/InputSource.swift +++ b/Sources/ShellKit/IO/InputSource.swift @@ -53,14 +53,14 @@ public struct InputSource: Sendable { }() /// A stream that yields a single UTF-8-encoded chunk then finishes. - public static func string(_ s: String) -> InputSource { - .data(Data(s.utf8)) + public static func string(_ text: String) -> InputSource { + .data(Data(text.utf8)) } /// A stream that yields one `Data` chunk then finishes. - public static func data(_ d: Data) -> InputSource { + public static func data(_ data: Data) -> InputSource { let (stream, cont) = AsyncStream.makeStream() - if !d.isEmpty { cont.yield(d) } + if !data.isEmpty { cont.yield(data) } cont.finish() return InputSource(bytes: stream) } @@ -81,6 +81,8 @@ public struct InputSource: Sendable { /// command is fed binary data). public func readAllString() async -> String { let data = await readAllData() + // Lossy decode by design — see type doc on permissive UTF-8. + // swiftlint:disable:next optional_data_string_conversion return String(decoding: data, as: UTF8.self) } @@ -101,13 +103,18 @@ public struct InputSource: Sendable { func readLine() async -> String? { while true { - if let nl = pending.firstIndex(of: 0x0A) { - let line = pending[pending.startIndex.. String { + // Lossy decode by design — see type doc on permissive UTF-8. + // swiftlint:disable:next optional_data_string_conversion String(decoding: await readAllData(), as: UTF8.self) } @@ -95,10 +97,13 @@ public final class OutputSink: @unchecked Sendable { Task { var pending = "" for await chunk in bytes { + // Chunks may split a multibyte char at the boundary; + // lossy decode by design. + // swiftlint:disable:next optional_data_string_conversion pending += String(decoding: chunk, as: UTF8.self) - while let nl = pending.range(of: "\n") { - let line = String(pending[.. String in - guard let first = p.first else { return "" } - return String(first).uppercased() + p.dropFirst().lowercased() + return parts.map { part -> String in + guard let first = part.first else { return "" } + return String(first).uppercased() + part.dropFirst().lowercased() }.joined(separator: "-") } private final class NoRedirectDelegate: NSObject, - URLSessionTaskDelegate, @unchecked Sendable - { + URLSessionTaskDelegate, @unchecked Sendable { func urlSession( _ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, diff --git a/Sources/ShellKit/Network/PrivateIP.swift b/Sources/ShellKit/Network/PrivateIP.swift index 1382f4d..435c1ca 100644 --- a/Sources/ShellKit/Network/PrivateIP.swift +++ b/Sources/ShellKit/Network/PrivateIP.swift @@ -37,8 +37,8 @@ public enum PrivateIP { /// of resolution (`localhost`, `*.localhost`). public static func isPrivate(host: String) -> Bool { if host.isEmpty { return false } - let h = host.lowercased() - if h == "localhost" || h.hasSuffix(".localhost") { return true } + let lower = host.lowercased() + if lower == "localhost" || lower.hasSuffix(".localhost") { return true } if let ipv4 = parseIPv4(host) { return isPrivateIPv4(ipv4) } @@ -75,27 +75,27 @@ public enum PrivateIP { #else hints.ai_socktype = SOCK_STREAM #endif - var info: UnsafeMutablePointer? = nil - let rc = hostname.withCString { name in + var info: UnsafeMutablePointer? + let result = hostname.withCString { name in getaddrinfo(name, nil, &hints, &info) } defer { if let info { freeaddrinfo(info) } } - if rc != 0 { + if result != 0 { // EAI_NONAME / EAI_NODATA: nothing to check, no rebinding risk. // EAI_NODATA was removed from POSIX in 2008; Linux's headers // don't define it any more, so guard it on Darwin only. #if canImport(Darwin) - if rc == EAI_NONAME || rc == EAI_NODATA { return [] } + if result == EAI_NONAME || result == EAI_NODATA { return [] } #else - if rc == EAI_NONAME { return [] } + if result == EAI_NONAME { return [] } #endif #if os(Windows) // Windows gai_strerror returns a wchar_t*. Easier to just // report the numeric code than bridge the wide string. throw NetworkError.transport( - message: "DNS resolution failed (WSA \(rc))") + message: "DNS resolution failed (WSA \(result))") #else - let msg = String(cString: gai_strerror(rc)) + let msg = String(cString: gai_strerror(result)) throw NetworkError.transport(message: "DNS resolution failed: \(msg)") #endif } @@ -104,10 +104,9 @@ public enum PrivateIP { var cur = info while let node = cur { if let saPtr = node.pointee.ai_addr { - if let s = describeAddress(saPtr, - length: Int(node.pointee.ai_addrlen)) - { - out.append(s) + if let text = describeAddress(saPtr, + length: Int(node.pointee.ai_addrlen)) { + out.append(text) } } cur = node.pointee.ai_next @@ -117,7 +116,7 @@ public enum PrivateIP { /// Render a `sockaddr` as the textual address (no port). private static func describeAddress( - _ sa: UnsafePointer, length: Int + _ addr: UnsafePointer, length: Int ) -> String? { var buf = [Int8](repeating: 0, count: Int(NI_MAXHOST)) // Windows' getnameinfo takes DWORD for the buffer-size args @@ -130,49 +129,71 @@ public enum PrivateIP { #else let bufSize = socklen_t(buf.count) #endif - let rc = getnameinfo(sa, socklen_t(length), - &buf, bufSize, - nil, 0, - NI_NUMERICHOST) - if rc != 0 { return nil } + let result = getnameinfo(addr, socklen_t(length), + &buf, bufSize, + nil, 0, + NI_NUMERICHOST) + if result != 0 { return nil } // Truncate at the trailing NUL and decode as UTF-8 (the // deprecated `String(cString: array)` initializer scans for // the NUL itself). let bytes = buf.prefix(while: { $0 != 0 }).map { UInt8(bitPattern: $0) } + // Lossy decode — getnameinfo with NI_NUMERICHOST yields ASCII, + // but failable decode would drop the address on the unlikely + // off-chance of bogus bytes. Lossy is the conservative choice. + // swiftlint:disable:next optional_data_string_conversion return String(decoding: bytes, as: UTF8.self) } // MARK: IPv4 ranges - /// Returns the four octets of `s` if it parses as a dotted-decimal + /// Four-octet IPv4 address. The natural shape is a `(UInt8, UInt8, + /// UInt8, UInt8)` tuple, but SwiftLint's `large_tuple` rule caps + /// returns at 2 members. + struct IPv4Address: Equatable { + let octet0: UInt8 + let octet1: UInt8 + let octet2: UInt8 + let octet3: UInt8 + } + + /// Returns the four octets of `text` if it parses as a dotted-decimal /// IPv4 literal; `nil` otherwise. - static func parseIPv4(_ s: String) -> (UInt8, UInt8, UInt8, UInt8)? { - let parts = s.split(separator: ".", omittingEmptySubsequences: false) + static func parseIPv4(_ text: String) -> IPv4Address? { + let parts = text.split(separator: ".", omittingEmptySubsequences: false) guard parts.count == 4 else { return nil } var bytes: [UInt8] = [] - for p in parts { - guard let n = UInt32(p), n <= 255 else { return nil } - bytes.append(UInt8(n)) + for part in parts { + guard let value = UInt32(part), value <= 255 else { return nil } + bytes.append(UInt8(value)) } - return (bytes[0], bytes[1], bytes[2], bytes[3]) + return IPv4Address(octet0: bytes[0], octet1: bytes[1], + octet2: bytes[2], octet3: bytes[3]) } /// True if the v4 address is in any of the standard private/loopback/ /// link-local/CGNAT/multicast/broadcast ranges. - static func isPrivateIPv4(_ a: (UInt8, UInt8, UInt8, UInt8)) -> Bool { - let (b0, b1, _, _) = a - switch b0 { - case 0: return true // 0.0.0.0/8 - case 10: return true // 10.0.0.0/8 - case 127: return true // 127.0.0.0/8 loopback - case 169 where b1 == 254: return true // 169.254.0.0/16 link-local - case 172 where (16...31).contains(b1): return true // 172.16/12 - case 192 where b1 == 168: return true // 192.168/16 - case 192 where b1 == 0: return true // 192.0.0/24 (IETF) - case 100 where (64...127).contains(b1): return true // 100.64/10 CGNAT - case 224...239: return true // multicast - case 240...255: return true // reserved + 255.255.255.255 - default: return false + static func isPrivateIPv4(_ address: IPv4Address) -> Bool { + let octet0 = address.octet0 + // Whole first-octet ranges that need no further inspection. + switch octet0 { + case 0, 10, 127: return true // 0/8, 10/8, 127/8 loopback + case 224...255: return true // multicast + reserved + broadcast + default: break + } + return isReservedIPv4OctetPair(octet0, octet1: address.octet1) + } + + /// Inner half of ``isPrivateIPv4`` — the ranges where membership + /// is decided by the first two octets together (link-local, + /// 172.16/12, 192.168/16, 192.0/24, 100.64/10 CGNAT). + private static func isReservedIPv4OctetPair(_ octet0: UInt8, octet1: UInt8) -> Bool { + switch octet0 { + case 169: return octet1 == 254 // 169.254.0.0/16 link-local + case 172: return (16...31).contains(octet1) // 172.16/12 + case 192: return octet1 == 168 || octet1 == 0 // 192.168/16, 192.0.0/24 + case 100: return (64...127).contains(octet1) // 100.64/10 CGNAT + default: return false } } @@ -184,21 +205,21 @@ public enum PrivateIP { /// the same way on macOS / Linux / Windows. static func parseIPv6(_ raw: String) -> [UInt8]? { // Strip zone identifier `%...`. - let s = raw.split(separator: "%").first.map(String.init) ?? raw - if s.isEmpty { return nil } + let text = raw.split(separator: "%").first.map(String.init) ?? raw + if text.isEmpty { return nil } // At most one `::` allowed. - let doubleColonRanges = s.ranges(of: "::") + let doubleColonRanges = text.ranges(of: "::") if doubleColonRanges.count > 1 { return nil } let head: [Substring] let tail: [Substring] - if let dc = doubleColonRanges.first { - let h = s[s.startIndex.. [UInt8]? { if isLast, seg.contains(".") { - guard let v4 = parseIPv4(String(seg)) else { return nil } - return [v4.0, v4.1, v4.2, v4.3] + guard let octets = parseIPv4(String(seg)) else { return nil } + return [octets.octet0, octets.octet1, octets.octet2, octets.octet3] } guard !seg.isEmpty, seg.count <= 4, seg.allSatisfy({ $0.isHexDigit }), - let v = UInt16(seg, radix: 16) + let value = UInt16(seg, radix: 16) else { return nil } - return [UInt8(v >> 8), UInt8(v & 0xff)] + return [UInt8(value >> 8), UInt8(value & 0xff)] } var headBytes: [UInt8] = [] - for (i, seg) in head.enumerated() { + for (index, seg) in head.enumerated() { let isLast = doubleColonRanges.isEmpty && tail.isEmpty - && i == head.count - 1 - guard let bs = segmentBytes(seg, isLast: isLast) else { return nil } - headBytes.append(contentsOf: bs) + && index == head.count - 1 + guard let segBytes = segmentBytes(seg, isLast: isLast) else { return nil } + headBytes.append(contentsOf: segBytes) } var tailBytes: [UInt8] = [] - for (i, seg) in tail.enumerated() { - guard let bs = segmentBytes(seg, isLast: i == tail.count - 1) + for (index, seg) in tail.enumerated() { + guard let segBytes = segmentBytes(seg, isLast: index == tail.count - 1) else { return nil } - tailBytes.append(contentsOf: bs) + tailBytes.append(contentsOf: segBytes) } if doubleColonRanges.isEmpty { @@ -259,11 +280,12 @@ public enum PrivateIP { if bytes[0] == 0xfe, (bytes[1] & 0xc0) == 0x80 { return true } // IPv4-mapped: `::ffff:a.b.c.d` — last 4 bytes are the v4 addr. let mappedPrefix: [UInt8] = [ - 0,0,0,0, 0,0,0,0, 0,0, 0xff,0xff + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff ] if Array(bytes[0..<12]) == mappedPrefix { - let v4 = (bytes[12], bytes[13], bytes[14], bytes[15]) - return isPrivateIPv4(v4) + let octets = IPv4Address(octet0: bytes[12], octet1: bytes[13], + octet2: bytes[14], octet3: bytes[15]) + return isPrivateIPv4(octets) } return false } diff --git a/Sources/ShellKit/Network/SecureFetcher.swift b/Sources/ShellKit/Network/SecureFetcher.swift index 0bb2f76..2f524fd 100644 --- a/Sources/ShellKit/Network/SecureFetcher.swift +++ b/Sources/ShellKit/Network/SecureFetcher.swift @@ -107,16 +107,15 @@ public struct SecureFetcher: Sendable { // MARK: Validation private func checkAllowed(url: URL, - redirectError: Bool = false) async throws - { - let s = url.absoluteString + redirectError: Bool = false) async throws { + let urlString = url.absoluteString if !config.dangerouslyAllowFullInternetAccess { - if !URLAllowList.isAllowed(s, entries: config.allowedURLPrefixes) { + if !URLAllowList.isAllowed(urlString, entries: config.allowedURLPrefixes) { if redirectError { - throw NetworkError.redirectNotAllowed(url: s) + throw NetworkError.redirectNotAllowed(url: urlString) } throw NetworkError.accessDenied( - url: s, reason: "URL not in allow-list") + url: urlString, reason: "URL not in allow-list") } } // Private-range guard runs even with full internet access. @@ -138,12 +137,10 @@ public struct SecureFetcher: Sendable { || (PrivateIP.parseIPv6(host) != nil) if isIPLiteral { return } let addresses = try await PrivateIP.resolve(host) - for addr in addresses { - if PrivateIP.isPrivate(host: addr) { - throw NetworkError.privateAddress( - url: url.absoluteString, - reason: "hostname resolves to private/loopback IP address") - } + for addr in addresses where PrivateIP.isPrivate(host: addr) { + throw NetworkError.privateAddress( + url: url.absoluteString, + reason: "hostname resolves to private/loopback IP address") } } @@ -152,9 +149,9 @@ public struct SecureFetcher: Sendable { for: req.url.absoluteString, entries: config.allowedURLPrefixes) else { return } - for (k, v) in injected { + for (key, value) in injected { // Override script-supplied values for these names. - req.headers[k] = v + req.headers[key] = value } } @@ -163,13 +160,13 @@ public struct SecureFetcher: Sendable { } /// Strip headers that don't make sense to forward across a hop. - private func stripHopHeaders(_ h: [String: String]) -> [String: String] { + private func stripHopHeaders(_ headers: [String: String]) -> [String: String] { let drop: Set = [ "Authorization", "Cookie", "Host", "Content-Length", - "Content-Type", "Transfer-Encoding", "Connection", + "Content-Type", "Transfer-Encoding", "Connection" ] - var out = h - for k in drop where out[k] != nil { out.removeValue(forKey: k) } + var out = headers + for key in drop where out[key] != nil { out.removeValue(forKey: key) } return out } } diff --git a/Sources/ShellKit/Network/URLAllowList.swift b/Sources/ShellKit/Network/URLAllowList.swift index ce92cb5..74e5fbb 100644 --- a/Sources/ShellKit/Network/URLAllowList.swift +++ b/Sources/ShellKit/Network/URLAllowList.swift @@ -86,12 +86,9 @@ public enum URLAllowList { /// Does `url` match *any* entry? public static func isAllowed(_ url: String, - entries: [AllowedURLEntry]) -> Bool - { - for entry in entries { - if matches(url: url, entry: entry.url) { - return true - } + entries: [AllowedURLEntry]) -> Bool { + for entry in entries where matches(url: url, entry: entry.url) { + return true } return false } @@ -108,9 +105,9 @@ public enum URLAllowList { for entry in entries where !entry.transforms.isEmpty { if matches(url: url, entry: entry.url) { any = true - for t in entry.transforms { - for (k, v) in t.headers { - merged[k] = v + for transform in entry.transforms { + for (key, value) in transform.headers { + merged[key] = value } } } @@ -123,8 +120,8 @@ public enum URLAllowList { /// Minimal URL parser used by the allow-list. Returns `nil` for /// malformed input. Lowercases the scheme and host (per RFC 3986) /// for stable comparison; preserves the path verbatim. - public static func parseURL(_ s: String) -> ParsedURL? { - guard let url = URL(string: s), + public static func parseURL(_ raw: String) -> ParsedURL? { + guard let url = URL(string: raw), let scheme = url.scheme?.lowercased(), let host = url.host?.lowercased(), !host.isEmpty @@ -134,7 +131,7 @@ public enum URLAllowList { // check can spot `%2f` / `%5c`, which `URL.path` would // already have decoded away). let path = url.path - let rawPath = URLComponents(string: s)?.percentEncodedPath ?? path + let rawPath = URLComponents(string: raw)?.percentEncodedPath ?? path var origin = "\(scheme)://\(host)" if let port = url.port { origin += ":\(port)" } return ParsedURL(scheme: scheme, host: host, path: path, diff --git a/Sources/ShellKit/Process/ChainLauncher.swift b/Sources/ShellKit/Process/ChainLauncher.swift index 28b0b1d..56b3d24 100644 --- a/Sources/ShellKit/Process/ChainLauncher.swift +++ b/Sources/ShellKit/Process/ChainLauncher.swift @@ -30,6 +30,9 @@ public struct ChainLauncher: ProcessLauncher { self.fallback = fallback } + // Mirrors the ProcessLauncher protocol signature — see protocol + // for why 7 parameters is the canonical exec contract. + // swiftlint:disable:next function_parameter_count public func launch( _ executable: Executable, arguments: Arguments, diff --git a/Sources/ShellKit/Process/DefaultProcessLauncher.swift b/Sources/ShellKit/Process/DefaultProcessLauncher.swift index b0aa1c7..c30b280 100644 --- a/Sources/ShellKit/Process/DefaultProcessLauncher.swift +++ b/Sources/ShellKit/Process/DefaultProcessLauncher.swift @@ -53,6 +53,11 @@ public struct DefaultProcessLauncher: ProcessLauncher { self.bufferLimit = bufferLimit } + // The launch contract mirrors the POSIX exec model (program + + // args + env + cwd + stdin + stdout + stderr). Bundling into a + // struct would break every conformer and downstream consumer for + // no behavioural gain. + // swiftlint:disable:next function_parameter_count public func launch( _ executable: Executable, arguments: Arguments, @@ -64,43 +69,13 @@ public struct DefaultProcessLauncher: ProcessLauncher { ) async throws -> ExecutionRecord { #if canImport(Subprocess) // Drain stdin upfront. v1 doesn't stream — see type doc. - var inputBytes: [UInt8] = [] - for await chunk in input.bytes { - inputBytes.append(contentsOf: chunk) - } - - let exe: Subprocess.Executable - switch executable.storage { - case .name(let name): exe = .name(name) - case .path(let p): exe = .path(FilePath(p)) - } + let inputBytes = await collectInputBytes(input) + let exe = subprocessExecutable(from: executable) let args = Subprocess.Arguments(arguments.values) - - // Mirror ShellKit's ``Environment/variables`` into a custom - // subprocess environment. `.custom` rather than `.inherit` - // because the embedder is the source of truth — anything that - // should leak through from `ProcessInfo.processInfo.environment` - // is already in `Shell.processDefault.environment.variables`. - // ``Subprocess.Environment.Key.init(_:)`` is package-private; - // the public path is its `ExpressibleByStringLiteral` init. - var envMap: [Subprocess.Environment.Key: String] = [:] - for (k, v) in environment.variables { - envMap[Subprocess.Environment.Key(stringLiteral: k)] = v - } - let subprocessEnv = Subprocess.Environment.custom(envMap) - - // Resolve working directory. Explicit override wins; otherwise - // fall back to the shell's `environment.workingDirectory` if - // set; otherwise pass nil so the host's CWD is inherited. - let cwd: FilePath? = { - if let wd = workingDirectory, !wd.isEmpty { - return FilePath(wd) - } - let envCwd = environment.workingDirectory - if !envCwd.isEmpty { return FilePath(envCwd) } - return nil - }() + let subprocessEnv = subprocessEnvironment(from: environment) + let cwd = resolveWorkingDirectory( + override: workingDirectory, environment: environment) let record = try await Subprocess.run( exe, @@ -138,4 +113,50 @@ public struct DefaultProcessLauncher: ProcessLauncher { throw ProcessLaunchUnsupportedOnThisPlatform(executable: executable) #endif } + + #if canImport(Subprocess) + private func collectInputBytes(_ input: InputSource) async -> [UInt8] { + var inputBytes: [UInt8] = [] + for await chunk in input.bytes { + inputBytes.append(contentsOf: chunk) + } + return inputBytes + } + + private func subprocessExecutable(from executable: Executable) -> Subprocess.Executable { + switch executable.storage { + case .name(let name): return .name(name) + case .path(let path): return .path(FilePath(path)) + } + } + + /// Mirror ShellKit's ``Environment/variables`` into a custom + /// subprocess environment. `.custom` rather than `.inherit` + /// because the embedder is the source of truth — anything that + /// should leak through from `ProcessInfo.processInfo.environment` + /// is already in `Shell.processDefault.environment.variables`. + /// ``Subprocess.Environment.Key.init(_:)`` is package-private; + /// the public path is its `ExpressibleByStringLiteral` init. + private func subprocessEnvironment(from environment: Environment) -> Subprocess.Environment { + var envMap: [Subprocess.Environment.Key: String] = [:] + for (key, value) in environment.variables { + envMap[Subprocess.Environment.Key(stringLiteral: key)] = value + } + return Subprocess.Environment.custom(envMap) + } + + /// Explicit override wins; otherwise fall back to the shell's + /// `environment.workingDirectory` if set; otherwise pass nil so + /// the host's CWD is inherited. + private func resolveWorkingDirectory( + override: String?, environment: Environment + ) -> FilePath? { + if let dir = override, !dir.isEmpty { + return FilePath(dir) + } + let envCwd = environment.workingDirectory + if !envCwd.isEmpty { return FilePath(envCwd) } + return nil + } + #endif } diff --git a/Sources/ShellKit/Process/Executable.swift b/Sources/ShellKit/Process/Executable.swift index 6867d65..8d5ce6b 100644 --- a/Sources/ShellKit/Process/Executable.swift +++ b/Sources/ShellKit/Process/Executable.swift @@ -39,8 +39,8 @@ public struct Executable: Sendable, Hashable { /// The string form a user would type — name or path. public var description: String { switch storage { - case .name(let n): return n - case .path(let p): return p + case .name(let name): return name + case .path(let path): return path } } } diff --git a/Sources/ShellKit/Process/HostInfo.swift b/Sources/ShellKit/Process/HostInfo.swift index 49d0163..85fc45e 100644 --- a/Sources/ShellKit/Process/HostInfo.swift +++ b/Sources/ShellKit/Process/HostInfo.swift @@ -101,41 +101,24 @@ public struct HostInfo: Sendable, Equatable { public static func real() -> HostInfo { let info = ProcessInfo.processInfo #if !os(Windows) - // `ProcessInfo.userName` and `fullUserName` are macOS-only — - // iOS / tvOS / watchOS don't expose them. Fall back to the - // POSIX uid → name lookup, then to a generic "user". - #if os(macOS) - let user = info.userName - let full = info.fullUserName.isEmpty ? user : info.fullUserName - #else - let user = passwdUserName(uid: UInt32(getuid())) ?? "user" - let full = user - #endif - let host = info.hostName + let (user, full) = realUserAndFull(info: info) let uid = UInt32(getuid()) let gid = UInt32(getgid()) - // Get supplementary groups via getgroups(2). Cap at NGROUPS_MAX. - var groups: [UInt32] = [gid] - var buf = [gid_t](repeating: 0, count: 64) - let n = getgroups(Int32(buf.count), &buf) - if n > 0 { - groups = (0.. (user: String, full: String) { + #if os(macOS) + let user = info.userName + let full = info.fullUserName.isEmpty ? user : info.fullUserName + #else + let user = passwdUserName(uid: UInt32(getuid())) ?? "user" + let full = user + #endif + return (user, full) + } + + /// Supplementary groups via `getgroups(2)`. Cap at 64 (the + /// NGROUPS_MAX floor on every supported platform). + private static func realSupplementaryGroups(gid: UInt32) -> [UInt32] { + var buf = [gid_t](repeating: 0, count: 64) + let count = getgroups(Int32(buf.count), &buf) + if count > 0 { + return (0.. String? { var bufSize: DWORD = 256 var buf = [WCHAR](repeating: 0, count: Int(bufSize)) - let ok = buf.withUnsafeMutableBufferPointer { bp -> Bool in - GetUserNameW(bp.baseAddress, &bufSize) + let success = buf.withUnsafeMutableBufferPointer { buffer -> Bool in + GetUserNameW(buffer.baseAddress, &bufSize) } - guard ok, bufSize > 1 else { return nil } + guard success, bufSize > 1 else { return nil } // bufSize includes the trailing NUL — drop it. return String(decoding: buf.prefix(Int(bufSize) - 1), as: UTF16.self) } @@ -173,10 +183,10 @@ public struct HostInfo: Sendable, Equatable { private static func winComputerName() -> String? { var bufSize: DWORD = DWORD(MAX_COMPUTERNAME_LENGTH + 1) var buf = [WCHAR](repeating: 0, count: Int(bufSize)) - let ok = buf.withUnsafeMutableBufferPointer { bp -> Bool in - GetComputerNameW(bp.baseAddress, &bufSize) + let success = buf.withUnsafeMutableBufferPointer { buffer -> Bool in + GetComputerNameW(buffer.baseAddress, &bufSize) } - guard ok, bufSize > 0 else { return nil } + guard success, bufSize > 0 else { return nil } return String(decoding: buf.prefix(Int(bufSize)), as: UTF16.self) } @@ -184,8 +194,8 @@ public struct HostInfo: Sendable, Equatable { /// Foundation `OperatingSystemVersion` (cheaper than calling /// `RtlGetVersion` and lying-via-AppCompat-shim safe). private static func winKernelInfo() -> (release: String, machine: String) { - let v = ProcessInfo.processInfo.operatingSystemVersion - let release = "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" + let osVersion = ProcessInfo.processInfo.operatingSystemVersion + let release = "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" var sysInfo = SYSTEM_INFO() GetNativeSystemInfo(&sysInfo) @@ -217,18 +227,29 @@ public struct HostInfo: Sendable, Equatable { return String(cString: name) } - private static func realUname() -> (String, String, String, String, String) { - var u = utsname() - guard uname(&u) == 0 else { - return ("Darwin", "0.0.0", "swift-bash", "arm64", "sandbox") + /// Result of `uname(3)`. Natural shape is a 5-tuple but + /// `large_tuple` caps returns at 2. + private struct UnameInfo { + let sysname: String + let release: String + let version: String + let machine: String + let node: String + } + + private static func realUname() -> UnameInfo { + var uts = utsname() + guard uname(&uts) == 0 else { + return UnameInfo(sysname: "Darwin", release: "0.0.0", + version: "swift-bash", machine: "arm64", + node: "sandbox") } - return ( - cString(of: &u.sysname), - cString(of: &u.release), - cString(of: &u.version), - cString(of: &u.machine), - cString(of: &u.nodename) - ) + return UnameInfo( + sysname: cString(of: &uts.sysname), + release: cString(of: &uts.release), + version: cString(of: &uts.version), + machine: cString(of: &uts.machine), + node: cString(of: &uts.nodename)) } /// Convert a `utsname`-style fixed-size CChar tuple to a String. diff --git a/Sources/ShellKit/Process/ProcessLauncher.swift b/Sources/ShellKit/Process/ProcessLauncher.swift index a8a5505..6c5cb7f 100644 --- a/Sources/ShellKit/Process/ProcessLauncher.swift +++ b/Sources/ShellKit/Process/ProcessLauncher.swift @@ -51,7 +51,12 @@ public protocol ProcessLauncher: Sendable { /// ``ProcessLaunchUnsupportedOnThisPlatform`` (real exec is /// unavailable on iOS / tvOS / watchOS / visionOS), or any /// transport error from the underlying engine. - func launch( + /// + /// The 7-parameter signature mirrors the POSIX exec model + /// (program + args + env + cwd + stdin + stdout + stderr). + /// Bundling these into a struct would be a breaking protocol + /// change for every ShellKit consumer for no behavioural gain. + func launch( // swiftlint:disable:this function_parameter_count _ executable: Executable, arguments: Arguments, environment: Environment, diff --git a/Sources/ShellKit/Process/ProcessTable.swift b/Sources/ShellKit/Process/ProcessTable.swift index 3a58f91..b96b59d 100644 --- a/Sources/ShellKit/Process/ProcessTable.swift +++ b/Sources/ShellKit/Process/ProcessTable.swift @@ -22,6 +22,9 @@ public actor ProcessTable { /// or `.failed` when the Task completes; never removed (so `$!` /// references survive after the Task is done, matching bash). public struct Entry: Sendable { + // `Entry.State` is the meaningful name for callers; lifting it + // out would lose that grouping without buying anything. + // swiftlint:disable:next nesting public enum State: Sendable, Equatable { case running case exited(ExitStatus) @@ -40,7 +43,7 @@ public actor ProcessTable { /// errors never need to escape the Task boundary. private var tasks: [Int32: Task] = [:] /// Most-recently-spawned PID — what `$!` resolves to. - public private(set) var lastBackgroundPID: Int32? = nil + public private(set) var lastBackgroundPID: Int32? public init(startingAt: Int32 = 1000) { self.nextPID = startingAt @@ -69,8 +72,8 @@ public actor ProcessTable { // a (status, state) pair keeps that quiet. let task = Task { do { - let s = try await body() - return TaskOutcome(status: s, state: .exited(s)) + let status = try await body() + return TaskOutcome(status: status, state: .exited(status)) } catch is CancellationError { return TaskOutcome(status: ExitStatus(143), state: .cancelled) } catch { @@ -109,9 +112,9 @@ public actor ProcessTable { public func wait(pid: Int32) async -> ExitStatus? { if let entry = entries[pid] { switch entry.state { - case .exited(let s): + case .exited(let status): reap(pid: pid) - return s + return status case .failed: reap(pid: pid) return ExitStatus(1) @@ -147,7 +150,7 @@ public actor ProcessTable { let pending = entries.keys.sorted() var last = ExitStatus.success for pid in pending { - if let s = await wait(pid: pid) { last = s } + if let status = await wait(pid: pid) { last = status } } return last } @@ -158,7 +161,7 @@ public actor ProcessTable { /// embedder's REPL can auto-reap at the prompt boundary the way /// real bash does between commands. public func reap(pid: Int32) { - guard let e = entries[pid], e.state != .running else { return } + guard let entry = entries[pid], entry.state != .running else { return } entries.removeValue(forKey: pid) tasks.removeValue(forKey: pid) } @@ -195,12 +198,12 @@ public actor ProcessTable { } private func markFinished(pid: Int32, state: Entry.State) { - if var e = entries[pid] { + if var entry = entries[pid] { // Don't overwrite a state we set deliberately (e.g. a // signal landing right at the same moment as natural exit). - if case .running = e.state { - e.state = state - entries[pid] = e + if case .running = entry.state { + entry.state = state + entries[pid] = entry } } // Drop the strong Task reference now that it's done. diff --git a/Sources/ShellKit/Process/SandboxedDenyLauncher.swift b/Sources/ShellKit/Process/SandboxedDenyLauncher.swift index 0a09138..6ca5445 100644 --- a/Sources/ShellKit/Process/SandboxedDenyLauncher.swift +++ b/Sources/ShellKit/Process/SandboxedDenyLauncher.swift @@ -16,6 +16,9 @@ public struct SandboxedDenyLauncher: ProcessLauncher { self.reason = reason } + // Mirrors the ProcessLauncher protocol signature — see protocol + // for why 7 parameters is the canonical exec contract. + // swiftlint:disable:next function_parameter_count public func launch( _ executable: Executable, arguments: Arguments, diff --git a/Sources/ShellKit/Sandbox/Sandbox+Factories.swift b/Sources/ShellKit/Sandbox/Sandbox+Factories.swift index 70258e8..9fa68a5 100644 --- a/Sources/ShellKit/Sandbox/Sandbox+Factories.swift +++ b/Sources/ShellKit/Sandbox/Sandbox+Factories.swift @@ -154,10 +154,8 @@ extension Sandbox { ) throws { if url.isFileURL { let candidate = canonicalizeForCheck(url.path) - for root in canonicalRoots { - if pathHasPrefix(candidate, prefix: root) { - return - } + for root in canonicalRoots where pathHasPrefix(candidate, prefix: root) { + return } // Build a hint pointing at where, conceptually, this URL // would land under the first root. Built from the diff --git a/Sources/ShellKit/Shell+Static.swift b/Sources/ShellKit/Shell+Static.swift index 8200904..ea0183d 100644 --- a/Sources/ShellKit/Shell+Static.swift +++ b/Sources/ShellKit/Shell+Static.swift @@ -59,7 +59,7 @@ public extension Shell { // shell — a standalone CLI just wants the host's directories. static var homeDirectory: URL { - if let sb = current.sandbox { return sb.homeDirectory } + if let sandbox = current.sandbox { return sandbox.homeDirectory } #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) return URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) #else @@ -73,14 +73,14 @@ public extension Shell { } static var cachesDirectory: URL { - if let sb = current.sandbox { return sb.cachesDirectory } + if let sandbox = current.sandbox { return sandbox.cachesDirectory } return FileManager.default .urls(for: .cachesDirectory, in: .userDomainMask).first ?? FileManager.default.temporaryDirectory } static var documentsDirectory: URL { - if let sb = current.sandbox { return sb.documentsDirectory } + if let sandbox = current.sandbox { return sandbox.documentsDirectory } return FileManager.default .urls(for: .documentDirectory, in: .userDomainMask).first ?? homeDirectory.appendingPathComponent( @@ -88,7 +88,7 @@ public extension Shell { } static var downloadsDirectory: URL { - if let sb = current.sandbox { return sb.downloadsDirectory } + if let sandbox = current.sandbox { return sandbox.downloadsDirectory } return FileManager.default .urls(for: .downloadsDirectory, in: .userDomainMask).first ?? homeDirectory.appendingPathComponent( @@ -96,7 +96,7 @@ public extension Shell { } static var libraryDirectory: URL { - if let sb = current.sandbox { return sb.libraryDirectory } + if let sandbox = current.sandbox { return sandbox.libraryDirectory } return FileManager.default .urls(for: .libraryDirectory, in: .userDomainMask).first ?? homeDirectory.appendingPathComponent( @@ -104,7 +104,7 @@ public extension Shell { } static var moviesDirectory: URL { - if let sb = current.sandbox { return sb.moviesDirectory } + if let sandbox = current.sandbox { return sandbox.moviesDirectory } return FileManager.default .urls(for: .moviesDirectory, in: .userDomainMask).first ?? homeDirectory.appendingPathComponent( @@ -112,7 +112,7 @@ public extension Shell { } static var musicDirectory: URL { - if let sb = current.sandbox { return sb.musicDirectory } + if let sandbox = current.sandbox { return sandbox.musicDirectory } return FileManager.default .urls(for: .musicDirectory, in: .userDomainMask).first ?? homeDirectory.appendingPathComponent( @@ -120,7 +120,7 @@ public extension Shell { } static var picturesDirectory: URL { - if let sb = current.sandbox { return sb.picturesDirectory } + if let sandbox = current.sandbox { return sandbox.picturesDirectory } return FileManager.default .urls(for: .picturesDirectory, in: .userDomainMask).first ?? homeDirectory.appendingPathComponent( @@ -128,7 +128,7 @@ public extension Shell { } static var sharedPublicDirectory: URL { - if let sb = current.sandbox { return sb.sharedPublicDirectory } + if let sandbox = current.sandbox { return sandbox.sharedPublicDirectory } return FileManager.default .urls(for: .sharedPublicDirectory, in: .userDomainMask).first ?? homeDirectory.appendingPathComponent( @@ -136,7 +136,7 @@ public extension Shell { } static var trashDirectory: URL { - if let sb = current.sandbox { return sb.trashDirectory } + if let sandbox = current.sandbox { return sandbox.trashDirectory } return FileManager.default .urls(for: .trashDirectory, in: .userDomainMask).first ?? homeDirectory.appendingPathComponent( @@ -144,7 +144,7 @@ public extension Shell { } static var userDirectory: URL { - if let sb = current.sandbox { return sb.userDirectory } + if let sandbox = current.sandbox { return sandbox.userDirectory } return FileManager.default .urls(for: .userDirectory, in: .userDomainMask).first ?? homeDirectory @@ -166,8 +166,7 @@ public extension Shell { /// replace `print(` with `Shell.print(`. static func print(_ items: Any..., separator: String = " ", - terminator: String = "\n") - { + terminator: String = "\n") { var rendered = "" var first = true for item in items { diff --git a/Tests/ShellKitTests/ProcessLauncherTests.swift b/Tests/ShellKitTests/ProcessLauncherTests.swift index 7a0de66..50b24b8 100644 --- a/Tests/ShellKitTests/ProcessLauncherTests.swift +++ b/Tests/ShellKitTests/ProcessLauncherTests.swift @@ -71,7 +71,7 @@ private let supportsPosixShell: Bool = { #expect(record.terminationStatus.isSuccess) #expect(record.terminationStatus == .exited(0)) - let out = String(decoding: record.standardOutput, as: UTF8.self) + let out = String(bytes: record.standardOutput, encoding: .utf8) ?? "" // `echo hi` adds a trailing newline. #expect(out == "hi\n") @@ -99,7 +99,7 @@ private let supportsPosixShell: Bool = { error: stderr) #expect(record.terminationStatus.isSuccess) - let errText = String(decoding: record.standardError, as: UTF8.self) + let errText = String(bytes: record.standardError, encoding: .utf8) ?? "" #expect(errText == "to-err\n") stderr.finish() @@ -144,7 +144,7 @@ private let supportsPosixShell: Bool = { error: OutputSink()) #expect(record.terminationStatus.isSuccess) - let out = String(decoding: record.standardOutput, as: UTF8.self) + let out = String(bytes: record.standardOutput, encoding: .utf8) ?? "" #expect(out == "hello-from-shellkit") } @@ -170,7 +170,7 @@ private let supportsPosixShell: Bool = { error: OutputSink()) #expect(record.terminationStatus.isSuccess) - let out = String(decoding: record.standardOutput, as: UTF8.self) + let out = (String(bytes: record.standardOutput, encoding: .utf8) ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) // Bypass /var → /private/var canonicalisation pitfalls by // anchoring on the unique component we created. @@ -193,7 +193,7 @@ private let supportsPosixShell: Bool = { error: OutputSink()) #expect(record.terminationStatus.isSuccess) - let out = String(decoding: record.standardOutput, as: UTF8.self) + let out = String(bytes: record.standardOutput, encoding: .utf8) ?? "" #expect(out == "piped-in") } @@ -235,8 +235,8 @@ private let supportsPosixShell: Bool = { Issue.record("expected ProcessLaunchDenied") } catch let denial as ProcessLaunchDenied { #expect(denial.reason == "test deny") - if case .name(let n) = denial.executable.storage { - #expect(n == "echo") + if case .name(let name) = denial.executable.storage { + #expect(name == "echo") } else { Issue.record("unexpected executable storage") } @@ -271,6 +271,8 @@ private let supportsPosixShell: Bool = { /// record — and throws ``ProcessLaunchUnresolved`` for everything /// else, so ``ChainLauncher`` falls through to the tail. private struct OnlyFooLauncher: ProcessLauncher { + // Mirrors the ProcessLauncher protocol signature. + // swiftlint:disable:next function_parameter_count func launch( _ executable: Executable, arguments: Arguments, @@ -309,7 +311,7 @@ private let supportsPosixShell: Bool = { error: OutputSink()) #expect(record.terminationStatus == .exited(0)) - let out = String(decoding: record.standardOutput, as: UTF8.self) + let out = String(bytes: record.standardOutput, encoding: .utf8) ?? "" #expect(out == "foo-builtin\n") } @@ -331,7 +333,7 @@ private let supportsPosixShell: Bool = { output: fooOut, error: OutputSink()) #expect(fooRec.terminationStatus == .exited(0)) - let fooText = String(decoding: fooRec.standardOutput, as: UTF8.self) + let fooText = String(bytes: fooRec.standardOutput, encoding: .utf8) ?? "" #expect(fooText == "foo-builtin\n") // `echo` falls through to the real exec engine. @@ -345,7 +347,7 @@ private let supportsPosixShell: Bool = { output: echoOut, error: OutputSink()) #expect(echoRec.terminationStatus.isSuccess) - let echoText = String(decoding: echoRec.standardOutput, as: UTF8.self) + let echoText = String(bytes: echoRec.standardOutput, encoding: .utf8) ?? "" #expect(echoText == "chained\n") } diff --git a/Tests/ShellKitTests/ShellTests.swift b/Tests/ShellKitTests/ShellTests.swift index 950f182..bce5bcb 100644 --- a/Tests/ShellKitTests/ShellTests.swift +++ b/Tests/ShellKitTests/ShellTests.swift @@ -142,15 +142,15 @@ import Testing let viaDescribing = String(describing: denial) let viaLocalized = denial.errorDescription ?? "" - for s in [viaInterpolation, viaDescribing, viaLocalized] { - #expect(s.contains("host 'github.com' is not in the sandbox allowlist"), - "expected reason in: \(s)") - #expect(!s.contains("/secret/host/root"), - "host path leaked into: \(s)") - #expect(!s.contains("suggestion"), - "field name leaked into: \(s)") - #expect(!s.contains("Denial("), - "struct shape leaked into: \(s)") + for output in [viaInterpolation, viaDescribing, viaLocalized] { + #expect(output.contains("host 'github.com' is not in the sandbox allowlist"), + "expected reason in: \(output)") + #expect(!output.contains("/secret/host/root"), + "host path leaked into: \(output)") + #expect(!output.contains("suggestion"), + "field name leaked into: \(output)") + #expect(!output.contains("Denial("), + "struct shape leaked into: \(output)") } } }