diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index c2247dc..9a5d0e8 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -13,8 +13,10 @@ on: jobs: spm: name: SwiftPM build and test - runs-on: macos-latest + runs-on: macos-13 steps: + - run: | + sudo xcode-select -s /Applications/Xcode_15.0.app - uses: actions/checkout@v3 - name: Build swift packages run: swift build -v @@ -22,8 +24,10 @@ jobs: run: swift test -v carthage: name: Xcode project build and test - runs-on: macos-latest + runs-on: macos-13 steps: + - run: | + sudo xcode-select -s /Applications/Xcode_15.0.app - uses: actions/checkout@v3 - name: Build xcode project run: xcodebuild build -scheme 'SubprocessMocks' -derivedDataPath .build @@ -31,8 +35,10 @@ jobs: run: xcodebuild test -scheme 'Subprocess' -derivedDataPath .build cocoapods: name: Pod lib lint - runs-on: macos-latest + runs-on: macos-13 steps: + - run: | + sudo xcode-select -s /Applications/Xcode_15.0.app - uses: actions/checkout@v3 - name: Lib lint - run: pod lib lint --verbose Subprocess.podspec + run: pod lib lint --verbose Subprocess.podspec --allow-warnings diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index a00a0dd..579b7b8 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -14,8 +14,10 @@ concurrency: jobs: build_docs: name: Build and Archive Docs - runs-on: macos-12 + runs-on: macos-13 steps: + - run: | + sudo xcode-select -s /Applications/Xcode_15.0.app - name: Checkout uses: actions/checkout@v3 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..1d91053 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,9 @@ +disabled_rules: +- trailing_whitespace # Xcode automatically adds space for new lines +- line_length # IDE is good at wrapping long lines +- function_body_length +- file_length # doesn't play nice when you need to have private in the same file +- nesting +- large_tuple +- colon # doesn't follow Swift formatting +- type_body_length # XCTest subclasses and arbitrary depending on type diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index f5cf9f7..393d4dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,24 @@ ## Subprocess -Subprocess is a Swift library for macOS providing interfaces for both synchronous and asynchronous process execution. +Subprocess is a Swift library for macOS providing interfaces for external process execution. All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 3.0.0 - 2023-10-13 + +### Added +- Methods to `Subprocess` that support Swift Concurrency. +- `Subprocess.run(standardInput:options:)` can run interactive commands. + +### Changed +- Breaking: `Subprocess.init` no longer accepts an argument for a dispatch queue's quality of service since the underlying implementation now uses Swift Concurrency and not GCD. +- Breaking: `Input`s `text` case no longer accepts an encoding as utf8 is overwhelmingly common. Instead convert the string to data explicitly if an alternate encoding is required. +- `Shell` and `SubprocessError` have been deprecated in favor of using new replacement methods that support Swift Concurrency and that no longer have a synchronized wait. +- Swift 5.9 (Xcode 15) is now the package minimum required to build. + ## 2.0.0 - 2021-07-01 ### Changed diff --git a/LICENSE b/LICENSE index 7f5f8ba..d35774a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Jamf Open Source Community +Copyright (c) 2023 Jamf Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Package.resolved b/Package.resolved index 568e78b..e65252d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,25 +1,23 @@ { - "object": { - "pins": [ - { - "package": "SwiftDocCPlugin", - "repositoryURL": "https://github.com/apple/swift-docc-plugin", - "state": { - "branch": null, - "revision": "9b1258905c21fc1b97bf03d1b4ca12c4ec4e5fda", - "version": "1.2.0" - } - }, - { - "package": "SymbolKit", - "repositoryURL": "https://github.com/apple/swift-docc-symbolkit", - "state": { - "branch": null, - "revision": "b45d1f2ed151d057b54504d653e0da5552844e34", - "version": "1.0.0" - } + "pins" : [ + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", + "version" : "1.3.0" } - ] - }, - "version": 1 + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + } + ], + "version" : 2 } diff --git a/Package.swift b/Package.swift index 9ea09d9..f72edb6 100644 --- a/Package.swift +++ b/Package.swift @@ -1,10 +1,10 @@ -// swift-tools-version:5.1 +// swift-tools-version: 5.9 import PackageDescription let package = Package( name: "Subprocess", - platforms: [ .macOS(.v10_13) ], + platforms: [ .macOS("10.15.4") ], products: [ .library( name: "Subprocess", @@ -28,7 +28,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") + .package(url: "https://github.com/apple/swift-docc-plugin", .upToNextMajor(from: "1.0.0")) ], targets: [ .target( diff --git a/README.md b/README.md index 20a290b..4ffbe4b 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,9 @@ Subprocess is a Swift library for macOS providing interfaces for both synchronou SubprocessMocks can be used in unit tests for quick and highly customizable mocking and verification of Subprocess usage. - [Usage](#usage) - - [Shell](#shell-class) - - [Input](#command-input) - [Data](#input-for-data), [Text](#input-for-text), [File](#input-for-file-url) - - [Output](#command-output) - [Data](#output-as-data), [Text](#output-as-string), [JSON](#output-as-json), [Decodable JSON object](#output-as-decodable-object-from-json), [Property list](#output-as-property-list), [Decodable property list object](#output-as-decodable-object-from-property-list) - - [Subprocess](#subprocess-class) + - [Subprocess Class](#subprocess-class) + - [Command Input](#command-input) - [Data](#input-for-data), [Text](#input-for-text), [File](#input-for-file-url) + - [Command Output](#command-output) - [Data](#output-as-data), [Text](#output-as-string), [Decodable JSON object](#output-as-decodable-object-from-json), [Decodable property list object](#output-as-decodable-object-from-property-list) - [Installation](#installation) - [SwiftPM](#swiftpm) - [Cocoapods](#cocoapods) @@ -24,44 +23,34 @@ SubprocessMocks can be used in unit tests for quick and highly customizable mock [Full Documentation](./docs/index.html) # Usage -### Shell Class -The Shell class can be used for synchronous command execution. +### Subprocess Class +The `Subprocess` class can be used for command execution. #### Command Input ###### Input for data ```swift -let inputData: Data = ... -let data = try Shell(["/usr/bin/grep", "Hello"]).exec(input: .data(inputData)) +let inputData = Data("hello world".utf8) +let data = try await Subprocess.data(for: ["/usr/bin/grep", "hello"], standardInput: inputData) ``` ###### Input for text ```swift -let data = try Shell(["/usr/bin/grep", "Hello"]).exec(input: .text("Hello world")) +let data = try await Subprocess.data(for: ["/usr/bin/grep", "hello"], standardInput: "hello world") ``` ###### Input for file URL ```swift -let url = URL(fileURLWithPath: "/path/to/input/file") -let data = try Shell(["/usr/bin/grep", "foo"]).exec(input: .file(url: url)) -``` -###### Input for file path -```swift -let data = try Shell(["/usr/bin/grep", "foo"]).exec(input: .file(path: "/path/to/input/file")) +let data = try await Subprocess.data(for: ["/usr/bin/grep", "foo"], standardInput: URL(filePath: "/path/to/input/file")) ``` #### Command Output ###### Output as Data ```swift -let data = try Shell(["/usr/bin/sw_vers"]).exec() +let data = try await Subprocess.data(for: ["/usr/bin/sw_vers"]) ``` ###### Output as String ```swift -let text = try Shell(["/usr/bin/sw_vers"]).exec(encoding: .utf8) -``` -###### Output as JSON (Array or Dictionary) -```swift -let command = ["/usr/bin/log", "show", "--style", "json", "--last", "5m"] -let logs: [[String: Any]] = try Shell(command).execJSON()) +let string = try await Subprocess.string(for: ["/usr/bin/sw_vers"]) ``` ###### Output as decodable object from JSON ```swift @@ -70,13 +59,8 @@ struct LogMessage: Codable { var category: String var machTimestamp: UInt64 } -let command = ["/usr/bin/log", "show", "--style", "json", "--last", "5m"] -let logs: [LogMessage] = try Shell(command).exec(decoder: JSONDecoder()) -``` -###### Output as Property List (Array or Dictionary) -```swift -let command = ["/bin/cat", "/System/Library/CoreServices/SystemVersion.plist"] -let dictionary: [String: Any] = try Shell(command).execPropertyList()) + +let result: [LogMessage] = try await Subprocess.value(for: ["/usr/bin/log", "show", "--style", "json", "--last", "30s"], decoder: JSONDecoder()) ``` ###### Output as decodable object from Property List ```swift @@ -86,26 +70,71 @@ struct SystemVersion: Codable { } var version: String } -let command = ["/bin/cat", "/System/Library/CoreServices/SystemVersion.plist"] -let result: SystemVersion = try Shell(command).exec(decoder: PropertyListDecoder()) + +let result: SystemVersion = try await Subprocess.value(for: ["/bin/cat", "/System/Library/CoreServices/SystemVersion.plist"], decoder: PropertyListDecoder()) ``` ###### Output mapped to other type ```swift -let enabled = try Shell(["csrutil", "status"]).exec(encoding: .utf8) { _, txt in txt.contains("enabled") } +let enabled = try await Subprocess(["/usr/bin/csrutil", "status"]).run().standardOutput.lines.first(where: { $0.contains("enabled") } ) != nil ``` - ###### Output options ```swift -let command: [String] = ... -let errorText = try Shell(command).exec(options: .stderr, encoding: .utf8) -let outputText = try Shell(command).exec(options: .stdout, encoding: .utf8) -let combinedData = try Shell(command).exec(options: .combined) -``` -### Subprocess Class -The Subprocess class can be used for asynchronous command execution. +let errorText = try await Subprocess.string(for: ["/usr/bin/cat", "/non/existent/file.txt"], options: .returnStandardError) +let outputText = try await Subprocess.string(for: ["/usr/bin/sw_vers"]) +async let (standardOutput, standardError, _) = try Subprocess(["/usr/bin/csrutil", "status"]).run() +let combinedOutput = try await [standardOutput.string(), standardError.string()] +``` ###### Handling output as it is read ```swift +let (stream, input) = { + var input: AsyncStream.Continuation! + let stream: AsyncStream = AsyncStream { continuation in + input = continuation + } + + return (stream, input!) +}() + +let subprocess = Subprocess(["/bin/cat"]) +let (standardOutput, _, waitForExit) = try subprocess.run(standardInput: stream) + +input.yield("hello\n") + +Task { + for await line in standardOutput.lines { + switch line { + case "hello": + input.yield("world\n") + case "world": + input.yield("and\nuniverse") + input.finish() + case "universe": + await waitForExit() + break + default: + continue + } + } +} +``` +###### Handling output on termination +```swift +let process = Subprocess(["/usr/bin/csrutil", "status"]) +let (standardOutput, standardError, waitForExit) = try process.run() +async let (stdout, stderr) = (standardOutput, standardError) +let combinedOutput = await [stdout.data(), stderr.data()] + +await waitForExit() + +if process.exitCode == 0 { + // Do something with output data +} else { + // Handle failure +} +``` +###### Closure based callbacks +```swift let command: [String] = ... let process = Subprocess(command) @@ -120,7 +149,7 @@ try process.launch(outputHandler: { data in // have completed. }) ``` -###### Handling output on termination +###### Handing output on termination with a closure ```swift let command: [String] = ... let process = Subprocess(command) @@ -131,15 +160,26 @@ try process.launch { (process, outputData, errorData) in } else { // Handle failure } -} ``` ## Installation ### SwiftPM ```swift -dependencies: [ - .package(url: "https://github.com/jamf/Subprocess.git", from: "1.0.0") -] +let package = Package( + // name, platforms, products, etc. + dependencies: [ + // other dependencies + .package(url: "https://github.com/jamf/Subprocess.git", .upToNextMajor(from: "3.0.0")), + ], + targets: [ + .target(name: "", + dependencies: [ + // other dependencies + .product(name: "Subprocess"), + ]), + // other targets + ] +) ``` ### Cocoapods ```ruby diff --git a/Sources/Subprocess/AsyncSequence+Additions.swift b/Sources/Subprocess/AsyncSequence+Additions.swift new file mode 100644 index 0000000..f39bd85 --- /dev/null +++ b/Sources/Subprocess/AsyncSequence+Additions.swift @@ -0,0 +1,50 @@ +// +// AsyncSequence+Additions.swift +// Subprocess +// +// MIT License +// +// Copyright (c) 2023 Jamf +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +extension AsyncSequence { + /// Returns a sequence of all the elements. + public func sequence() async rethrows -> [Element] { + try await reduce(into: [Element]()) { $0.append($1) } + } +} + +extension AsyncSequence where Element == UInt8 { + /// Returns a `Data` representation. + public func data() async rethrows -> Data { + Data(try await sequence()) + } + + public func string() async rethrows -> String { + if #available(macOS 12.0, *) { + String(try await characters.sequence()) + } else { + String(decoding: try await data(), as: UTF8.self) + } + } +} diff --git a/Sources/Subprocess/AsyncStream+Yield.swift b/Sources/Subprocess/AsyncStream+Yield.swift new file mode 100644 index 0000000..383edba --- /dev/null +++ b/Sources/Subprocess/AsyncStream+Yield.swift @@ -0,0 +1,69 @@ +// +// AsyncStream+Yield.swift +// Subprocess +// +// MIT License +// +// Copyright (c) 2023 Jamf +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +extension AsyncStream.Continuation where Element == UInt8 { + /// Resume the task awaiting the next iteration point by having it return + /// normally from its suspension point with the given data. + /// + /// - Parameter value: The value to yield from the continuation. + /// - Returns: A `YieldResult` that indicates the success or failure of the + /// yield operation from the last byte of the `Data`. + /// + /// If nothing is awaiting the next value, this method attempts to buffer the + /// result's element. + /// + /// This can be called more than once and returns to the caller immediately + /// without blocking for any awaiting consumption from the iteration. + @discardableResult public func yield(_ value: Data) -> AsyncStream.Continuation.YieldResult? { + var yieldResult: AsyncStream.Continuation.YieldResult? + + for byte in value { + yieldResult = yield(byte) + } + + return yieldResult + } + + /// Resume the task awaiting the next iteration point by having it return + /// normally from its suspension point with the given string. + /// + /// - Parameter value: The value to yield from the continuation. + /// - Returns: A `YieldResult` that indicates the success or failure of the + /// yield operation from the last byte of the string after being converted to `Data`. + /// + /// If nothing is awaiting the next value, this method attempts to buffer the + /// result's element. + /// + /// This can be called more than once and returns to the caller immediately + /// without blocking for any awaiting consumption from the iteration. + @discardableResult public func yield(_ value: String) -> AsyncStream.Continuation.YieldResult? { + // unicode encodings are safe to explicity unwrap + yield(value.data(using: .utf8)!) + } +} diff --git a/Sources/Subprocess/Errors.swift b/Sources/Subprocess/Errors.swift index b4dadf3..995ef5c 100644 --- a/Sources/Subprocess/Errors.swift +++ b/Sources/Subprocess/Errors.swift @@ -4,7 +4,7 @@ // // MIT License // -// Copyright (c) 2018 Jamf Software +// Copyright (c) 2023 Jamf // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -28,6 +28,7 @@ import Foundation /// Type representing possible errors +@available(*, deprecated, message: "This type is no longer used with non-deprecated methods") public enum SubprocessError: Error { /// The process completed with a non-zero exit code @@ -47,15 +48,16 @@ public enum SubprocessError: Error { case outputStringEncodingError } +@available(*, deprecated, message: "This type is no longer used with non-deprecated methods") extension SubprocessError: LocalizedError { public var errorDescription: String? { switch self { case .exitedWithNonZeroStatus(_, let errorMessage): return "\(errorMessage)" - case .unexpectedPropertyListObject(_): + case .unexpectedPropertyListObject: // Ignoring the plist contents parameter as we don't want that in the error message return "The property list object could not be cast to expected type" - case .unexpectedJSONObject(_): + case .unexpectedJSONObject: // Ignoring the json contents parameter as we don't want that in the error message return "The JSON object could not be cast to expected type" case .inputStringEncodingError: @@ -67,6 +69,7 @@ extension SubprocessError: LocalizedError { } /// Common NSError methods for better interop with Objective-C +@available(*, deprecated, message: "This type is no longer used with non-deprecated methods") extension SubprocessError: CustomNSError { public var errorCode: Int { switch self { diff --git a/Sources/Subprocess/Input.swift b/Sources/Subprocess/Input.swift index 4c9f4b6..1598ae4 100644 --- a/Sources/Subprocess/Input.swift +++ b/Sources/Subprocess/Input.swift @@ -4,7 +4,7 @@ // // MIT License // -// Copyright (c) 2018 Jamf Software +// Copyright (c) 2023 Jamf // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -28,7 +28,6 @@ import Foundation /// Interface representing input to the process public struct Input { - /// Reference to the input value public enum Value { @@ -36,7 +35,7 @@ public struct Input { case data(Data) /// Text to be written to stdin of the child process - case text(String, String.Encoding) + case text(String) /// File to be written to stdin of the child process case file(URL) @@ -55,8 +54,8 @@ public struct Input { /// Creates input for writing text to stdin of the child process /// - Parameter text: Text written to stdin of the child process /// - Returns: New Input instance - public static func text(_ text: String, encoding: String.Encoding = .utf8) -> Input { - return Input(value: .text(text, encoding)) + public static func text(_ text: String) -> Input { + return Input(value: .text(text)) } /// Creates input for writing contents of file at path to stdin of the child process @@ -78,10 +77,15 @@ public struct Input { func createPipeOrFileHandle() throws -> Any { switch value { case .data(let data): - return SubprocessDependencyBuilder.shared.makeInputPipe(data: data) - case .text(let text, let encoding): - guard let data = text.data(using: encoding) else { throw SubprocessError.inputStringEncodingError } - return SubprocessDependencyBuilder.shared.makeInputPipe(data: data) + return try SubprocessDependencyBuilder.shared.makeInputPipe(sequence: AsyncStream(UInt8.self, { continuation in + continuation.yield(data) + continuation.finish() + })) + case .text(let text): + return try SubprocessDependencyBuilder.shared.makeInputPipe(sequence: AsyncStream(UInt8.self, { continuation in + continuation.yield(Data(text.utf8)) + continuation.finish() + })) case .file(let url): return try SubprocessDependencyBuilder.shared.makeInputFileHandle(url: url) } diff --git a/Sources/Subprocess/Pipe+AsyncBytes.swift b/Sources/Subprocess/Pipe+AsyncBytes.swift new file mode 100644 index 0000000..e1d14f9 --- /dev/null +++ b/Sources/Subprocess/Pipe+AsyncBytes.swift @@ -0,0 +1,67 @@ +// +// Pipe+AsyncBytes.swift +// Subprocess +// +// MIT License +// +// Copyright (c) 2023 Jamf +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +// `FileHandle.AsyncBytes` has a bug that can block reading of stdout when also reading stderr. +// We can avoid this problem if we create independent handlers. +extension Pipe { + /// Convenience for reading bytes from the pipe's file handle. + public struct AsyncBytes: AsyncSequence { + public typealias Element = UInt8 + + let pipe: Pipe + + public func makeAsyncIterator() -> AsyncStream.Iterator { + AsyncStream { continuation in + pipe.fileHandleForReading.readabilityHandler = { handle in + let availableData = handle.availableData + + guard !availableData.isEmpty else { + handle.readabilityHandler = nil + continuation.finish() + return + } + + for byte in availableData { + if case .terminated = continuation.yield(byte) { + break + } + } + } + + continuation.onTermination = { _ in + pipe.fileHandleForReading.readabilityHandler = nil + } + }.makeAsyncIterator() + } + } + + public var bytes: AsyncBytes { + AsyncBytes(pipe: self) + } +} diff --git a/Sources/Subprocess/Shell.swift b/Sources/Subprocess/Shell.swift index 59922b8..f09642c 100644 --- a/Sources/Subprocess/Shell.swift +++ b/Sources/Subprocess/Shell.swift @@ -4,7 +4,7 @@ // // MIT License // -// Copyright (c) 2018 Jamf Software +// Copyright (c) 2023 Jamf // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -28,6 +28,7 @@ import Foundation /// Class used for synchronous process execution +@available(*, deprecated, message: "Use Swift Concurrency methods instead which are part of the Subprocess class") public class Shell { /// OptionSet representing output handling @@ -63,19 +64,33 @@ public class Shell { /// - transformBlock: Block executed given a reference to the completed process and the output /// - Returns: Process output as output type of the `transformBlock` /// - Throws: Error from process launch,`transformBlock` or failing create a string from the process output + @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)") public func exec(input: Input? = nil, options: OutputOptions = .stdout, transformBlock: (_ process: Subprocess, _ data: Data) throws -> T) throws -> T { - var stdoutBuffer = Data() - var stderrBuffer = Data() - try process.launch(input: input, - outputHandler: options.contains(.stdout) ? { stdoutBuffer.append($0) } : nil, - errorHandler: options.contains(.stderr) ? { stderrBuffer.append($0) } : nil) + let stdoutData = UnsafeData() + let stderrData = UnsafeData() + let outputHandler: (@Sendable (Data) -> Void)? = if options.contains(.stdout) { + { data in + stdoutData.append(data) + } + } else { + nil + } + let errorHandler: (@Sendable (Data) -> Void)? = if options.contains(.stderr) { + { data in + stderrData.append(data) + } + } else { + nil + } + + try process.launch(input: input, outputHandler: outputHandler, errorHandler: errorHandler) process.waitForTermination() // doing this so we can consistently get stdout before stderr when using the combined option var combinedBuffer = Data() - combinedBuffer.append(stdoutBuffer) - combinedBuffer.append(stderrBuffer) + combinedBuffer.append(stdoutData.value()) + combinedBuffer.append(stderrData.value()) return try transformBlock(process, combinedBuffer) } @@ -86,6 +101,7 @@ public class Shell { /// - options: Output options defining the output to process (Default: .stdout) /// - Returns: Process output data /// - Throws: Error from process launch or if termination code is none-zero + @available(*, deprecated, message: "Use Subprocess.data(for:standardInput:options:)") public func exec(input: Input? = nil, options: OutputOptions = .stdout) throws -> Data { return try exec(input: input, options: options) { process, data in let exitCode = process.exitCode @@ -107,6 +123,7 @@ public class Shell { /// - transformBlock: Block executed given a reference to the completed process and the output as a string /// - Returns: Process output as output type of the `transformBlock` /// - Throws: Error from process launch,`transformBlock` or failing create a string from the process output + @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)") public func exec(input: Input? = nil, options: OutputOptions = .stdout, encoding: String.Encoding, @@ -127,6 +144,7 @@ public class Shell { /// - encoding: Encoding to use for the output /// - Returns: Process output as a String /// - Throws: Error from process launch, if termination code is none-zero or failing create a string from the output + @available(*, deprecated, message: "Use Subprocess.string(for:standardInput:options:)") public func exec(input: Input? = nil, options: OutputOptions = .stdout, encoding: String.Encoding) throws -> String { @@ -146,6 +164,7 @@ public class Shell { /// - options: Output options defining the output to process (Default: .stdout) /// - Returns: Process output as an Array or Dictionary /// - Throws: Error from process launch, JSONSerialization or failing to cast to expected type + @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)") public func execJSON(input: Input? = nil, options: OutputOptions = .stdout) throws -> T { return try exec(input: input, options: options) { _, data in let object = try JSONSerialization.jsonObject(with: data, options: []) @@ -163,6 +182,7 @@ public class Shell { /// - options: Output options defining the output to process (Default: .stdout) /// - Returns: Process output as an Array or Dictionary /// - Throws: Error from process launch, PropertyListSerialization or failing to cast to expected type + @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)") public func execPropertyList(input: Input? = nil, options: OutputOptions = .stdout) throws -> T { return try exec(input: input, options: options) { _, data in let object = try PropertyListSerialization.propertyList(from: data, options: [], format: .none) @@ -181,6 +201,7 @@ public class Shell { /// - decoder: JSONDecoder instance used for decoding the output object /// - Returns: Process output as the decodable object type /// - Throws: Error from process launch or JSONDecoder + @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)") public func exec(input: Input? = nil, options: OutputOptions = .stdout, decoder: JSONDecoder) throws -> T { @@ -195,6 +216,7 @@ public class Shell { /// - decoder: PropertyListDecoder instance used for decoding the output object /// - Returns: Process output as the decodable object type /// - Throws: Error from process launch or PropertyListDecoder + @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)") public func exec(input: Input? = nil, options: OutputOptions = .stdout, decoder: PropertyListDecoder) throws -> T { diff --git a/Sources/Subprocess/Subprocess.h b/Sources/Subprocess/Subprocess.h index ce53257..8a4a8c9 100644 --- a/Sources/Subprocess/Subprocess.h +++ b/Sources/Subprocess/Subprocess.h @@ -4,7 +4,7 @@ // // MIT License // -// Copyright (c) 2018 Jamf Software +// Copyright (c) 2023 Jamf // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/Sources/Subprocess/Subprocess.swift b/Sources/Subprocess/Subprocess.swift index 6698a32..5ce893f 100644 --- a/Sources/Subprocess/Subprocess.swift +++ b/Sources/Subprocess/Subprocess.swift @@ -4,7 +4,7 @@ // // MIT License // -// Copyright (c) 2018 Jamf Software +// Copyright (c) 2023 Jamf // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -26,48 +26,464 @@ // import Foundation +import Combine /// Class used for asynchronous process execution -open class Subprocess { - +open class Subprocess: @unchecked Sendable { + /// Output options. + public struct OutputOptions: OptionSet { + public let rawValue: Int + + /// Buffer standard output. + public static let standardOutput = Self(rawValue: 1 << 0) + + /// Buffer standard error which may include useful error messages. + public static let standardError = Self(rawValue: 1 << 1) + + public init(rawValue: Int) { + self.rawValue = rawValue + } + } + /// Process reference - let reference: Process - + let process: Process + /// Process identifier - public var pid: Int32 { reference.processIdentifier } - + public var pid: Int32 { process.processIdentifier } + /// Exit code of the process - public var exitCode: Int32 { reference.terminationStatus } - + public var exitCode: Int32 { process.terminationStatus } + /// Returns whether the process is still running. - public var isRunning: Bool { reference.isRunning } - + public var isRunning: Bool { process.isRunning } + /// Reason for process termination - public var terminationReason: Process.TerminationReason { reference.terminationReason } - + public var terminationReason: Process.TerminationReason { process.terminationReason } + /// Reference environment property public var environment: [String: String]? { get { - reference.environment + process.environment } set { - reference.environment = newValue + process.environment = newValue } } - + + private lazy var group = DispatchGroup() + /// Creates new Subprocess /// /// - Parameter command: Command represented as an array of strings - public init(_ command: [String], qos: DispatchQoS = .default) { - reference = SubprocessDependencyBuilder.shared.makeProcess(command: command) - queue = DispatchQueue(label: "SubprocessQueue", - qos: qos, - attributes: [], - autoreleaseFrequency: .workItem, - target: nil) + public required init(_ command: [String]) { + process = SubprocessDependencyBuilder.shared.makeProcess(command: command) + } + + // You may ask yourself, "Well, how did I get here?" + // It would be nice if we could write something like: + // + // public func run(standardInput: Input? = nil, options: OutputOptions = [.standardOutput, .standardError]) throws -> (standardOutput: Pipe.AsyncBytes, standardError: Pipe.AsyncBytes, waitUntilExit: () async -> Void) where Input : AsyncSequence, Input.Element == UInt8 {} + // + // Then the equivelent convenience methods below could take an AsyncSequence as well in the same style. + // + // The problem with this is that AsyncSequence is a rethrowing protocol that has no primary associated type. So it's always up to the caller of the method to specify its type and if the default nil is used then it can't determine the type thus causing a compile time error. + // There are a few Swift Forum threads discussing the problems with AsyncSequence in interfaces: + // https://forums.swift.org/t/anyasyncsequence/50828/33 + // https://forums.swift.org/t/type-erasure-of-asyncsequences/66547/23 + // + // The solution used here is to unfortunately have an extra method that just omits the standard input when its not going to be used. I believe the interface is well defined this way and easier to use in the end without strange hacks or conversions to AsyncStream. + + /// Run a command. + /// + /// - Parameters: + /// - options: Options to control which output should be returned. + /// - Returns: The standard output and standard error as `Pipe.AsyncBytes` sequences and an optional closure that can be used to `await` the process until it has completed. + /// + /// Run a command and optionally read its output. + /// + /// let subprocess = Subprocess(["/bin/cat somefile"]) + /// let (standardOutput, _, waitForExit) = try subprocess.run() + /// + /// Task { + /// for await line in standardOutput.lines { + /// switch line { + /// case "hello": + /// await waitForExit() + /// break + /// default: + /// continue + /// } + /// } + /// } + /// + /// It is the callers responsibility to ensure that any reads occur if waiting for the process to exit otherwise a deadlock can happen if the process is waiting to write to its output buffer. + /// A task group can be used to wait for exit while reading the output. If the output is discardable consider passing (`[]`) an empty set for the options which effectively flushes output to null. + public func run(options: OutputOptions = [.standardOutput, .standardError]) throws -> (standardOutput: Pipe.AsyncBytes, standardError: Pipe.AsyncBytes, waitUntilExit: () async -> Void) { + let standardOutput: Pipe.AsyncBytes = { + if options.contains(.standardOutput) { + let pipe = Pipe() + + process.standardOutput = pipe + return pipe.bytes + } else { + let pipe = Pipe() + + defer { + try? pipe.fileHandleForReading.close() + } + + process.standardOutput = FileHandle.nullDevice + return pipe.bytes + } + }() + let standardError: Pipe.AsyncBytes = { + if options.contains(.standardError) { + let pipe = Pipe() + + process.standardError = pipe + return pipe.bytes + } else { + let pipe = Pipe() + + defer { + try? pipe.fileHandleForReading.close() + } + + process.standardError = FileHandle.nullDevice + return pipe.bytes + } + }() + + let terminationContinuation = TerminationContinuation() + let task: Task = Task.detached { + await withUnsafeContinuation { continuation in + Task { + await terminationContinuation.setContinuation(continuation) + } + } + } + let waitUntilExit = { + await task.value + } + + process.terminationHandler = { _ in + Task { + await terminationContinuation.resume() + } + } + + try process.run() + return (standardOutput, standardError, waitUntilExit) + } + + /// Run an interactive command. + /// + /// - Parameters: + /// - standardInput: An `AsyncSequence` that is used to supply input to the underlying process. + /// - options: Options to control which output should be returned. + /// - Returns: The standard output and standard error as `Pipe.AsyncBytes` sequences and an optional closure that can be used to `await` the process until it has completed. + /// + /// Run a command and interactively respond to output. + /// + /// let (stream, input) = { + /// var input: AsyncStream.Continuation! + /// let stream: AsyncStream = AsyncStream { continuation in + /// input = continuation + /// } + /// + /// return (stream, input!) + /// }() + /// + /// let subprocess = Subprocess(["/bin/cat"]) + /// let (standardOutput, _, waitForExit) = try subprocess.run(standardInput: stream) + /// + /// input.yield("hello\n") + /// + /// Task { + /// for await line in standardOutput.lines { + /// switch line { + /// case "hello": + /// input.yield("world\n") + /// case "world": + /// input.yield("and\nuniverse") + /// input.finish() + /// case "universe": + /// await waitForExit() + /// break + /// default: + /// continue + /// } + /// } + /// } + /// + public func run(standardInput: Input, options: OutputOptions = [.standardOutput, .standardError]) throws -> (standardOutput: Pipe.AsyncBytes, standardError: Pipe.AsyncBytes, waitUntilExit: () async -> Void) where Input : AsyncSequence, Input.Element == UInt8 { + process.standardInput = try SubprocessDependencyBuilder.shared.makeInputPipe(sequence: standardInput) + return try run(options: options) + } + + /// Suspends the command + public func suspend() -> Bool { + process.suspend() + } + + /// Resumes the command which was suspended + public func resume() -> Bool { + process.resume() + } + + /// Sends the command the term signal + public func kill() { + process.terminate() + } +} +// Methods for typical one-off acquisition of output from running some command. +extension Subprocess { + /// Additional configuration options. + public struct RunOptions: OptionSet { + public let rawValue: Int + + /// Throw an error if the process exited with a non-zero exit code. + public static let throwErrorOnNonZeroExit = Self(rawValue: 1 << 0) + + /// Return the output from standard error instead of standard output. + public static let returnStandardError = Self(rawValue: 1 << 1) + + public init(rawValue: Int) { + self.rawValue = rawValue + } } + + /// Retreive output as `Data` from running an external command. + /// - Parameters: + /// - command: An external command to run with optional arguments. + /// - standardInput: A type conforming to `DataProtocol` (typically a `Data` type) from which to read input to the external command. + /// - options: Options used to specify runtime behavior. + public static func data(for command: [String], standardInput: (any DataProtocol)? = nil, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> Data { + let subprocess = Self(command) + let (standardOutput, standardError, waitForExit) = if let standardInput { + try subprocess.run(standardInput: AsyncStream(UInt8.self, { continuation in + for byte in standardInput { + if case .terminated = continuation.yield(byte) { + break + } + } + + continuation.finish() + })) + } else { + try subprocess.run() + } + + // need to read output for processes that fill their buffers otherwise a wait could occur waiting for a read to clear the buffer + let result = await withTaskGroup(of: Void.self) { group in + let stdoutData = UnsafeData() + let stderrData = UnsafeData() + + group.addTask { + await withTaskCancellationHandler(operation: { + await waitForExit() + }, onCancel: { + subprocess.kill() + }) + } + group.addTask { + var bytes = [UInt8]() + + for await byte in standardOutput { + bytes.append(byte) + } + + stdoutData.set(Data(bytes)) + } + group.addTask { + var bytes = [UInt8]() + + for await byte in standardError { + bytes.append(byte) + } + + stderrData.set(Data(bytes)) + } + + for await _ in group { + // nothing to collect here + } + + return (standardOutputData: stdoutData.value(), standardErrorData: stderrData.value()) + } + try Task.checkCancellation() + + if options.contains(.throwErrorOnNonZeroExit), subprocess.process.terminationStatus != 0 { + throw Error.nonZeroExit(status: subprocess.process.terminationStatus, reason: subprocess.process.terminationReason, standardOutput: result.standardOutputData, standardError: String(decoding: result.standardErrorData, as: UTF8.self)) + } + + let data = if options.contains(.returnStandardError) { + result.standardErrorData + } else { + result.standardOutputData + } + + return data + } + + // MARK: Data convenience methods + + /// Retreive output as `Data` from running an external command. + /// - Parameters: + /// - command: An external command to run with optional arguments. + /// - standardInput: A `String` from which to send input to the external command. + /// - options: Options used to specify runtime behavior. + @inlinable + public static func data(for command: [String], standardInput: String, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> Data { + try await data(for: command, standardInput: standardInput.data(using: .utf8)!, options: options) + } + + /// Retreive output as `Data` from running an external command. + /// - Parameters: + /// - command: An external command to run with optional arguments. + /// - standardInput: A file `URL` from which to read input to the external command. + /// - options: Options used to specify runtime behavior. + public static func data(for command: [String], standardInput: URL, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> Data { + let subprocess = Self(command) + let (standardOutput, standardError, waitForExit) = if #available(macOS 12.0, *) { + try subprocess.run(standardInput: SubprocessDependencyBuilder.shared.makeInputFileHandle(url: standardInput).bytes) + } else if let fileData = try SubprocessDependencyBuilder.shared.makeInputFileHandle(url: standardInput).readToEnd(), !fileData.isEmpty { + try subprocess.run(standardInput: AsyncStream(UInt8.self, { continuation in + for byte in fileData { + if case .terminated = continuation.yield(byte) { + break + } + } + + continuation.finish() + })) + } else { + try subprocess.run() + } + + // need to read output for processes that fill their buffers otherwise a wait could occur waiting for a read to clear the buffer + let result = await withTaskGroup(of: Void.self) { group in + let stdoutData = UnsafeData() + let stderrData = UnsafeData() + + group.addTask { + await withTaskCancellationHandler(operation: { + await waitForExit() + }, onCancel: { + subprocess.kill() + }) + } + group.addTask { + var bytes = [UInt8]() + + for await byte in standardOutput { + bytes.append(byte) + } + + stdoutData.set(Data(bytes)) + } + group.addTask { + var bytes = [UInt8]() + + for await byte in standardError { + bytes.append(byte) + } + + stderrData.set(Data(bytes)) + } + + for await _ in group { + // nothing to collect + } + + return (standardOutputData: stdoutData.value(), standardErrorData: stderrData.value()) + } + try Task.checkCancellation() + + if options.contains(.throwErrorOnNonZeroExit), subprocess.process.terminationStatus != 0 { + throw Error.nonZeroExit(status: subprocess.process.terminationStatus, reason: subprocess.process.terminationReason, standardOutput: result.standardOutputData, standardError: String(decoding: result.standardErrorData, as: UTF8.self)) + } + + let data = if options.contains(.returnStandardError) { + result.standardErrorData + } else { + result.standardOutputData + } + + return data + } + + // MARK: String convenience methods + + /// Retreive output as a UTF8 `String` from running an external command. + /// - Parameters: + /// - command: An external command to run with optional arguments. + /// - standardInput: A type conforming to `DataProtocol` (typically a `Data` type) from which to read input to the external command. + /// - options: Options used to specify runtime behavior. + @inlinable + public static func string(for command: [String], standardInput: (any DataProtocol)? = nil, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> String { + String(decoding: try await data(for: command, standardInput: standardInput, options: options), as: UTF8.self) + } + + /// Retreive output as `String` from running an external command. + /// - Parameters: + /// - command: An external command to run with optional arguments. + /// - standardInput: A `String` from which to send input to the external command. + /// - options: Options used to specify runtime behavior. + @inlinable + public static func string(for command: [String], standardInput: String, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> String { + String(decoding: try await data(for: command, standardInput: standardInput, options: options), as: UTF8.self) + } + + /// Retreive output as `String` from running an external command. + /// - Parameters: + /// - command: An external command to run with optional arguments. + /// - standardInput: A file `URL` from which to read input to the external command. + /// - options: Options used to specify runtime behavior. + @inlinable + public static func string(for command: [String], standardInput: URL, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> String { + String(decoding: try await data(for: command, standardInput: standardInput, options: options), as: UTF8.self) + } + + // MARK: Decodable types convenience methods + + /// Retreive output from from running an external command. + /// - Parameters: + /// - command: An external command to run with optional arguments. + /// - standardInput: A type conforming to `DataProtocol` (typically a `Data` type) from which to read input to the external command. + /// - options: Options used to specify runtime behavior. + /// - decoder: A `TopLevelDecoder` that will be used to decode the data. + @inlinable + public static func value(for command: [String], standardInput: (any DataProtocol)? = nil, options: RunOptions = .throwErrorOnNonZeroExit, decoder: Decoder) async throws -> Content where Content : Decodable, Decoder : TopLevelDecoder, Decoder.Input == Data { + try await decoder.decode(Content.self, from: data(for: command, standardInput: standardInput, options: options)) + } + + /// Retreive output from from running an external command. + /// - Parameters: + /// - command: An external command to run with optional arguments. + /// - standardInput: A `String` from which to send input to the external command. + /// - options: Options used to specify runtime behavior. + /// - decoder: A `TopLevelDecoder` that will be used to decode the data. + @inlinable + public static func value(for command: [String], standardInput: String, options: RunOptions = .throwErrorOnNonZeroExit, decoder: Decoder) async throws -> Content where Content : Decodable, Decoder : TopLevelDecoder, Decoder.Input == Data { + try await decoder.decode(Content.self, from: data(for: command, standardInput: standardInput, options: options)) + } + + /// Retreive output from from running an external command. + /// - Parameters: + /// - command: An external command to run with optional arguments. + /// - standardInput: A file `URL` from which to read input to the external command. + /// - options: Options used to specify runtime behavior. + /// - decoder: A `TopLevelDecoder` that will be used to decode the data. + @inlinable + public static func value(for command: [String], standardInput: URL, options: RunOptions = .throwErrorOnNonZeroExit, decoder: Decoder) async throws -> Content where Content : Decodable, Decoder : TopLevelDecoder, Decoder.Input == Data { + try await decoder.decode(Content.self, from: data(for: command, standardInput: standardInput, options: options)) + } +} +// closure based methods +extension Subprocess { /// Launches command with read handlers and termination handler /// /// - Parameters: @@ -75,98 +491,134 @@ open class Subprocess { /// - outputHandler: Block called whenever new data is read from standard output of the process /// - errorHandler: Block called whenever new data is read from standard error of the process /// - terminationHandler: Block called when process has terminated and all output handlers have returned - public func launch(input: Input? = nil, - outputHandler: ((Data) -> Void)? = nil, - errorHandler: ((Data) -> Void)? = nil, - terminationHandler: ((Subprocess) -> Void)? = nil) throws { - - reference.standardInput = try input?.createPipeOrFileHandle() - - if let handler = outputHandler { - reference.standardOutput = createPipeWithReadabilityHandler(handler) + public func launch(input: Input? = nil, outputHandler: (@Sendable (Data) -> Void)? = nil, errorHandler: (@Sendable (Data) -> Void)? = nil, terminationHandler: (@Sendable (Subprocess) -> Void)? = nil) throws { + process.standardInput = try input?.createPipeOrFileHandle() + + process.standardOutput = if let outputHandler { + createPipeWithReadabilityHandler(outputHandler) } else { - reference.standardOutput = FileHandle.nullDevice + FileHandle.nullDevice } - - if let handler = errorHandler { - reference.standardError = createPipeWithReadabilityHandler(handler) + + process.standardError = if let errorHandler { + createPipeWithReadabilityHandler(errorHandler) } else { - reference.standardError = FileHandle.nullDevice + FileHandle.nullDevice } - + group.enter() - reference.terminationHandler = { [weak self] _ in - self?.group.leave() - self?.reference.terminationHandler = nil + process.terminationHandler = { [unowned self] _ in + group.leave() } - - group.notify(queue: queue) { + + group.notify(queue: .main) { terminationHandler?(self) } - - if #available(OSX 10.13, *) { - try reference.run() - } else { - reference.launch() - } + + try process.run() } - + /// Block type called for executing process returning data from standard out and standard error - public typealias DataTerminationHandler = (_ process: Subprocess, _ stdout: Data, _ stderr: Data) -> Void - + public typealias DataTerminationHandler = @Sendable (_ process: Subprocess, _ stdout: Data, _ stderr: Data) -> Void + /// Launches command calling a block when process terminates /// /// - Parameters: /// - input: File or data to write to standard input of the process /// - terminationHandler: Block called with Subprocess, stdout Data, stderr Data - public func launch(input: Input? = nil, - terminationHandler: @escaping DataTerminationHandler) throws { - var stdoutBuffer = Data() - var stderrBuffer = Data() + public func launch(input: Input? = nil, terminationHandler: @escaping DataTerminationHandler) throws { + let stdoutData = UnsafeData() + let stderrData = UnsafeData() + try launch(input: input, outputHandler: { data in - stdoutBuffer.append(data) + stdoutData.append(data) }, errorHandler: { data in - stderrBuffer.append(data) + stderrData.append(data) }, terminationHandler: { selfRef in - terminationHandler(selfRef, stdoutBuffer, stderrBuffer) + let standardOutput = stdoutData.value() + let standardError = stderrData.value() + + terminationHandler(selfRef, standardOutput, standardError) }) } - - /// Suspends the command - public func suspend() -> Bool { - return reference.suspend() - } - - /// Resumes the command which was suspended - public func resume() -> Bool { - return reference.resume() - } - - /// Sends the command the term signal - public func kill() { - reference.terminate() - } - - /// Waits for process to complete and all handlers to be called + + /// Waits for process to complete and all handlers to be called. Not to be + /// confused with `Process.waitUntilExit()` which can return before its + /// `terminationHandler` is called. + /// Calling this method when using the non-deprecated methods will return immediately and not wait for the process to exit. public func waitForTermination() { group.wait() } - - private let group = DispatchGroup() - private let queue: DispatchQueue - - private func createPipeWithReadabilityHandler(_ handler: @escaping (Data) -> Void) -> Pipe { + + private func createPipeWithReadabilityHandler(_ handler: @escaping @Sendable (Data) -> Void) -> Pipe { let pipe = Pipe() + group.enter() - pipe.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData - if data.isEmpty { - self.queue.async { self.group.leave() } - handle.readabilityHandler = nil - } else { - self.queue.async { handler(data) } + + let stream: AsyncStream = AsyncStream { continuation in + pipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + + guard !data.isEmpty else { + handle.readabilityHandler = nil + continuation.finish() + return + } + + continuation.yield(data) + } + + continuation.onTermination = { _ in + pipe.fileHandleForReading.readabilityHandler = nil + } + } + + Task { + for await data in stream { + handler(data) } + + group.leave() } + return pipe } } + +extension Subprocess { + /// Errors specific to `Subprocess`. + public enum Error: LocalizedError { + case nonZeroExit(status: Int32, reason: Process.TerminationReason, standardOutput: Data, standardError: String) + + public var errorDescription: String? { + switch self { + case let .nonZeroExit(status: terminationStatus, reason: _, standardOutput: _, standardError: errorString): + return "Process exited with status \(terminationStatus): \(errorString)" + } + } + } +} + +private actor TerminationContinuation { + private var continuation: UnsafeContinuation? + private var didResume = false + + deinit { + continuation?.resume() + } + + func setContinuation(_ continuation: UnsafeContinuation) { + self.continuation = continuation + + // in case the termination happened before the task was able to set the continuation + if didResume { + resume() + } + } + + func resume() { + continuation?.resume() + continuation = nil + didResume = true + } +} diff --git a/Sources/Subprocess/SubprocessDependencyBuilder.swift b/Sources/Subprocess/SubprocessDependencyBuilder.swift index fe40dae..e554d1e 100644 --- a/Sources/Subprocess/SubprocessDependencyBuilder.swift +++ b/Sources/Subprocess/SubprocessDependencyBuilder.swift @@ -4,7 +4,7 @@ // // MIT License // -// Copyright (c) 2018 Jamf Software +// Copyright (c) 2023 Jamf // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -41,28 +41,37 @@ public protocol SubprocessDependencyFactory { /// - Returns: New FileHandle for reading /// - Throws: When unable to open file for reading func makeInputFileHandle(url: URL) throws -> FileHandle - - /// Creates a Pipe and writes given data + + /// Creates a `Pipe` and writes the sequence. /// - /// - Parameter data: Data to write to the Pipe - /// - Returns: New Pipe instance - func makeInputPipe(data: Data) -> Pipe + /// - Parameter sequence: An `AsyncSequence` that supplies data to be written. + /// - Returns: New `Pipe` instance. + func makeInputPipe(sequence: Input) throws -> Pipe where Input : AsyncSequence, Input.Element == UInt8 } /// Default implementation of SubprocessDependencyFactory public struct SubprocessDependencyBuilder: SubprocessDependencyFactory { - - /// Shared instance used for dependency creatation - public static var shared: SubprocessDependencyFactory = SubprocessDependencyBuilder() + private static let queue = DispatchQueue(label: "\(Self.self)") + private static var _shared: any SubprocessDependencyFactory = SubprocessDependencyBuilder() + /// Shared instance used for dependency creation + public static var shared: any SubprocessDependencyFactory { + get { + queue.sync { + _shared + } + } + set { + queue.sync { + _shared = newValue + } + } + } public func makeProcess(command: [String]) -> Process { var tmp = command let process = Process() - if #available(OSX 10.13, *) { - process.executableURL = URL(fileURLWithPath: tmp.removeFirst()) - } else { - process.launchPath = tmp.removeFirst() - } + + process.executableURL = URL(fileURLWithPath: tmp.removeFirst()) process.arguments = tmp return process } @@ -70,18 +79,33 @@ public struct SubprocessDependencyBuilder: SubprocessDependencyFactory { public func makeInputFileHandle(url: URL) throws -> FileHandle { return try FileHandle(forReadingFrom: url) } - - public func makeInputPipe(data: Data) -> Pipe { + + public func makeInputPipe(sequence: Input) throws -> Pipe where Input : AsyncSequence, Input.Element == UInt8 { let pipe = Pipe() + // see here: https://developer.apple.com/forums/thread/690382 + let result = fcntl(pipe.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1) + + guard result >= 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(result), userInfo: nil) + } + pipe.fileHandleForWriting.writeabilityHandler = { handle in - handle.write(data) handle.writeabilityHandler = nil - if #available(OSX 10.15, *) { - try? handle.close() - } else { - handle.closeFile() + + Task { + defer { + try? handle.close() + } + + // `DispatchIO` seems like an interesting solution but doesn't seem to mesh well with async/await, perhaps there will be updates in this area in the future. + // https://developer.apple.com/forums/thread/690310 + // According to Swift forum talk byte by byte reads _could_ be optimized by the compiler depending on how much visibility it has into methods. + for try await byte in sequence { + try handle.write(contentsOf: [byte]) + } } } + return pipe } } diff --git a/Sources/Subprocess/UnsafeData.swift b/Sources/Subprocess/UnsafeData.swift new file mode 100644 index 0000000..bfbc7d6 --- /dev/null +++ b/Sources/Subprocess/UnsafeData.swift @@ -0,0 +1,45 @@ +// +// UnsafeData.swift +// Subprocess +// +// MIT License +// +// Copyright (c) 2023 Jamf +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +// Avoids errors for modifying data in concurrent contexts when we know it's safe to do so. +final class UnsafeData: @unchecked Sendable { + private lazy var data = Data() + + func set(_ data: Data) { + self.data = data + } + + func append(_ other: Data) { + data.append(other) + } + + func value() -> Data { + data + } +} diff --git a/Sources/SubprocessMocks/MockOutput.swift b/Sources/SubprocessMocks/MockOutput.swift new file mode 100644 index 0000000..cad9907 --- /dev/null +++ b/Sources/SubprocessMocks/MockOutput.swift @@ -0,0 +1,45 @@ +// +// MockOutput.swift +// SubprocessMocks +// +// MIT License +// +// Copyright (c) 2023 Jamf +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// + +import Foundation + +/// A way to supply data to mock methods +public protocol MockOutput { + var data: Data { get } +} + +extension Data: MockOutput { + public var data: Data { + self + } +} + +extension String: MockOutput { + public var data: Data { + Data(self.utf8) + } +} diff --git a/Sources/SubprocessMocks/MockProcess.swift b/Sources/SubprocessMocks/MockProcess.swift index 7047379..207eb58 100644 --- a/Sources/SubprocessMocks/MockProcess.swift +++ b/Sources/SubprocessMocks/MockProcess.swift @@ -4,7 +4,7 @@ // // MIT License // -// Copyright (c) 2018 Jamf Software +// Copyright (c) 2023 Jamf // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -38,28 +38,22 @@ public struct MockProcess { /// Writes given data to standard out of the mock child process /// - Parameter data: Data to write to standard out of the mock child process - public func writeTo(stdout data: Data) { - reference.standardOutputPipe?.fileHandleForWriting.write(data) - } - - /// Writes given text to standard out of the mock child process - /// - Parameter text: Text to write to standard out of the mock child process - public func writeTo(stdout text: String, encoding: String.Encoding = .utf8) { - guard let data = text.data(using: encoding) else { return } - reference.standardOutputPipe?.fileHandleForWriting.write(data) + public func writeTo(stdout: some MockOutput) { + do { + try reference.standardOutputPipe?.fileHandleForWriting.write(contentsOf: stdout.data) + } catch { + fatalError("unexpected write failure: \(error)") + } } /// Writes given data to standard error of the mock child process /// - Parameter data: Data to write to standard error of the mock child process - public func writeTo(stderr data: Data) { - reference.standardErrorPipe?.fileHandleForWriting.write(data) - } - - /// Writes given text to standard error of the mock child process - /// - Parameter text: Text to write to standard error of the mock child process - public func writeTo(stderr text: String, encoding: String.Encoding = .utf8) { - guard let data = text.data(using: encoding) else { return } - reference.standardErrorPipe?.fileHandleForWriting.write(data) + public func writeTo(stderr: some MockOutput) { + do { + try reference.standardErrorPipe?.fileHandleForWriting.write(contentsOf: stderr.data) + } catch { + fatalError("unexpected write failure: \(error)") + } } /// Completes the mock process execution @@ -73,8 +67,6 @@ public struct MockProcess { /// Subclass of `Process` used for mocking open class MockProcessReference: Process { - // swiftlint:disable nesting - /// Context information and values used for overriden properties public struct Context { @@ -95,19 +87,14 @@ open class MockProcessReference: Process { var standardInput: Any? var standardOutput: Any? var standardError: Any? -#if compiler(>=5.7) var terminationHandler: (@Sendable (Process) -> Void)? -#else - var terminationHandler: ((Process) -> Void)? -#endif } - // swiftlint:enable nesting public var context: Context /// Creates a new `MockProcessReference` which throws an error on launch /// - Parameter error: Error thrown when `Process.run` is called - public init(withRunError error: Error) { + public init(withRunError error: any Error) { context = Context(runStub: { _ in throw error }) } @@ -115,7 +102,7 @@ open class MockProcessReference: Process { /// - Parameter block: Block used to stub `Process.run` public init(withRunBlock block: @escaping (MockProcess) -> Void) { context = Context(runStub: { mock in - DispatchQueue.global(qos: .userInitiated).async { + Task(priority: .userInitiated) { block(mock) } }) @@ -129,6 +116,9 @@ open class MockProcessReference: Process { /// Block called when `Process.suspend` is called public var stubSuspend: (() -> Bool)? + + /// Block called when `Process.waitUntilExit` is called + public var stubWaitUntilExit: (() -> Void)? /// standardOutput object as a Pipe public var standardOutputPipe: Pipe? { standardOutput as? Pipe } @@ -155,13 +145,8 @@ open class MockProcessReference: Process { guard context.state == .running else { return } context.state = (reason == .exit) ? .exited : .uncaughtSignal context.terminationStatus = statusCode - if #available(OSX 10.15, *) { - try? standardOutputPipe?.fileHandleForWriting.close() - try? standardErrorPipe?.fileHandleForWriting.close() - } else { - standardOutputPipe?.fileHandleForWriting.closeFile() - standardErrorPipe?.fileHandleForWriting.closeFile() - } + try? standardOutputPipe?.fileHandleForWriting.close() + try? standardErrorPipe?.fileHandleForWriting.close() guard let handler = terminationHandler else { return } terminationHandler = nil @@ -189,6 +174,7 @@ open class MockProcessReference: Process { open override func terminate() { if let stub = stubTerminate { stub(self) + context.state = .exited } else { context.state = .uncaughtSignal } @@ -209,19 +195,16 @@ open class MockProcessReference: Process { set { context.standardError = newValue } } -#if compiler(>=5.7) open override var terminationHandler: (@Sendable (Process) -> Void)? { get { context.terminationHandler } set { context.terminationHandler = newValue } } -#else - open override var terminationHandler: ((Process) -> Void)? { - get { context.terminationHandler } - set { context.terminationHandler = newValue } - } -#endif open override func resume() -> Bool { stubResume?() ?? false } open override func suspend() -> Bool { stubSuspend?() ?? false } + + open override func waitUntilExit() { + stubWaitUntilExit?() + } } diff --git a/Sources/SubprocessMocks/MockShell.swift b/Sources/SubprocessMocks/MockShell.swift index 2f6e4f3..c716e0b 100644 --- a/Sources/SubprocessMocks/MockShell.swift +++ b/Sources/SubprocessMocks/MockShell.swift @@ -4,7 +4,7 @@ // // MIT License // -// Copyright (c) 2018 Jamf Software +// Copyright (c) 2023 Jamf // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -30,8 +30,10 @@ import Foundation import Subprocess #endif +@available(*, deprecated, message: "Swift Concurrency methods in Subprocess replace Shell") extension Shell: SubprocessMockObject {} +@available(*, deprecated, message: "Swift Concurrency methods in Subprocess replace Shell") public extension Shell { /// Adds a mock for a command which throws an error when `Process.run` is called @@ -39,7 +41,7 @@ public extension Shell { /// - Parameters: /// - command: The command to mock /// - error: Error thrown when `Process.run` is called - static func stub(_ command: [String], error: Error) { + static func stub(_ command: [String], error: any Error) { Subprocess.stub(command, error: error) } @@ -171,7 +173,7 @@ public extension Shell { /// - line: Line number of source file where expect was called (Default: #line) static func expect(_ command: [String], input: Input? = nil, - error: Error, + error: any Swift.Error, file: StaticString = #file, line: UInt = #line) { Subprocess.expect(command, input: input, error: error, file: file, line: line) diff --git a/Sources/SubprocessMocks/MockSubprocess.swift b/Sources/SubprocessMocks/MockSubprocess.swift index fe76352..8368fcd 100644 --- a/Sources/SubprocessMocks/MockSubprocess.swift +++ b/Sources/SubprocessMocks/MockSubprocess.swift @@ -4,7 +4,7 @@ // // MIT License // -// Copyright (c) 2018 Jamf Software +// Copyright (c) 2023 Jamf // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -26,6 +26,7 @@ // import Foundation +import Combine #if !COCOA_PODS import Subprocess #endif @@ -39,7 +40,7 @@ public extension Subprocess { /// - Parameters: /// - command: The command to mock /// - error: Error thrown when `Process.run` is called - static func stub(_ command: [String], error: Error) { + static func stub(_ command: [String], error: any Swift.Error) { let mock = MockProcessReference(withRunError: error) MockSubprocessDependencyBuilder.shared.stub(command, process: mock) } @@ -54,6 +55,44 @@ public extension Subprocess { let mock = MockProcessReference(withRunBlock: runBlock ?? { $0.exit() }) MockSubprocessDependencyBuilder.shared.stub(command, process: mock) } + + /// Adds a mock for a command which writes the given data to the outputs and exits with the provided exit code + /// + /// - Parameters: + /// - command: The command to mock + /// - standardOutput: Data written to stdout of the process + /// - standardError: Data written to stderr of the process + /// - exitCode: Exit code of the process (Default: 0) + static func stub(_ command: [String], standardOutput: (any MockOutput)? = nil, standardError: (any MockOutput)? = nil, exitCode: Int32 = 0) { + stub(command) { process in + if let data = standardOutput { + process.writeTo(stdout: data) + } + + if let data = standardError { + process.writeTo(stderr: data) + } + + process.exit(withStatus: exitCode) + } + } + + /// Adds a mock for a command which writes the given encodable object as JSON to stdout + /// and exits with the provided exit code + /// + /// - Parameters: + /// - command: The command to mock + /// - content: Encodable object written to stdout + /// - encoder: `TopLevelEncoder` used to encoder `content` into `Data`. + /// - exitCode: Exit code of the process (Default: 0) + /// - Throws: Error when encoding the provided object + static func stub(_ command: [String], content: Content, encoder: Encoder, exitCode: Int32 = 0) throws where Content : Encodable, Encoder : TopLevelEncoder, Encoder.Output == Data { + let data: Data = try encoder.encode(content) + + stub(command, standardOutput: data, exitCode: exitCode) + } + + // MARK: - /// Adds an expected mock for a given command which throws an error when `Process.run` is called /// @@ -63,11 +102,7 @@ public extension Subprocess { /// - error: Error thrown when `Process.run` is called /// - file: Source file where expect was called (Default: #file) /// - line: Line number of source file where expect was called (Default: #line) - static func expect(_ command: [String], - input: Input? = nil, - error: Error, - file: StaticString = #file, - line: UInt = #line) { + static func expect(_ command: [String], input: Input? = nil, error: any Swift.Error, file: StaticString = #file, line: UInt = #line) { let mock = MockProcessReference(withRunError: error) MockSubprocessDependencyBuilder.shared.expect(command, input: input, process: mock, file: file, line: line) } @@ -81,12 +116,44 @@ public extension Subprocess { /// - file: Source file where expect was called (Default: #file) /// - line: Line number of source file where expect was called (Default: #line) /// - runBlock: Block called with a `MockProcess` to mock process execution - static func expect(_ command: [String], - input: Input? = nil, - file: StaticString = #file, - line: UInt = #line, - runBlock: ((MockProcess) -> Void)? = nil) { + static func expect(_ command: [String], input: Input? = nil, file: StaticString = #file, line: UInt = #line, runBlock: ((MockProcess) -> Void)? = nil) { let mock = MockProcessReference(withRunBlock: runBlock ?? { $0.exit() }) MockSubprocessDependencyBuilder.shared.expect(command, input: input, process: mock, file: file, line: line) } + + /// Adds a mock for a command which writes the given data to the outputs and exits with the provided exit code + /// + /// - Parameters: + /// - command: The command to mock + /// - standardOutput: Data written to stdout of the process + /// - standardError: Data written to stderr of the process + /// - exitCode: Exit code of the process (Default: 0) + static func expect(_ command: [String], standardOutput: (any MockOutput)? = nil, standardError: (any MockOutput)? = nil, input: Input? = nil, exitCode: Int32 = 0, file: StaticString = #file, line: UInt = #line) { + expect(command, input: input, file: file, line: line) { process in + if let data = standardOutput { + process.writeTo(stdout: data) + } + + if let data = standardError { + process.writeTo(stderr: data) + } + + process.exit(withStatus: exitCode) + } + } + + /// Adds a mock for a command which writes the given encodable object as JSON to stdout + /// and exits with the provided exit code + /// + /// - Parameters: + /// - command: The command to mock + /// - content: Encodable object written to stdout + /// - encoder: `TopLevelEncoder` used to encoder `content` into `Data`. + /// - exitCode: Exit code of the process (Default: 0) + /// - Throws: Error when encoding the provided object + static func expect(_ command: [String], content: Content, encoder: Encoder, input: Input? = nil, exitCode: Int32 = 0, file: StaticString = #file, line: UInt = #line) throws where Content : Encodable, Encoder : TopLevelEncoder, Encoder.Output == Data { + let data: Data = try encoder.encode(content) + + expect(command, standardOutput: data, input: input, exitCode: exitCode, file: file, line: line) + } } diff --git a/Sources/SubprocessMocks/MockSubprocessDependencyBuilder.swift b/Sources/SubprocessMocks/MockSubprocessDependencyBuilder.swift index bb48fca..d64d537 100644 --- a/Sources/SubprocessMocks/MockSubprocessDependencyBuilder.swift +++ b/Sources/SubprocessMocks/MockSubprocessDependencyBuilder.swift @@ -4,7 +4,7 @@ // // MIT License // -// Copyright (c) 2018 Jamf Software +// Copyright (c) 2023 Jamf // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -70,8 +70,21 @@ public class MockFileHandle: FileHandle { public var url: URL? } -public class MockPipe: Pipe { - public var data: Data? +public final class MockPipe: Pipe { + private static let queue = DispatchQueue(label: "\(MockPipe.self)") + private var _data: Data? + public var data: Data? { + get { + Self.queue.sync { + _data + } + } + set { + Self.queue.sync { + _data = newValue + } + } + } } class MockSubprocessDependencyBuilder { @@ -83,11 +96,8 @@ class MockSubprocessDependencyBuilder { var process: MockProcessReference var file: StaticString? var line: UInt? - init(command: [String], - input: Input?, - process: MockProcessReference, - file: StaticString?, - line: UInt?) { + + init(command: [String], input: Input?, process: MockProcessReference, file: StaticString?, line: UInt?) { self.command = command self.input = input self.process = process @@ -107,11 +117,7 @@ class MockSubprocessDependencyBuilder { mocks.append(mock) } - func expect(_ command: [String], - input: Input?, - process: MockProcessReference, - file: StaticString, - line: UInt) { + func expect(_ command: [String], input: Input?, process: MockProcessReference, file: StaticString, line: UInt) { let mock = MockItem(command: command, input: input, process: process, file: file, line: line) mocks.append(mock) } @@ -129,12 +135,13 @@ class MockSubprocessDependencyBuilder { // Check the expected input let expectedData: Data? let expectedFile: URL? + switch $0.input?.value { case .data(let data): expectedData = data expectedFile = nil - case .text(let string, let encoding): - expectedData = string.data(using: encoding) + case .text(let string): + expectedData = Data(string.utf8) expectedFile = nil case .file(let url): expectedData = nil @@ -234,10 +241,17 @@ extension MockSubprocessDependencyBuilder: SubprocessDependencyFactory { handle.url = url return handle } - - func makeInputPipe(data: Data) -> Pipe { + + func makeInputPipe(sequence: Input) throws -> Pipe where Input : AsyncSequence, Input.Element == UInt8 { + let semaphore = DispatchSemaphore(value: 0) let pipe = MockPipe() - pipe.data = data + + Task { + pipe.data = try await sequence.data() + semaphore.signal() + } + + semaphore.wait() return pipe } } diff --git a/Sources/SubprocessMocks/SubprocessMocks.h b/Sources/SubprocessMocks/SubprocessMocks.h index 2b9cc16..95a58d2 100644 --- a/Sources/SubprocessMocks/SubprocessMocks.h +++ b/Sources/SubprocessMocks/SubprocessMocks.h @@ -4,7 +4,7 @@ // // MIT License // -// Copyright (c) 2018 Jamf Software +// Copyright (c) 2023 Jamf // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/Subprocess.podspec b/Subprocess.podspec index 0f5670b..0463454 100644 --- a/Subprocess.podspec +++ b/Subprocess.podspec @@ -1,17 +1,17 @@ Pod::Spec.new do |s| s.name = 'Subprocess' - s.version = '2.0.0' + s.version = '3.0.0' s.summary = 'Wrapper for NSTask used for running processes and shell commands on macOS.' s.license = { :type => 'MIT', :text => "" } s.description = <<-DESC Everything related to creating processes and running shell commands on macOS. DESC s.homepage = 'https://github.com/jamf/Subprocess' - s.authors = { 'Cyrus Ingraham' => 'cyrus.ingraham@jamf.com' } + s.authors = { 'Michael Link' => 'michael.link@jamf.com' } s.source = { :git => "https://github.com/jamf/Subprocess.git", :tag => s.version.to_s } - s.platform = :osx, '10.13' - s.osx.deployment_target = '10.13' - s.swift_version = '5.1' + s.platform = :osx, '10.15.4' + s.osx.deployment_target = '10.15.4' + s.swift_version = '5.9' s.default_subspec = 'Core' s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-DCOCOA_PODS' } diff --git a/Subprocess.xcodeproj/project.pbxproj b/Subprocess.xcodeproj/project.pbxproj index 48db648..bba48a8 100644 --- a/Subprocess.xcodeproj/project.pbxproj +++ b/Subprocess.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -25,6 +25,12 @@ 6EDDC6FC2410396000E171C6 /* ShellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6EDDC6BD2410378100E171C6 /* ShellTests.swift */; }; 6EDDC6FD24105F8300E171C6 /* SubprocessMocks.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6EDDC6CB241037CE00E171C6 /* SubprocessMocks.framework */; }; 6EDDC6FE24105F8700E171C6 /* Subprocess.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6EDDC6382410369400E171C6 /* Subprocess.framework */; }; + 8F6AA3F62ADDA80000F86C7A /* UnsafeData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F6AA3F22ADDA80000F86C7A /* UnsafeData.swift */; }; + 8F6AA3F72ADDA80000F86C7A /* AsyncStream+Yield.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F6AA3F32ADDA80000F86C7A /* AsyncStream+Yield.swift */; }; + 8F6AA3F82ADDA80000F86C7A /* AsyncSequence+Additions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F6AA3F42ADDA80000F86C7A /* AsyncSequence+Additions.swift */; }; + 8F6AA3F92ADDA80000F86C7A /* Pipe+AsyncBytes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F6AA3F52ADDA80000F86C7A /* Pipe+AsyncBytes.swift */; }; + 8F6AA3FC2ADDA85C00F86C7A /* SubprocessSystemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F6AA3FA2ADDA85500F86C7A /* SubprocessSystemTests.swift */; }; + 8F95A1182AE1F8A3008958DD /* MockOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F95A1172AE1F8A3008958DD /* MockOutput.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -71,6 +77,12 @@ 6EDDC6CB241037CE00E171C6 /* SubprocessMocks.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = SubprocessMocks.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6EDDC6E02410391500E171C6 /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6EDDC6EF2410392400E171C6 /* SystemTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SystemTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 8F6AA3F22ADDA80000F86C7A /* UnsafeData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnsafeData.swift; sourceTree = ""; }; + 8F6AA3F32ADDA80000F86C7A /* AsyncStream+Yield.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AsyncStream+Yield.swift"; sourceTree = ""; }; + 8F6AA3F42ADDA80000F86C7A /* AsyncSequence+Additions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AsyncSequence+Additions.swift"; sourceTree = ""; }; + 8F6AA3F52ADDA80000F86C7A /* Pipe+AsyncBytes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Pipe+AsyncBytes.swift"; sourceTree = ""; }; + 8F6AA3FA2ADDA85500F86C7A /* SubprocessSystemTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubprocessSystemTests.swift; sourceTree = ""; }; + 8F95A1172AE1F8A3008958DD /* MockOutput.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockOutput.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -148,11 +160,15 @@ children = ( 6EDDC6A52410378100E171C6 /* Subprocess.swift */, 6EDDC6A62410378100E171C6 /* SubprocessDependencyBuilder.swift */, - 6EDDC6A72410378100E171C6 /* Shell.swift */, + 8F6AA3F42ADDA80000F86C7A /* AsyncSequence+Additions.swift */, + 8F6AA3F32ADDA80000F86C7A /* AsyncStream+Yield.swift */, + 8F6AA3F52ADDA80000F86C7A /* Pipe+AsyncBytes.swift */, + 8F6AA3F22ADDA80000F86C7A /* UnsafeData.swift */, + 6E1195E32416630500534F74 /* Input.swift */, 6EDDC6A82410378100E171C6 /* Errors.swift */, + 6EDDC6A72410378100E171C6 /* Shell.swift */, 6EDDC6A92410378100E171C6 /* Subprocess.h */, 6EDDC6AA2410378100E171C6 /* Info.plist */, - 6E1195E32416630500534F74 /* Input.swift */, ); path = Subprocess; sourceTree = ""; @@ -164,8 +180,9 @@ 6EDDC6AD2410378100E171C6 /* MockSubprocess.swift */, 6EDDC6AE2410378100E171C6 /* MockShell.swift */, 6EDDC6AF2410378100E171C6 /* MockSubprocessDependencyBuilder.swift */, - 6EDDC6B02410378100E171C6 /* Info.plist */, 6EDDC6B12410378100E171C6 /* MockProcess.swift */, + 8F95A1172AE1F8A3008958DD /* MockOutput.swift */, + 6EDDC6B02410378100E171C6 /* Info.plist */, ); path = SubprocessMocks; sourceTree = ""; @@ -182,6 +199,7 @@ 6EDDC6B52410378100E171C6 /* SystemTests */ = { isa = PBXGroup; children = ( + 8F6AA3FA2ADDA85500F86C7A /* SubprocessSystemTests.swift */, 6EDDC6B82410378100E171C6 /* ShellSystemTests.swift */, 6EDDC6B92410378100E171C6 /* Info.plist */, ); @@ -307,8 +325,9 @@ 6EDDC62F2410369400E171C6 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1130; - LastUpgradeCheck = 1220; + LastUpgradeCheck = 1500; ORGANIZATIONNAME = Jamf; TargetAttributes = { 6EDDC6372410369400E171C6 = { @@ -381,6 +400,7 @@ /* Begin PBXShellScriptBuildPhase section */ 6E1195E124165F1500534F74 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -394,10 +414,11 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + shellScript = "# Adds support for Apple Silicon brew directory\nexport PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; }; 6E1195E224165F2000534F74 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -411,7 +432,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if which swiftlint >/dev/null; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi \n"; + shellScript = "# Adds support for Apple Silicon brew directory\nexport PATH=\"$PATH:/opt/homebrew/bin\"\n\nif which swiftlint; then\n swiftlint\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -421,9 +442,13 @@ buildActionMask = 2147483647; files = ( 6E1195E42416630500534F74 /* Input.swift in Sources */, + 8F6AA3F92ADDA80000F86C7A /* Pipe+AsyncBytes.swift in Sources */, + 8F6AA3F82ADDA80000F86C7A /* AsyncSequence+Additions.swift in Sources */, 6EDDC6C42410379F00E171C6 /* Errors.swift in Sources */, 6EDDC6C32410379F00E171C6 /* Shell.swift in Sources */, + 8F6AA3F62ADDA80000F86C7A /* UnsafeData.swift in Sources */, 6EDDC6C22410379F00E171C6 /* SubprocessDependencyBuilder.swift in Sources */, + 8F6AA3F72ADDA80000F86C7A /* AsyncStream+Yield.swift in Sources */, 6EDDC6C12410379F00E171C6 /* Subprocess.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -434,6 +459,7 @@ files = ( 6EDDC6D9241037F500E171C6 /* MockShell.swift in Sources */, 6EDDC6DB241037F500E171C6 /* MockProcess.swift in Sources */, + 8F95A1182AE1F8A3008958DD /* MockOutput.swift in Sources */, 6EDDC6D8241037F500E171C6 /* MockSubprocess.swift in Sources */, 6EDDC6DA241037F500E171C6 /* MockSubprocessDependencyBuilder.swift in Sources */, ); @@ -453,6 +479,7 @@ buildActionMask = 2147483647; files = ( 6EDDC6F92410395800E171C6 /* ShellSystemTests.swift in Sources */, + 8F6AA3FC2ADDA85C00F86C7A /* SubprocessSystemTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -507,6 +534,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -524,7 +552,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = 10.15.4; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -571,6 +599,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -582,7 +611,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = 10.15.4; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -599,11 +628,13 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; + DEAD_CODE_STRIPPING = YES; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; INFOPLIST_FILE = Sources/Subprocess/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -611,6 +642,8 @@ "@executable_path/../Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; PRODUCT_BUNDLE_IDENTIFIER = com.jamf.opensource.subprocess.Subprocess; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -626,11 +659,13 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; + DEAD_CODE_STRIPPING = YES; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; INFOPLIST_FILE = Sources/Subprocess/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -638,6 +673,8 @@ "@executable_path/../Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; PRODUCT_BUNDLE_IDENTIFIER = com.jamf.opensource.subprocess.Subprocess; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -651,11 +688,13 @@ buildSettings = { CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; + DEAD_CODE_STRIPPING = YES; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; INFOPLIST_FILE = Sources/SubprocessMocks/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -663,6 +702,8 @@ "@executable_path/../Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; PRODUCT_BUNDLE_IDENTIFIER = com.jamf.opensource.subprocess.SubprocessMocks; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -676,11 +717,13 @@ buildSettings = { CODE_SIGN_STYLE = Manual; COMBINE_HIDPI_IMAGES = YES; + DEAD_CODE_STRIPPING = YES; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; INFOPLIST_FILE = Sources/SubprocessMocks/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; LD_RUNPATH_SEARCH_PATHS = ( @@ -688,6 +731,8 @@ "@executable_path/../Frameworks", "@loader_path/Frameworks", ); + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; PRODUCT_BUNDLE_IDENTIFIER = com.jamf.opensource.subprocess.SubprocessMocks; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -701,6 +746,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEAD_CODE_STRIPPING = YES; INFOPLIST_FILE = Tests/UnitTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -718,6 +764,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEAD_CODE_STRIPPING = YES; INFOPLIST_FILE = Tests/UnitTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -735,6 +782,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEAD_CODE_STRIPPING = YES; INFOPLIST_FILE = Tests/SystemTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -752,6 +800,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; + DEAD_CODE_STRIPPING = YES; INFOPLIST_FILE = Tests/SystemTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/Subprocess.xcodeproj/xcshareddata/xcschemes/Subprocess.xcscheme b/Subprocess.xcodeproj/xcshareddata/xcschemes/Subprocess.xcscheme index 23c9ece..cc43bf6 100644 --- a/Subprocess.xcodeproj/xcshareddata/xcschemes/Subprocess.xcscheme +++ b/Subprocess.xcodeproj/xcshareddata/xcschemes/Subprocess.xcscheme @@ -1,6 +1,6 @@ .Continuation! + let stream: AsyncStream = AsyncStream { continuation in + input = continuation + } + let subprocess = Subprocess(["/bin/cat"]) + let (standardOutput, _, _) = try subprocess.run(standardInput: stream) + + input.yield("hello\n") + + for await line in standardOutput.lines { + XCTAssertEqual("hello", line) + break + } + + input.yield("world\n") + input.finish() + + for await line in standardOutput.lines { + XCTAssertEqual("world", line) + break + } + } + + @available(macOS 12.0, *) + func testInteractiveAsyncRun() throws { + let exp = expectation(description: "\(#file):\(#line)") + let (stream, input) = { + var input: AsyncStream.Continuation! + let stream: AsyncStream = AsyncStream { continuation in + input = continuation + } + + return (stream, input!) + }() + + let subprocess = Subprocess(["/bin/cat"]) + let (standardOutput, _, _) = try subprocess.run(standardInput: stream) + + input.yield("hello\n") + + Task { + for await line in standardOutput.lines { + switch line { + case "hello": + Task { + input.yield("world\n") + } + case "world": + input.yield("and\nuniverse") + input.finish() + case "universe": + break + default: + continue + } + } + + exp.fulfill() + } + + wait(for: [exp]) + } + + func testData() async throws { + let data = try await Subprocess.data(for: ["/bin/cat", softwareVersionFilePath]) + + XCTAssert(!data.isEmpty) + } + + func testDataWithInput() async throws { + let data = try await Subprocess.data(for: ["/bin/cat"], standardInput: Data("hello".utf8)) + + XCTAssertEqual(String(decoding: data, as: UTF8.self), "hello") + } + + @available(macOS 13.0, *) + func testDataCancel() async throws { + let exp = expectation(description: "\(#file):\(#line)") + let task = Task { + do { + _ = try await Subprocess.data(for: ["/bin/cat"], standardInput: URL(filePath: "/dev/random")) + + XCTFail("expected task to be canceled") + } catch { + exp.fulfill() + } + } + + try await Task.sleep(nanoseconds: 1_000_000_000) + task.cancel() + await fulfillment(of: [exp]) + } + + func testDataCancelWithoutInput() async throws { + let exp = expectation(description: "\(#file):\(#line)") + let task = Task { + do { + _ = try await Subprocess.data(for: ["/bin/cat", "/dev/random"]) + + XCTFail("expected task to be canceled") + } catch { + exp.fulfill() + } + } + + try await Task.sleep(nanoseconds: 1_000_000_000) + task.cancel() + await fulfillment(of: [exp]) + } + + func testString() async throws { + let username = NSUserName() + let result = try await Subprocess.string(for: ["/usr/bin/dscl", ".", "list", "/Users"]) + + XCTAssertTrue(result.contains(username)) + } + + func testStringWithStringInput() async throws { + let result = try await Subprocess.string(for: ["/bin/cat"], standardInput: "hello") + + XCTAssertEqual("hello", result) + } + + @available(macOS 13.0, *) + func testStringWithFileInput() async throws { + let result = try await Subprocess.string(for: ["/bin/cat"], standardInput: URL(filePath: softwareVersionFilePath)) + + XCTAssertEqual(try String(contentsOf: URL(filePath: softwareVersionFilePath)), result) + } + + func testReturningJSON() async throws { + struct LogMessage: Codable { + var subsystem: String + var category: String + var machTimestamp: UInt64 + } + + let result: [LogMessage] = try await Subprocess.value(for: ["/usr/bin/log", "show", "--style", "json", "--last", "30s"], decoder: JSONDecoder()) + + XCTAssertTrue(!result.isEmpty) + } + + func testReturningPropertyList() async throws { + struct SystemVersion: Codable { + enum CodingKeys: String, CodingKey { + case version = "ProductVersion" + } + var version: String + } + + let fullVersionString = ProcessInfo.processInfo.operatingSystemVersionString + let result: SystemVersion = try await Subprocess.value(for: ["/bin/cat", softwareVersionFilePath], decoder: PropertyListDecoder()) + let versionNumber = result.version + + XCTAssertTrue(fullVersionString.contains(versionNumber)) + } + + func testNonZeroExit() async { + do { + _ = try await Subprocess.string(for: ["/bin/cat", "/non/existent/path/file.txt"]) + XCTFail("expected failure") + } catch Subprocess.Error.nonZeroExit { + // expected + } catch { + XCTFail("unexpected error: \(error)") + } + } +} diff --git a/Tests/UnitTests/ShellTests.swift b/Tests/UnitTests/ShellTests.swift index 2f7b177..56cd954 100644 --- a/Tests/UnitTests/ShellTests.swift +++ b/Tests/UnitTests/ShellTests.swift @@ -9,7 +9,8 @@ struct TestCodableObject: Codable, Equatable { init() { uuid = UUID() } } -// swiftlint:disable control_statement +// swiftlint:disable control_statement duplicated_key_in_dictionary_literal +@available(*, deprecated, message: "Swift Concurrency methods in Subprocess replace Shell") final class ShellTests: XCTestCase { let command = [ "/usr/local/bin/somefakeCommand", "foo", "bar" ] @@ -354,4 +355,4 @@ final class ShellTests: XCTestCase { Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } } } -// swiftlint:enable control_statement +// swiftlint:enable control_statement duplicated_key_in_dictionary_literal diff --git a/Tests/UnitTests/SubprocessTests.swift b/Tests/UnitTests/SubprocessTests.swift index e225721..29e2091 100644 --- a/Tests/UnitTests/SubprocessTests.swift +++ b/Tests/UnitTests/SubprocessTests.swift @@ -4,6 +4,7 @@ import XCTest @testable import SubprocessMocks #endif +// swiftlint:disable duplicated_key_in_dictionary_literal final class SubprocessTests: XCTestCase { let command = [ "/usr/local/bin/somefakeCommand", "foo", "bar" ] @@ -50,9 +51,8 @@ final class SubprocessTests: XCTestCase { // Then switch input.value { - case .text(let text, let encoding): + case .text(let text): XCTAssertEqual(text, expected) - XCTAssertEqual(encoding, .utf8) default: XCTFail("Unexpected type") } guard let pipe = pipeOrFileHandle as? MockPipe else { return XCTFail("Unable to cast MockPipe") } @@ -99,8 +99,8 @@ final class SubprocessTests: XCTestCase { } // MARK: PID - - func testGetPID() { + + func testGetPID() throws { // Given let mockCalled = expectation(description: "Mock setup called") var expectedPID: Int32? @@ -111,12 +111,11 @@ final class SubprocessTests: XCTestCase { // When let subprocess = Subprocess(command) - XCTAssertNoThrow(try subprocess.launch(terminationHandler: { (_, _, _) in })) + _ = try subprocess.run() // Then - waitForExpectations(timeout: 5.0) { _ in - XCTAssertEqual(subprocess.pid, expectedPID) - } + wait(for: [mockCalled], timeout: 5.0) + XCTAssertEqual(subprocess.pid, expectedPID) } // MARK: launch with termination handler @@ -139,22 +138,54 @@ final class SubprocessTests: XCTestCase { // When let subprocess = Subprocess(command) - XCTAssertNoThrow(try subprocess.launch(terminationHandler: { (process, standardOutput, _) in - XCTAssertEqual(standardOutput, expectedStdout) + XCTAssertNoThrow(try subprocess.launch(terminationHandler: { (process, standardOutput, standardError) in XCTAssertEqual(standardOutput, expectedStdout) + XCTAssertEqual(standardError, expectedStderr) XCTAssertEqual(process.terminationReason, .uncaughtSignal) XCTAssertEqual(process.exitCode, expectedExitCode) terminationExpectation.fulfill() })) // Then - waitForExpectations(timeout: 5.0) + wait(for: [terminationExpectation], timeout: 5.0) + Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } + } + + func testRunhWithWaitUntilExit() async throws { + // Given + let expectedExitCode = Int32.random(in: Int32.min...Int32.max) + let expectedStdout = Data([ UInt8.random(in: 0...UInt8.max), + UInt8.random(in: 0...UInt8.max), + UInt8.random(in: 0...UInt8.max) ]) + let expectedStderr = Data([ UInt8.random(in: 0...UInt8.max), + UInt8.random(in: 0...UInt8.max), + UInt8.random(in: 0...UInt8.max) ]) + Subprocess.expect(command) { mock in + mock.writeTo(stdout: expectedStdout) + mock.writeTo(stderr: expectedStderr) + mock.exit(withStatus: expectedExitCode, reason: .uncaughtSignal) + } + + // When + let subprocess = Subprocess(command) + let (standardOutput, standardError, waitUntilExit) = try subprocess.run() + async let (stdout, stderr) = (standardOutput, standardError) + let combinedOutput = await [stdout.data(), stderr.data()] + + await waitUntilExit() + + XCTAssertEqual(combinedOutput[0], expectedStdout) + XCTAssertEqual(combinedOutput[1], expectedStderr) + XCTAssertEqual(subprocess.terminationReason, .uncaughtSignal) + XCTAssertEqual(subprocess.exitCode, expectedExitCode) + + // Then Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } } // MARK: suspend - func testSuspend() { + func testSuspend() throws { // Given let semaphore = DispatchSemaphore(value: 0) let suspendCalled = expectation(description: "Suspend called") @@ -166,7 +197,7 @@ final class SubprocessTests: XCTestCase { semaphore.signal() } let subprocess = Subprocess(command) - XCTAssertNoThrow(try subprocess.launch(terminationHandler: { (_, _, _) in })) + _ = try subprocess.run() semaphore.wait() // When @@ -179,7 +210,7 @@ final class SubprocessTests: XCTestCase { // MARK: resume - func testResume() { + func testResume() throws { // Given let semaphore = DispatchSemaphore(value: 0) let resumeCalled = expectation(description: "Resume called") @@ -191,7 +222,7 @@ final class SubprocessTests: XCTestCase { semaphore.signal() } let subprocess = Subprocess(command) - XCTAssertNoThrow(try subprocess.launch(terminationHandler: { (_, _, _) in })) + _ = try subprocess.run() semaphore.wait() // When @@ -204,7 +235,7 @@ final class SubprocessTests: XCTestCase { // MARK: kill - func testKill() { + func testKill() throws { // Given let semaphore = DispatchSemaphore(value: 0) let terminateCalled = expectation(description: "Terminate called") @@ -215,7 +246,7 @@ final class SubprocessTests: XCTestCase { semaphore.signal() } let subprocess = Subprocess(command) - XCTAssertNoThrow(try subprocess.launch(terminationHandler: { (_, _, _) in })) + _ = try subprocess.run() semaphore.wait() // When @@ -240,4 +271,150 @@ final class SubprocessTests: XCTestCase { XCTAssertEqual(subprocess.environment?[environmentVariableName], environmentVariableValue, "The environment property did not store the value correctly.") } + + // MARK: Data + + func testReturningDataWhenExitCodeIsNoneZero() async { + // Given + let exitCode = Int32.random(in: 1...Int32.max) + let stdoutData = Data("stdout example".utf8) + let stderrData = Data("stderr example".utf8) + Subprocess.expect(command, standardOutput: stdoutData, standardError: stderrData, exitCode: exitCode) + + // When + do { + _ = try await Subprocess.data(for: command) + } catch let Subprocess.Error.nonZeroExit(status: status, reason: _, standardOutput: stdout, standardError: stderr) { + XCTAssertEqual(status, exitCode) + XCTAssertTrue(stderr.contains("stderr example")) + XCTAssertEqual(stdoutData, stdout) + } catch { + XCTFail("Unexpected error type: \(error)") + } + + // Then + Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } + } + + func testReturningDataFromStandardOutput() async throws { + // Given + let expected = Data([ UInt8.random(in: 0...UInt8.max), + UInt8.random(in: 0...UInt8.max), + UInt8.random(in: 0...UInt8.max) ]) + let errorData = Data([ UInt8.random(in: 0...UInt8.max) ]) + Subprocess.expect(command, standardOutput: expected, standardError: errorData) + + // When + let result = try await Subprocess.data(for: command) + + // Then + XCTAssertEqual(expected, result) + Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } + } + + func testReturningDataFromStandardError() async throws { + // Given + let expected = Data([ UInt8.random(in: 0...UInt8.max), + UInt8.random(in: 0...UInt8.max), + UInt8.random(in: 0...UInt8.max) ]) + let stdOutData = Data([ UInt8.random(in: 0...UInt8.max) ]) + Subprocess.expect(command, standardOutput: stdOutData, standardError: expected) + + // When + let result = try await Subprocess.data(for: command, options: .returnStandardError) + + // Then + XCTAssertEqual(expected, result) + Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } + } + + // MARK: String + + func testReturningStringWhenExitCodeIsNoneZero() async throws { + // Given + let exitCode = Int32.random(in: 1...Int32.max) + let stdoutText = "should not show up" + let stderrText = "should show up" + Subprocess.expect(command, standardOutput: stdoutText, standardError: stderrText, exitCode: exitCode) + + // When + do { + _ = try await Subprocess.string(for: command) + } catch let Subprocess.Error.nonZeroExit(status: status, reason: _, standardOutput: stdout, standardError: stderr) { + XCTAssertEqual(status, exitCode) + XCTAssertTrue(stderr.contains("should show up")) + XCTAssertEqual(stdoutText, String(decoding: stdout, as: UTF8.self)) + } catch { + XCTFail("Unexpected error type: \(error)") + } + + // Then + Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } + } + + func testReturningStringFromStandardOutput() async throws { + // Given + let expected = UUID().uuidString + Subprocess.expect(command, standardOutput: expected, standardError: UUID().uuidString) + + // When + let result = try await Subprocess.string(for: command) + + // Then + XCTAssertEqual(expected, result) + Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } + } + + func testReturningStringFromStandardError() async throws { + // Given + let expected = UUID().uuidString + Subprocess.expect(command, standardOutput: UUID().uuidString, standardError: expected) + + // When + let result = try await Subprocess.string(for: command, options: .returnStandardError) + + // Then + XCTAssertEqual(expected, result) + Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } + } + + // MARK: JSON object + + func testReturningJSONArray() async throws { + // Given + let expected: [String] = [ + UUID().uuidString, + UUID().uuidString + ] + + XCTAssertNoThrow(try Subprocess.expect(command, content: expected, encoder: JSONEncoder())) + + // When + let result: [String] = try await Subprocess.value(for: command, decoder: JSONDecoder()) + + // Then + XCTAssertEqual(expected, result) + Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } + } + + func testExecReturningJSONDictionary() async throws { + // Given + let expected: [String: [String: String]] = [ + UUID().uuidString: [ + UUID().uuidString: UUID().uuidString + ], + UUID().uuidString: [ + UUID().uuidString: UUID().uuidString + ] + ] + XCTAssertNoThrow(try Subprocess.expect(command, content: expected, encoder: JSONEncoder())) + + // When + let result: [String: [String: String]] = try await Subprocess.value(for: command, decoder: JSONDecoder()) + + // Then + XCTAssertEqual(expected, result) + Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } + } } +// swiftlint:enable duplicated_key_in_dictionary_literal