├── .github └── workflows │ ├── build-and-test.yml │ └── publish-docs.yml ├── .gitignore ├── .swiftlint.yml ├── .swiftpm ├── configuration │ └── Package.resolved └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── CHANGELOG.md ├── CODEOWNERS ├── LICENSE ├── Package.resolved ├── Package.swift ├── PrivacyInfo.xcprivacy ├── README.md ├── Sources ├── Subprocess │ ├── AsyncSequence+Additions.swift │ ├── AsyncStream+Yield.swift │ ├── Errors.swift │ ├── Info.plist │ ├── Input.swift │ ├── Pipe+AsyncBytes.swift │ ├── Shell.swift │ ├── Subprocess.h │ ├── Subprocess.swift │ ├── SubprocessDependencyBuilder.swift │ └── UnsafeData.swift └── SubprocessMocks │ ├── Info.plist │ ├── MockOutput.swift │ ├── MockProcess.swift │ ├── MockShell.swift │ ├── MockSubprocess.swift │ ├── MockSubprocessDependencyBuilder.swift │ └── SubprocessMocks.h ├── Subprocess.podspec ├── Subprocess.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ ├── Subprocess.xcscheme │ └── SubprocessMocks.xcscheme └── Tests ├── SystemTests ├── Info.plist ├── ShellSystemTests.swift └── SubprocessSystemTests.swift └── UnitTests ├── Info.plist ├── ShellTests.swift └── SubprocessTests.swift /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | 13 | jobs: 14 | spm: 15 | name: SwiftPM build and test 16 | runs-on: macos-14 17 | steps: 18 | - run: | 19 | sudo xcode-select -s /Applications/Xcode_15.3.app 20 | - uses: actions/checkout@v3 21 | - name: Build swift packages 22 | run: swift build -v 23 | - name: Run tests 24 | run: swift test -v 25 | carthage: 26 | name: Xcode project build and test 27 | runs-on: macos-14 28 | steps: 29 | - run: | 30 | sudo xcode-select -s /Applications/Xcode_15.3.app 31 | - uses: actions/checkout@v3 32 | - name: Build xcode project 33 | run: xcodebuild build -scheme 'SubprocessMocks' -derivedDataPath .build 34 | - name: Run tests 35 | run: xcodebuild test -scheme 'Subprocess' -derivedDataPath .build 36 | cocoapods: 37 | name: Pod lib lint 38 | runs-on: macos-14 39 | steps: 40 | - run: | 41 | sudo xcode-select -s /Applications/Xcode_15.3.app 42 | - uses: actions/checkout@v3 43 | - name: Lib lint 44 | run: pod lib lint --verbose Subprocess.podspec --allow-warnings 45 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Docs 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - 'main' 8 | 9 | # Kill any previous run still executing 10 | concurrency: 11 | group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | build_docs: 16 | name: Build and Archive Docs 17 | runs-on: macos-13 18 | steps: 19 | - run: | 20 | sudo xcode-select -s /Applications/Xcode_15.0.app 21 | - name: Checkout 22 | uses: actions/checkout@v3 23 | 24 | - name: Generate docs 25 | run: | 26 | swift package \ 27 | --allow-writing-to-directory github-pages \ 28 | generate-documentation \ 29 | --target Subprocess \ 30 | --disable-indexing \ 31 | --transform-for-static-hosting \ 32 | --hosting-base-path Subprocess/ \ 33 | --output-path github-pages 34 | 35 | - name: Upload docs archive 36 | uses: actions/upload-pages-artifact@main 37 | with: 38 | path: github-pages 39 | 40 | deploy: 41 | name: Deploy Docs 42 | needs: build_docs 43 | 44 | permissions: 45 | pages: write 46 | id-token: write 47 | 48 | environment: 49 | name: github-pages 50 | url: ${{ steps.deployment.outputs.page_url }} 51 | 52 | runs-on: ubuntu-latest 53 | steps: 54 | - name: Deploy 55 | id: deployment 56 | uses: actions/deploy-pages@v1 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | .DS_Store 92 | -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: 2 | - trailing_whitespace # Xcode automatically adds space for new lines 3 | - line_length # IDE is good at wrapping long lines 4 | - function_body_length 5 | - file_length # doesn't play nice when you need to have private in the same file 6 | - nesting 7 | - large_tuple 8 | - colon # doesn't follow Swift formatting 9 | - type_body_length # XCTest subclasses and arbitrary depending on type 10 | -------------------------------------------------------------------------------- /.swiftpm/configuration/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "db0939bfcff17f77ea9eb9dc37e55caa295f4878b93f9eaf156720c118c15c0f", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-docc-plugin", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/apple/swift-docc-plugin", 8 | "state" : { 9 | "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", 10 | "version" : "1.3.0" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-docc-symbolkit", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-docc-symbolkit", 17 | "state" : { 18 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 19 | "version" : "1.0.0" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Subprocess 2 | 3 | Subprocess is a Swift library for macOS providing interfaces for external process execution. 4 | 5 | All notable changes to this project will be documented in this file. 6 | 7 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 8 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 9 | 10 | ## [3.0.5] - 2024-08-07 11 | 12 | ### Changed 13 | - Non-breaking changes to public imports when compiling under Swift 6. 14 | 15 | ## [3.0.4] - 2024-07-01 16 | 17 | ### Changed 18 | - Swift 6 compatibility updates. 19 | 20 | ## 3.0.3 - 2024-04-15 21 | 22 | ### Changed 23 | - Correctly turned on `StrictConcurrency` in Swift 5.10 and earlier and added non-breaking conformance to `Sendable`. 24 | - Updated documentation for closure based usage where `nonisolated(unsafe)` is required to avoid an error in projects that use `StrictConcurrency`. 25 | 26 | ## 3.0.2 - 2024-02-07 27 | 28 | ### Added 29 | - Additional `Sendable` conformance where it can't be implicitly determined outside of this package. 30 | 31 | ### Removed 32 | - `open` scope from `Subprocess` since none of its members were `open`. 33 | 34 | ## 3.0.1 - 2023-11-27 35 | 36 | ### Added 37 | - Explicit `Sendable` conformance for some types which silences warnings in consuming projects where Xcode can't determine implicit conformance. 38 | 39 | ## 3.0.0 - 2023-10-13 40 | 41 | ### Added 42 | - Methods to `Subprocess` that support Swift Concurrency. 43 | - `Subprocess.run(standardInput:options:)` can run interactive commands. 44 | 45 | ### Changed 46 | - 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. 47 | - 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. 48 | - `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. 49 | - Swift 5.9 (Xcode 15) is now the package minimum required to build. 50 | 51 | ## 2.0.0 - 2021-07-01 52 | 53 | ### Changed 54 | - Breaking: added the output of the command to Shell's exception exitedWithNonZeroStatus error to better conform to objc interop and NSError 55 | - Updated minimum deployment target to macOS 10.13. 56 | 57 | ## 1.1.0 - 2020-05-15 58 | 59 | ### Added 60 | - Added dynamic library targets to SPM 61 | 62 | ### Fixed 63 | - Fixed naming convention to match Jamf internal convention 64 | 65 | ## 1.0.1 - 2020-03-13 66 | 67 | ### Added 68 | - Added Cocoapods support 69 | 70 | 71 | ## 1.0.0 - 2020-03-13 72 | 73 | ### Added 74 | - All support for the initial release 75 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @jamf/apple-natives-write 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jamf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-docc-plugin", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-docc-plugin", 7 | "state" : { 8 | "revision" : "26ac5758409154cc448d7ab82389c520fa8a8247", 9 | "version" : "1.3.0" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-docc-symbolkit", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/apple/swift-docc-symbolkit", 16 | "state" : { 17 | "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", 18 | "version" : "1.0.0" 19 | } 20 | } 21 | ], 22 | "version" : 2 23 | } 24 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Subprocess", 7 | platforms: [ .macOS("10.15.4") ], 8 | products: [ 9 | .library( 10 | name: "Subprocess", 11 | targets: [ "Subprocess" ] 12 | ), 13 | .library( 14 | name: "SubprocessMocks", 15 | targets: [ "SubprocessMocks" ] 16 | ), 17 | .library( 18 | name: "libSubprocess", 19 | targets: [ "Subprocess" ] 20 | ), 21 | .library( 22 | name: "libSubprocessMocks", 23 | targets: [ "SubprocessMocks" ] 24 | ) 25 | ], 26 | dependencies: [ 27 | .package(url: "https://github.com/apple/swift-docc-plugin", .upToNextMajor(from: "1.0.0")) 28 | ], 29 | targets: [ 30 | .target( 31 | name: "Subprocess", 32 | dependencies: [] 33 | ), 34 | .target( 35 | name: "SubprocessMocks", 36 | dependencies: [ 37 | .target(name: "Subprocess") 38 | ] 39 | ), 40 | .testTarget( 41 | name: "UnitTests", 42 | dependencies: [ 43 | .target(name: "Subprocess"), 44 | .target(name: "SubprocessMocks") 45 | ] 46 | ), 47 | .testTarget( 48 | name: "SystemTests", 49 | dependencies: [ 50 | .target(name: "Subprocess") 51 | ] 52 | ) 53 | ], 54 | swiftLanguageVersions: [.v5, .version("6")] 55 | ) 56 | 57 | for target in package.targets { 58 | var swiftSettings = target.swiftSettings ?? [] 59 | 60 | // According to Swift's piecemeal adoption plan features that were 61 | // upcoming features that become language defaults and are still enabled 62 | // as upcoming features will result in a compiler error. Currently in the 63 | // latest 5.10 compiler this doesn't happen, the compiler ignores it. 64 | // 65 | // The Swift 6 compiler on the other hand does emit errors when features 66 | // are enabled. Unfortunately it appears that the preprocessor 67 | // !hasFeature(xxx) cannot be used to test for this situation nor does 68 | // #if swift(<6) guard against this. There must be some sort of magic 69 | // used that is special for compiling the Package.swift manifest. 70 | // Instead a versioned Package.swift can be used (e.g. Package@swift-5.10.swift) 71 | // and the implemented now default features can be removed in Package.swift. 72 | // 73 | // Or you can just delete the Swift 6 features that are enabled instead of 74 | // creating another manifest file and test to see if building under Swift 5 75 | // still works (it should almost always work). 76 | // 77 | // It's still safe to enable features that don't exist in older compiler 78 | // versions as the compiler will ignore features it doesn't have implemented. 79 | 80 | // swift 7 81 | swiftSettings.append(.enableUpcomingFeature("ExistentialAny")) 82 | swiftSettings.append(.enableUpcomingFeature("InternalImportsByDefault")) 83 | 84 | target.swiftSettings = swiftSettings 85 | } 86 | -------------------------------------------------------------------------------- /PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyCollectedDataTypes 6 | 7 | NSPrivacyAccessedAPITypes 8 | 9 | NSPrivacyTrackingDomains 10 | 11 | NSPrivacyTracking 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Subprocess 2 | [![License](http://img.shields.io/badge/license-MIT-informational.svg?style=flat)](http://mit-license.org) 3 | ![Build](https://github.com/jamf/Subprocess/workflows/Build%20&%20Test/badge.svg) 4 | [![CocoaPods](https://img.shields.io/cocoapods/v/Subprocess.svg)](https://cocoapods.org/pods/Subprocess) 5 | [![Platform](https://img.shields.io/badge/platform-macOS-success.svg?style=flat)](https://developer.apple.com/macos) 6 | [![Language](http://img.shields.io/badge/language-Swift-success.svg?style=flat)](https://developer.apple.com/swift) 7 | [![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) 8 | [![SwiftPM compatible](https://img.shields.io/badge/spm-compatible-success.svg?style=flat)](https://swift.org/package-manager) 9 | [![Documentation](https://img.shields.io/badge/documentation-100%25-green)](https://engineering.jamf.com/Subprocess/documentation/subprocess/) 10 | 11 | Subprocess is a Swift library for macOS providing interfaces for both synchronous and asynchronous process execution. 12 | SubprocessMocks can be used in unit tests for quick and highly customizable mocking and verification of Subprocess usage. 13 | 14 | - [Usage](#usage) 15 | - [Subprocess Class](#subprocess-class) 16 | - [Command Input](#command-input) - [Data](#input-for-data), [Text](#input-for-text), [File](#input-for-file-url) 17 | - [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) 18 | - [Installation](#installation) 19 | - [SwiftPM](#swiftpm) 20 | - [Cocoapods](#cocoapods) 21 | - [Carthage](#carthage) 22 | 23 | [Full Documentation](./docs/index.html) 24 | 25 | # Usage 26 | ### Subprocess Class 27 | The `Subprocess` class can be used for command execution. 28 | 29 | #### Command Input 30 | 31 | ###### Input for data 32 | ```swift 33 | let inputData = Data("hello world".utf8) 34 | let data = try await Subprocess.data(for: ["/usr/bin/grep", "hello"], standardInput: inputData) 35 | ``` 36 | ###### Input for text 37 | ```swift 38 | let data = try await Subprocess.data(for: ["/usr/bin/grep", "hello"], standardInput: "hello world") 39 | ``` 40 | ###### Input for file URL 41 | ```swift 42 | let data = try await Subprocess.data(for: ["/usr/bin/grep", "foo"], standardInput: URL(filePath: "/path/to/input/file")) 43 | ``` 44 | 45 | #### Command Output 46 | 47 | ###### Output as Data 48 | ```swift 49 | let data = try await Subprocess.data(for: ["/usr/bin/sw_vers"]) 50 | ``` 51 | ###### Output as String 52 | ```swift 53 | let string = try await Subprocess.string(for: ["/usr/bin/sw_vers"]) 54 | ``` 55 | ###### Output as decodable object from JSON 56 | ```swift 57 | struct LogMessage: Codable { 58 | var subsystem: String 59 | var category: String 60 | var machTimestamp: UInt64 61 | } 62 | 63 | let result: [LogMessage] = try await Subprocess.value(for: ["/usr/bin/log", "show", "--style", "json", "--last", "30s"], decoder: JSONDecoder()) 64 | ``` 65 | ###### Output as decodable object from Property List 66 | ```swift 67 | struct SystemVersion: Codable { 68 | enum CodingKeys: String, CodingKey { 69 | case version = "ProductVersion" 70 | } 71 | var version: String 72 | } 73 | 74 | let result: SystemVersion = try await Subprocess.value(for: ["/bin/cat", "/System/Library/CoreServices/SystemVersion.plist"], decoder: PropertyListDecoder()) 75 | ``` 76 | ###### Output mapped to other type 77 | ```swift 78 | let enabled = try await Subprocess(["/usr/bin/csrutil", "status"]).run().standardOutput.lines.first(where: { $0.contains("enabled") } ) != nil 79 | ``` 80 | ###### Output options 81 | ```swift 82 | let errorText = try await Subprocess.string(for: ["/usr/bin/cat", "/non/existent/file.txt"], options: .returnStandardError) 83 | let outputText = try await Subprocess.string(for: ["/usr/bin/sw_vers"]) 84 | 85 | async let (standardOutput, standardError, _) = try Subprocess(["/usr/bin/csrutil", "status"]).run() 86 | let combinedOutput = try await [standardOutput.string(), standardError.string()] 87 | ``` 88 | ###### Handling output as it is read 89 | ```swift 90 | let (stream, input) = { 91 | var input: AsyncStream.Continuation! 92 | let stream: AsyncStream = AsyncStream { continuation in 93 | input = continuation 94 | } 95 | 96 | return (stream, input!) 97 | }() 98 | 99 | let subprocess = Subprocess(["/bin/cat"]) 100 | let (standardOutput, _, waitForExit) = try subprocess.run(standardInput: stream) 101 | 102 | input.yield("hello\n") 103 | 104 | Task { 105 | for await line in standardOutput.lines { 106 | switch line { 107 | case "hello": 108 | input.yield("world\n") 109 | case "world": 110 | input.yield("and\nuniverse") 111 | input.finish() 112 | case "universe": 113 | await waitForExit() 114 | break 115 | default: 116 | continue 117 | } 118 | } 119 | } 120 | ``` 121 | ###### Handling output on termination 122 | ```swift 123 | let process = Subprocess(["/usr/bin/csrutil", "status"]) 124 | let (standardOutput, standardError, waitForExit) = try process.run() 125 | async let (stdout, stderr) = (standardOutput, standardError) 126 | let combinedOutput = await [stdout.data(), stderr.data()] 127 | 128 | await waitForExit() 129 | 130 | if process.exitCode == 0 { 131 | // Do something with output data 132 | } else { 133 | // Handle failure 134 | } 135 | ``` 136 | ###### Closure based callbacks 137 | ```swift 138 | let command: [String] = ... 139 | let process = Subprocess(command) 140 | nonisolated(unsafe) var outputData: Data? 141 | nonisolated(unsafe) var errorData: Data? 142 | 143 | // The outputHandler and errorHandler are invoked serially 144 | try process.launch(outputHandler: { data in 145 | // Handle new data read from stdout 146 | outputData = data 147 | }, errorHandler: { data in 148 | // Handle new data read from stderr 149 | errorData = data 150 | }, terminationHandler: { process in 151 | // Handle process termination, all scheduled calls to 152 | // the outputHandler and errorHandler are guaranteed to 153 | // have completed. 154 | }) 155 | ``` 156 | ###### Handing output on termination with a closure 157 | ```swift 158 | let command: [String] = ... 159 | let process = Subprocess(command) 160 | 161 | try process.launch { (process, outputData, errorData) in 162 | if process.exitCode == 0 { 163 | // Do something with output data 164 | } else { 165 | // Handle failure 166 | } 167 | ``` 168 | 169 | ## Installation 170 | ### SwiftPM 171 | ```swift 172 | let package = Package( 173 | // name, platforms, products, etc. 174 | dependencies: [ 175 | // other dependencies 176 | .package(url: "https://github.com/jamf/Subprocess.git", .upToNextMajor(from: "3.0.0")), 177 | ], 178 | targets: [ 179 | .target(name: "", 180 | dependencies: [ 181 | // other dependencies 182 | .product(name: "Subprocess"), 183 | ]), 184 | // other targets 185 | ] 186 | ) 187 | ``` 188 | ### Cocoapods 189 | ```ruby 190 | pod 'Subprocess' 191 | ``` 192 | ### Carthage 193 | ```ruby 194 | github 'jamf/Subprocess' 195 | ``` 196 | -------------------------------------------------------------------------------- /Sources/Subprocess/AsyncSequence+Additions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncSequence+Additions.swift 3 | // Subprocess 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | #if swift(>=6.0) 29 | public import Foundation 30 | #else 31 | import Foundation 32 | #endif 33 | 34 | extension AsyncSequence { 35 | /// Returns a sequence of all the elements. 36 | public func sequence() async rethrows -> [Element] { 37 | try await reduce(into: [Element]()) { $0.append($1) } 38 | } 39 | } 40 | 41 | extension AsyncSequence where Element == UInt8 { 42 | /// Returns a `Data` representation. 43 | public func data() async rethrows -> Data { 44 | Data(try await sequence()) 45 | } 46 | 47 | public func string() async rethrows -> String { 48 | if #available(macOS 12.0, *) { 49 | String(try await characters.sequence()) 50 | } else { 51 | String(decoding: try await data(), as: UTF8.self) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /Sources/Subprocess/AsyncStream+Yield.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AsyncStream+Yield.swift 3 | // Subprocess 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | #if swift(>=6.0) 29 | public import Foundation 30 | #else 31 | import Foundation 32 | #endif 33 | 34 | extension AsyncStream.Continuation where Element == UInt8 { 35 | /// Resume the task awaiting the next iteration point by having it return 36 | /// normally from its suspension point with the given data. 37 | /// 38 | /// - Parameter value: The value to yield from the continuation. 39 | /// - Returns: A `YieldResult` that indicates the success or failure of the 40 | /// yield operation from the last byte of the `Data`. 41 | /// 42 | /// If nothing is awaiting the next value, this method attempts to buffer the 43 | /// result's element. 44 | /// 45 | /// This can be called more than once and returns to the caller immediately 46 | /// without blocking for any awaiting consumption from the iteration. 47 | @discardableResult public func yield(_ value: Data) -> AsyncStream.Continuation.YieldResult? { 48 | var yieldResult: AsyncStream.Continuation.YieldResult? 49 | 50 | for byte in value { 51 | yieldResult = yield(byte) 52 | } 53 | 54 | return yieldResult 55 | } 56 | 57 | /// Resume the task awaiting the next iteration point by having it return 58 | /// normally from its suspension point with the given string. 59 | /// 60 | /// - Parameter value: The value to yield from the continuation. 61 | /// - Returns: A `YieldResult` that indicates the success or failure of the 62 | /// yield operation from the last byte of the string after being converted to `Data`. 63 | /// 64 | /// If nothing is awaiting the next value, this method attempts to buffer the 65 | /// result's element. 66 | /// 67 | /// This can be called more than once and returns to the caller immediately 68 | /// without blocking for any awaiting consumption from the iteration. 69 | @discardableResult public func yield(_ value: String) -> AsyncStream.Continuation.YieldResult? { 70 | // unicode encodings are safe to explicity unwrap 71 | yield(value.data(using: .utf8)!) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/Subprocess/Errors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Errors.swift 3 | // Subprocess 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | #if swift(>=6) 29 | public import Foundation 30 | #else 31 | import Foundation 32 | #endif 33 | 34 | /// Type representing possible errors 35 | @available(*, deprecated, message: "This type is no longer used with non-deprecated methods") 36 | public enum SubprocessError: Error { 37 | 38 | /// The process completed with a non-zero exit code 39 | /// and a custom error message. 40 | case exitedWithNonZeroStatus(Int32, String) 41 | 42 | /// The property list object could not be cast to expected type 43 | case unexpectedPropertyListObject(String) 44 | 45 | /// The JSON object could not be cast to expected type 46 | case unexpectedJSONObject(String) 47 | 48 | /// Input string could not be encoded 49 | case inputStringEncodingError 50 | 51 | /// Output string could not be encoded 52 | case outputStringEncodingError 53 | } 54 | 55 | @available(*, deprecated, message: "This type is no longer used with non-deprecated methods") 56 | extension SubprocessError: LocalizedError { 57 | public var errorDescription: String? { 58 | switch self { 59 | case .exitedWithNonZeroStatus(_, let errorMessage): 60 | return "\(errorMessage)" 61 | case .unexpectedPropertyListObject: 62 | // Ignoring the plist contents parameter as we don't want that in the error message 63 | return "The property list object could not be cast to expected type" 64 | case .unexpectedJSONObject: 65 | // Ignoring the json contents parameter as we don't want that in the error message 66 | return "The JSON object could not be cast to expected type" 67 | case .inputStringEncodingError: 68 | return "Input string could not be encoded" 69 | case .outputStringEncodingError: 70 | return "Output string could not be encoded" 71 | } 72 | } 73 | } 74 | 75 | /// Common NSError methods for better interop with Objective-C 76 | @available(*, deprecated, message: "This type is no longer used with non-deprecated methods") 77 | extension SubprocessError: CustomNSError { 78 | public var errorCode: Int { 79 | switch self { 80 | case .exitedWithNonZeroStatus(let errorCode, _): 81 | return Int(errorCode) 82 | case .unexpectedPropertyListObject: return 10 83 | case .unexpectedJSONObject: return 20 84 | case .inputStringEncodingError: return 30 85 | case .outputStringEncodingError: return 40 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/Subprocess/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 2.0.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/Subprocess/Input.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Input.swift 3 | // Subprocess 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | #if swift(>=6.0) 29 | public import Foundation 30 | #else 31 | import Foundation 32 | #endif 33 | 34 | /// Interface representing input to the process 35 | public struct Input { 36 | /// Reference to the input value 37 | public enum Value { 38 | 39 | /// Data to be written to stdin of the child process 40 | case data(Data) 41 | 42 | /// Text to be written to stdin of the child process 43 | case text(String) 44 | 45 | /// File to be written to stdin of the child process 46 | case file(URL) 47 | } 48 | 49 | /// Reference to the input value 50 | public let value: Value 51 | 52 | /// Creates input for writing data to stdin of the child process 53 | /// - Parameter data: Data written to stdin of the child process 54 | /// - Returns: New Input instance 55 | public static func data(_ data: Data) -> Input { 56 | return Input(value: .data(data)) 57 | } 58 | 59 | /// Creates input for writing text to stdin of the child process 60 | /// - Parameter text: Text written to stdin of the child process 61 | /// - Returns: New Input instance 62 | public static func text(_ text: String) -> Input { 63 | return Input(value: .text(text)) 64 | } 65 | 66 | /// Creates input for writing contents of file at path to stdin of the child process 67 | /// - Parameter path: Path to file written to stdin of the child process 68 | /// - Returns: New Input instance 69 | public static func file(path: String) -> Input { 70 | return Input(value: .file(URL(fileURLWithPath: path))) 71 | } 72 | 73 | /// Creates input for writing contents of file URL to stdin of the child process 74 | /// - Parameter url: URL for file written to stdin of the child process 75 | /// - Returns: New Input instance 76 | public static func file(url: URL) -> Input { 77 | return Input(value: .file(url)) 78 | } 79 | 80 | /// Creates file handle or pipe for given input 81 | /// - Returns: New FileHandle or Pipe 82 | func createPipeOrFileHandle() throws -> Any { 83 | switch value { 84 | case .data(let data): 85 | return try SubprocessDependencyBuilder.shared.makeInputPipe(sequence: AsyncStream(UInt8.self, { continuation in 86 | continuation.yield(data) 87 | continuation.finish() 88 | })) 89 | case .text(let text): 90 | return try SubprocessDependencyBuilder.shared.makeInputPipe(sequence: AsyncStream(UInt8.self, { continuation in 91 | continuation.yield(Data(text.utf8)) 92 | continuation.finish() 93 | })) 94 | case .file(let url): 95 | return try SubprocessDependencyBuilder.shared.makeInputFileHandle(url: url) 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/Subprocess/Pipe+AsyncBytes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Pipe+AsyncBytes.swift 3 | // Subprocess 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | #if swift(>=6.0) 29 | public import Foundation 30 | #else 31 | import Foundation 32 | #endif 33 | 34 | // `FileHandle.AsyncBytes` has a bug that can block reading of stdout when also reading stderr. 35 | // We can avoid this problem if we create independent handlers. 36 | extension Pipe { 37 | /// Convenience for reading bytes from the pipe's file handle. 38 | public struct AsyncBytes: AsyncSequence, Sendable { 39 | public typealias Element = UInt8 40 | 41 | let pipe: Pipe 42 | 43 | public func makeAsyncIterator() -> AsyncStream.Iterator { 44 | AsyncStream { continuation in 45 | pipe.fileHandleForReading.readabilityHandler = { @Sendable handle in 46 | let availableData = handle.availableData 47 | 48 | guard !availableData.isEmpty else { 49 | handle.readabilityHandler = nil 50 | continuation.finish() 51 | return 52 | } 53 | 54 | for byte in availableData { 55 | if case .terminated = continuation.yield(byte) { 56 | break 57 | } 58 | } 59 | } 60 | 61 | continuation.onTermination = { _ in 62 | pipe.fileHandleForReading.readabilityHandler = nil 63 | } 64 | }.makeAsyncIterator() 65 | } 66 | } 67 | 68 | public var bytes: AsyncBytes { 69 | AsyncBytes(pipe: self) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sources/Subprocess/Shell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Shell.swift 3 | // Subprocess 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | #if swift(>=6.0) 29 | public import Foundation 30 | #else 31 | import Foundation 32 | #endif 33 | 34 | /// Class used for synchronous process execution 35 | @available(*, deprecated, message: "Use Swift Concurrency methods instead which are part of the Subprocess class") 36 | public class Shell { 37 | 38 | /// OptionSet representing output handling 39 | public struct OutputOptions: OptionSet, Sendable { 40 | public let rawValue: Int 41 | 42 | /// Processes data written to stdout 43 | public static let stdout = OutputOptions(rawValue: 1 << 0) 44 | 45 | /// Processes data written to stderr 46 | public static let stderr = OutputOptions(rawValue: 1 << 1) 47 | 48 | /// Processes data written to both stdout and stderr 49 | public static let combined: OutputOptions = [ .stdout, .stderr ] 50 | public init(rawValue: Int) { self.rawValue = rawValue } 51 | } 52 | 53 | /// Reference to subprocess 54 | public let process: Subprocess 55 | 56 | /// Creates new Shell instance 57 | /// 58 | /// - Parameter command: Command represented as an array of strings 59 | public init(_ command: [String]) { 60 | process = Subprocess(command) 61 | } 62 | 63 | /// Executes shell command using a supplied block to tranform the process output into whatever type you would like 64 | /// 65 | /// - Parameters: 66 | /// - input: File or data to write to standard input of the process (Default: nil) 67 | /// - options: Output options defining the output to process (Default: .stdout) 68 | /// - transformBlock: Block executed given a reference to the completed process and the output 69 | /// - Returns: Process output as output type of the `transformBlock` 70 | /// - Throws: Error from process launch,`transformBlock` or failing create a string from the process output 71 | @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)") 72 | public func exec(input: Input? = nil, 73 | options: OutputOptions = .stdout, 74 | transformBlock: (_ process: Subprocess, _ data: Data) throws -> T) throws -> T { 75 | let stdoutData = UnsafeData() 76 | let stderrData = UnsafeData() 77 | let outputHandler: (@Sendable (Data) -> Void)? = if options.contains(.stdout) { 78 | { data in 79 | stdoutData.append(data) 80 | } 81 | } else { 82 | nil 83 | } 84 | let errorHandler: (@Sendable (Data) -> Void)? = if options.contains(.stderr) { 85 | { data in 86 | stderrData.append(data) 87 | } 88 | } else { 89 | nil 90 | } 91 | 92 | try process.launch(input: input, outputHandler: outputHandler, errorHandler: errorHandler) 93 | process.waitForTermination() 94 | // doing this so we can consistently get stdout before stderr when using the combined option 95 | var combinedBuffer = Data() 96 | combinedBuffer.append(stdoutData.value()) 97 | combinedBuffer.append(stderrData.value()) 98 | return try transformBlock(process, combinedBuffer) 99 | } 100 | 101 | /// Executes shell command expecting exit code of zero and returning the output data 102 | /// 103 | /// - Parameters: 104 | /// - input: File or data to write to standard input of the process (Default: nil) 105 | /// - options: Output options defining the output to process (Default: .stdout) 106 | /// - Returns: Process output data 107 | /// - Throws: Error from process launch or if termination code is none-zero 108 | @available(*, deprecated, message: "Use Subprocess.data(for:standardInput:options:)") 109 | public func exec(input: Input? = nil, options: OutputOptions = .stdout) throws -> Data { 110 | return try exec(input: input, options: options) { process, data in 111 | let exitCode = process.exitCode 112 | guard exitCode == 0 else { 113 | let message = String(data: data, encoding: .utf8) 114 | throw SubprocessError.exitedWithNonZeroStatus(exitCode, message ?? "") 115 | } 116 | return data 117 | } 118 | } 119 | 120 | /// Executes shell command using a supplied block to tranform the process output as a String 121 | /// into whatever type you would like 122 | /// 123 | /// - Parameters: 124 | /// - input: File or data to write to standard input of the process (Default: nil) 125 | /// - options: Output options defining the output to process (Default: .stdout) 126 | /// - encoding: Encoding to use for the output 127 | /// - transformBlock: Block executed given a reference to the completed process and the output as a string 128 | /// - Returns: Process output as output type of the `transformBlock` 129 | /// - Throws: Error from process launch,`transformBlock` or failing create a string from the process output 130 | @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)") 131 | public func exec(input: Input? = nil, 132 | options: OutputOptions = .stdout, 133 | encoding: String.Encoding, 134 | transformBlock: (_ process: Subprocess, _ string: String) throws -> T) throws -> T { 135 | return try exec(input: input, options: options) { process, data in 136 | guard let text = String(data: data, encoding: encoding) else { 137 | throw SubprocessError.outputStringEncodingError 138 | } 139 | return try transformBlock(process, text) 140 | } 141 | } 142 | 143 | /// Executes shell command expecting exit code of zero and returning the output as a string 144 | /// 145 | /// - Parameters: 146 | /// - input: File or data to write to standard input of the process (Default: nil) 147 | /// - options: Output options defining the output to process (Default: .stdout) 148 | /// - encoding: Encoding to use for the output 149 | /// - Returns: Process output as a String 150 | /// - Throws: Error from process launch, if termination code is none-zero or failing create a string from the output 151 | @available(*, deprecated, message: "Use Subprocess.string(for:standardInput:options:)") 152 | public func exec(input: Input? = nil, 153 | options: OutputOptions = .stdout, 154 | encoding: String.Encoding) throws -> String { 155 | return try exec(input: input, options: options, encoding: encoding) { process, text in 156 | let exitCode = process.exitCode 157 | guard exitCode == 0 else { 158 | throw SubprocessError.exitedWithNonZeroStatus(exitCode, text) 159 | } 160 | return text 161 | } 162 | } 163 | 164 | /// Executes shell command expecting JSON 165 | /// 166 | /// - Parameters: 167 | /// - input: File or data to write to standard input of the process (Default: nil) 168 | /// - options: Output options defining the output to process (Default: .stdout) 169 | /// - Returns: Process output as an Array or Dictionary 170 | /// - Throws: Error from process launch, JSONSerialization or failing to cast to expected type 171 | @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)") 172 | public func execJSON(input: Input? = nil, options: OutputOptions = .stdout) throws -> T { 173 | return try exec(input: input, options: options) { _, data in 174 | let object = try JSONSerialization.jsonObject(with: data, options: []) 175 | guard let value = object as? T else { 176 | throw SubprocessError.unexpectedJSONObject(String(describing: type(of: object))) 177 | } 178 | return value 179 | } 180 | } 181 | 182 | /// Executes shell command expecting a property list 183 | /// 184 | /// - Parameters: 185 | /// - input: File or data to write to standard input of the process (Default: nil) 186 | /// - options: Output options defining the output to process (Default: .stdout) 187 | /// - Returns: Process output as an Array or Dictionary 188 | /// - Throws: Error from process launch, PropertyListSerialization or failing to cast to expected type 189 | @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)") 190 | public func execPropertyList(input: Input? = nil, options: OutputOptions = .stdout) throws -> T { 191 | return try exec(input: input, options: options) { _, data in 192 | let object = try PropertyListSerialization.propertyList(from: data, options: [], format: .none) 193 | guard let value = object as? T else { 194 | throw SubprocessError.unexpectedPropertyListObject(String(describing: type(of: object))) 195 | } 196 | return value 197 | } 198 | } 199 | 200 | /// Executes shell command expecting JSON and decodes object conforming to Decodable 201 | /// 202 | /// - Parameters: 203 | /// - input: File or data to write to standard input of the process (Default: nil) 204 | /// - options: Output options defining the output to process (Default: .stdout) 205 | /// - decoder: JSONDecoder instance used for decoding the output object 206 | /// - Returns: Process output as the decodable object type 207 | /// - Throws: Error from process launch or JSONDecoder 208 | @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)") 209 | public func exec(input: Input? = nil, 210 | options: OutputOptions = .stdout, 211 | decoder: JSONDecoder) throws -> T { 212 | return try exec(input: input, options: options) { _, data in try decoder.decode(T.self, from: data) } 213 | } 214 | 215 | /// Executes shell command expecting property list and decodes object conforming to Decodable 216 | /// 217 | /// - Parameters: 218 | /// - input: File or data to write to standard input of the process (Default: nil) 219 | /// - options: Output options defining the output to process (Default: .stdout) 220 | /// - decoder: PropertyListDecoder instance used for decoding the output object 221 | /// - Returns: Process output as the decodable object type 222 | /// - Throws: Error from process launch or PropertyListDecoder 223 | @available(*, deprecated, message: "Use Subprocess.value(for:standardInput:options:decoder:)") 224 | public func exec(input: Input? = nil, 225 | options: OutputOptions = .stdout, 226 | decoder: PropertyListDecoder) throws -> T { 227 | return try exec(input: input, options: options) { _, data in try decoder.decode(T.self, from: data) } 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /Sources/Subprocess/Subprocess.h: -------------------------------------------------------------------------------- 1 | // 2 | // Subprocess.h 3 | // Subprocess 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | #import 29 | 30 | //! Project version number for Subprocess. 31 | FOUNDATION_EXPORT double SubprocessVersionNumber; 32 | 33 | //! Project version string for Subprocess. 34 | FOUNDATION_EXPORT const unsigned char SubprocessVersionString[]; 35 | -------------------------------------------------------------------------------- /Sources/Subprocess/Subprocess.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Subprocess.swift 3 | // Subprocess 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | #if swift(>=6.0) 29 | public import Foundation 30 | public import Combine 31 | #else 32 | import Foundation 33 | import Combine 34 | #endif 35 | 36 | /// Class used for asynchronous process execution 37 | public class Subprocess: @unchecked Sendable { 38 | /// Output options. 39 | public struct OutputOptions: OptionSet, Sendable { 40 | public let rawValue: Int 41 | 42 | /// Buffer standard output. 43 | public static let standardOutput = Self(rawValue: 1 << 0) 44 | 45 | /// Buffer standard error which may include useful error messages. 46 | public static let standardError = Self(rawValue: 1 << 1) 47 | 48 | public init(rawValue: Int) { 49 | self.rawValue = rawValue 50 | } 51 | } 52 | 53 | /// Process reference 54 | let process: Process 55 | 56 | /// Process identifier 57 | public var pid: Int32 { process.processIdentifier } 58 | 59 | /// Exit code of the process 60 | public var exitCode: Int32 { process.terminationStatus } 61 | 62 | /// Returns whether the process is still running. 63 | public var isRunning: Bool { process.isRunning } 64 | 65 | /// Reason for process termination 66 | public var terminationReason: Process.TerminationReason { process.terminationReason } 67 | 68 | /// Reference environment property 69 | public var environment: [String: String]? { 70 | get { 71 | process.environment 72 | } 73 | set { 74 | process.environment = newValue 75 | } 76 | } 77 | 78 | private lazy var group = DispatchGroup() 79 | 80 | /// Creates new Subprocess 81 | /// 82 | /// - Parameter command: Command represented as an array of strings 83 | public required init(_ command: [String]) { 84 | process = SubprocessDependencyBuilder.shared.makeProcess(command: command) 85 | } 86 | 87 | // You may ask yourself, "Well, how did I get here?" 88 | // It would be nice if we could write something like: 89 | // 90 | // 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 {} 91 | // 92 | // Then the equivelent convenience methods below could take an AsyncSequence as well in the same style. 93 | // 94 | // 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. 95 | // There are a few Swift Forum threads discussing the problems with AsyncSequence in interfaces: 96 | // https://forums.swift.org/t/anyasyncsequence/50828/33 97 | // https://forums.swift.org/t/type-erasure-of-asyncsequences/66547/23 98 | // 99 | // 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. 100 | 101 | /// Run a command. 102 | /// 103 | /// - Parameters: 104 | /// - options: Options to control which output should be returned. 105 | /// - 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. 106 | /// 107 | /// Run a command and optionally read its output. 108 | /// 109 | /// let subprocess = Subprocess(["/bin/cat somefile"]) 110 | /// let (standardOutput, _, waitForExit) = try subprocess.run() 111 | /// 112 | /// Task { 113 | /// for await line in standardOutput.lines { 114 | /// switch line { 115 | /// case "hello": 116 | /// await waitForExit() 117 | /// break 118 | /// default: 119 | /// continue 120 | /// } 121 | /// } 122 | /// } 123 | /// 124 | /// 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. 125 | /// 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. 126 | public func run(options: OutputOptions = [.standardOutput, .standardError]) throws -> (standardOutput: Pipe.AsyncBytes, standardError: Pipe.AsyncBytes, waitUntilExit: @Sendable () async -> Void) { 127 | let standardOutput: Pipe.AsyncBytes = { 128 | if options.contains(.standardOutput) { 129 | let pipe = Pipe() 130 | 131 | process.standardOutput = pipe 132 | return pipe.bytes 133 | } else { 134 | let pipe = Pipe() 135 | 136 | defer { 137 | try? pipe.fileHandleForReading.close() 138 | } 139 | 140 | process.standardOutput = FileHandle.nullDevice 141 | return pipe.bytes 142 | } 143 | }() 144 | let standardError: Pipe.AsyncBytes = { 145 | if options.contains(.standardError) { 146 | let pipe = Pipe() 147 | 148 | process.standardError = pipe 149 | return pipe.bytes 150 | } else { 151 | let pipe = Pipe() 152 | 153 | defer { 154 | try? pipe.fileHandleForReading.close() 155 | } 156 | 157 | process.standardError = FileHandle.nullDevice 158 | return pipe.bytes 159 | } 160 | }() 161 | 162 | let terminationContinuation = TerminationContinuation() 163 | let task: Task = Task.detached { 164 | await withUnsafeContinuation { continuation in 165 | Task { 166 | await terminationContinuation.setContinuation(continuation) 167 | } 168 | } 169 | } 170 | let waitUntilExit = { @Sendable in 171 | await task.value 172 | } 173 | 174 | process.terminationHandler = { _ in 175 | Task { 176 | await terminationContinuation.resume() 177 | } 178 | } 179 | 180 | try process.run() 181 | return (standardOutput, standardError, waitUntilExit) 182 | } 183 | 184 | /// Run an interactive command. 185 | /// 186 | /// - Parameters: 187 | /// - standardInput: An `AsyncSequence` that is used to supply input to the underlying process. 188 | /// - options: Options to control which output should be returned. 189 | /// - 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. 190 | /// 191 | /// Run a command and interactively respond to output. 192 | /// 193 | /// let (stream, input) = { 194 | /// var input: AsyncStream.Continuation! 195 | /// let stream: AsyncStream = AsyncStream { continuation in 196 | /// input = continuation 197 | /// } 198 | /// 199 | /// return (stream, input!) 200 | /// }() 201 | /// 202 | /// let subprocess = Subprocess(["/bin/cat"]) 203 | /// let (standardOutput, _, waitForExit) = try subprocess.run(standardInput: stream) 204 | /// 205 | /// input.yield("hello\n") 206 | /// 207 | /// Task { 208 | /// for await line in standardOutput.lines { 209 | /// switch line { 210 | /// case "hello": 211 | /// input.yield("world\n") 212 | /// case "world": 213 | /// input.yield("and\nuniverse") 214 | /// input.finish() 215 | /// case "universe": 216 | /// await waitForExit() 217 | /// break 218 | /// default: 219 | /// continue 220 | /// } 221 | /// } 222 | /// } 223 | /// 224 | public func run(standardInput: Input, options: OutputOptions = [.standardOutput, .standardError]) throws -> (standardOutput: Pipe.AsyncBytes, standardError: Pipe.AsyncBytes, waitUntilExit: @Sendable () async -> Void) where Input : AsyncSequence, Input.Element == UInt8 { 225 | process.standardInput = try SubprocessDependencyBuilder.shared.makeInputPipe(sequence: standardInput) 226 | return try run(options: options) 227 | } 228 | 229 | /// Suspends the command 230 | public func suspend() -> Bool { 231 | process.suspend() 232 | } 233 | 234 | /// Resumes the command which was suspended 235 | public func resume() -> Bool { 236 | process.resume() 237 | } 238 | 239 | /// Sends the command the term signal 240 | public func kill() { 241 | process.terminate() 242 | } 243 | } 244 | 245 | // Methods for typical one-off acquisition of output from running some command. 246 | extension Subprocess { 247 | /// Additional configuration options. 248 | public struct RunOptions: OptionSet, Sendable { 249 | public let rawValue: Int 250 | 251 | /// Throw an error if the process exited with a non-zero exit code. 252 | public static let throwErrorOnNonZeroExit = Self(rawValue: 1 << 0) 253 | 254 | /// Return the output from standard error instead of standard output. 255 | public static let returnStandardError = Self(rawValue: 1 << 1) 256 | 257 | public init(rawValue: Int) { 258 | self.rawValue = rawValue 259 | } 260 | } 261 | 262 | /// Retreive output as `Data` from running an external command. 263 | /// - Parameters: 264 | /// - command: An external command to run with optional arguments. 265 | /// - standardInput: A type conforming to `DataProtocol` (typically a `Data` type) from which to read input to the external command. 266 | /// - options: Options used to specify runtime behavior. 267 | public static func data(for command: [String], standardInput: (any DataProtocol & Sendable)? = nil, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> Data { 268 | let subprocess = Self(command) 269 | let (standardOutput, standardError, waitForExit) = if let standardInput { 270 | try subprocess.run(standardInput: AsyncStream(UInt8.self, { continuation in 271 | for byte in standardInput { 272 | if case .terminated = continuation.yield(byte) { 273 | break 274 | } 275 | } 276 | 277 | continuation.finish() 278 | })) 279 | } else { 280 | try subprocess.run() 281 | } 282 | 283 | // need to read output for processes that fill their buffers otherwise a wait could occur waiting for a read to clear the buffer 284 | let result = await withTaskGroup(of: Void.self) { group in 285 | let stdoutData = UnsafeData() 286 | let stderrData = UnsafeData() 287 | 288 | group.addTask { 289 | await withTaskCancellationHandler(operation: { 290 | await waitForExit() 291 | }, onCancel: { 292 | subprocess.kill() 293 | }) 294 | } 295 | group.addTask { 296 | var bytes = [UInt8]() 297 | 298 | for await byte in standardOutput { 299 | bytes.append(byte) 300 | } 301 | 302 | stdoutData.set(Data(bytes)) 303 | } 304 | group.addTask { 305 | var bytes = [UInt8]() 306 | 307 | for await byte in standardError { 308 | bytes.append(byte) 309 | } 310 | 311 | stderrData.set(Data(bytes)) 312 | } 313 | 314 | for await _ in group { 315 | // nothing to collect here 316 | } 317 | 318 | return (standardOutputData: stdoutData.value(), standardErrorData: stderrData.value()) 319 | } 320 | try Task.checkCancellation() 321 | 322 | if options.contains(.throwErrorOnNonZeroExit), subprocess.process.terminationStatus != 0 { 323 | throw Error.nonZeroExit(status: subprocess.process.terminationStatus, reason: subprocess.process.terminationReason, standardOutput: result.standardOutputData, standardError: String(decoding: result.standardErrorData, as: UTF8.self)) 324 | } 325 | 326 | let data = if options.contains(.returnStandardError) { 327 | result.standardErrorData 328 | } else { 329 | result.standardOutputData 330 | } 331 | 332 | return data 333 | } 334 | 335 | // MARK: Data convenience methods 336 | 337 | /// Retreive output as `Data` from running an external command. 338 | /// - Parameters: 339 | /// - command: An external command to run with optional arguments. 340 | /// - standardInput: A `String` from which to send input to the external command. 341 | /// - options: Options used to specify runtime behavior. 342 | @inlinable 343 | public static func data(for command: [String], standardInput: String, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> Data { 344 | try await data(for: command, standardInput: standardInput.data(using: .utf8)!, options: options) 345 | } 346 | 347 | /// Retreive output as `Data` from running an external command. 348 | /// - Parameters: 349 | /// - command: An external command to run with optional arguments. 350 | /// - standardInput: A file `URL` from which to read input to the external command. 351 | /// - options: Options used to specify runtime behavior. 352 | public static func data(for command: [String], standardInput: URL, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> Data { 353 | let subprocess = Self(command) 354 | let (standardOutput, standardError, waitForExit) = if #available(macOS 12.0, *) { 355 | try subprocess.run(standardInput: SubprocessDependencyBuilder.shared.makeInputFileHandle(url: standardInput).bytes) 356 | } else if let fileData = try SubprocessDependencyBuilder.shared.makeInputFileHandle(url: standardInput).readToEnd(), !fileData.isEmpty { 357 | try subprocess.run(standardInput: AsyncStream(UInt8.self, { continuation in 358 | for byte in fileData { 359 | if case .terminated = continuation.yield(byte) { 360 | break 361 | } 362 | } 363 | 364 | continuation.finish() 365 | })) 366 | } else { 367 | try subprocess.run() 368 | } 369 | 370 | // need to read output for processes that fill their buffers otherwise a wait could occur waiting for a read to clear the buffer 371 | let result = await withTaskGroup(of: Void.self) { group in 372 | let stdoutData = UnsafeData() 373 | let stderrData = UnsafeData() 374 | 375 | group.addTask { 376 | await withTaskCancellationHandler(operation: { 377 | await waitForExit() 378 | }, onCancel: { 379 | subprocess.kill() 380 | }) 381 | } 382 | group.addTask { 383 | var bytes = [UInt8]() 384 | 385 | for await byte in standardOutput { 386 | bytes.append(byte) 387 | } 388 | 389 | stdoutData.set(Data(bytes)) 390 | } 391 | group.addTask { 392 | var bytes = [UInt8]() 393 | 394 | for await byte in standardError { 395 | bytes.append(byte) 396 | } 397 | 398 | stderrData.set(Data(bytes)) 399 | } 400 | 401 | for await _ in group { 402 | // nothing to collect 403 | } 404 | 405 | return (standardOutputData: stdoutData.value(), standardErrorData: stderrData.value()) 406 | } 407 | try Task.checkCancellation() 408 | 409 | if options.contains(.throwErrorOnNonZeroExit), subprocess.process.terminationStatus != 0 { 410 | throw Error.nonZeroExit(status: subprocess.process.terminationStatus, reason: subprocess.process.terminationReason, standardOutput: result.standardOutputData, standardError: String(decoding: result.standardErrorData, as: UTF8.self)) 411 | } 412 | 413 | let data = if options.contains(.returnStandardError) { 414 | result.standardErrorData 415 | } else { 416 | result.standardOutputData 417 | } 418 | 419 | return data 420 | } 421 | 422 | // MARK: String convenience methods 423 | 424 | /// Retreive output as a UTF8 `String` from running an external command. 425 | /// - Parameters: 426 | /// - command: An external command to run with optional arguments. 427 | /// - standardInput: A type conforming to `DataProtocol` (typically a `Data` type) from which to read input to the external command. 428 | /// - options: Options used to specify runtime behavior. 429 | @inlinable 430 | public static func string(for command: [String], standardInput: (any DataProtocol & Sendable)? = nil, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> String { 431 | String(decoding: try await data(for: command, standardInput: standardInput, options: options), as: UTF8.self) 432 | } 433 | 434 | /// Retreive output as `String` from running an external command. 435 | /// - Parameters: 436 | /// - command: An external command to run with optional arguments. 437 | /// - standardInput: A `String` from which to send input to the external command. 438 | /// - options: Options used to specify runtime behavior. 439 | @inlinable 440 | public static func string(for command: [String], standardInput: String, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> String { 441 | String(decoding: try await data(for: command, standardInput: standardInput, options: options), as: UTF8.self) 442 | } 443 | 444 | /// Retreive output as `String` from running an external command. 445 | /// - Parameters: 446 | /// - command: An external command to run with optional arguments. 447 | /// - standardInput: A file `URL` from which to read input to the external command. 448 | /// - options: Options used to specify runtime behavior. 449 | @inlinable 450 | public static func string(for command: [String], standardInput: URL, options: RunOptions = .throwErrorOnNonZeroExit) async throws -> String { 451 | String(decoding: try await data(for: command, standardInput: standardInput, options: options), as: UTF8.self) 452 | } 453 | 454 | // MARK: Decodable types convenience methods 455 | 456 | /// Retreive output from from running an external command. 457 | /// - Parameters: 458 | /// - command: An external command to run with optional arguments. 459 | /// - standardInput: A type conforming to `DataProtocol` (typically a `Data` type) from which to read input to the external command. 460 | /// - options: Options used to specify runtime behavior. 461 | /// - decoder: A `TopLevelDecoder` that will be used to decode the data. 462 | @inlinable 463 | public static func value(for command: [String], standardInput: (any DataProtocol & Sendable)? = nil, options: RunOptions = .throwErrorOnNonZeroExit, decoder: Decoder) async throws -> Content where Content : Decodable, Decoder : TopLevelDecoder, Decoder.Input == Data { 464 | try await decoder.decode(Content.self, from: data(for: command, standardInput: standardInput, options: options)) 465 | } 466 | 467 | /// Retreive output from from running an external command. 468 | /// - Parameters: 469 | /// - command: An external command to run with optional arguments. 470 | /// - standardInput: A `String` from which to send input to the external command. 471 | /// - options: Options used to specify runtime behavior. 472 | /// - decoder: A `TopLevelDecoder` that will be used to decode the data. 473 | @inlinable 474 | 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 { 475 | try await decoder.decode(Content.self, from: data(for: command, standardInput: standardInput, options: options)) 476 | } 477 | 478 | /// Retreive output from from running an external command. 479 | /// - Parameters: 480 | /// - command: An external command to run with optional arguments. 481 | /// - standardInput: A file `URL` from which to read input to the external command. 482 | /// - options: Options used to specify runtime behavior. 483 | /// - decoder: A `TopLevelDecoder` that will be used to decode the data. 484 | @inlinable 485 | 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 { 486 | try await decoder.decode(Content.self, from: data(for: command, standardInput: standardInput, options: options)) 487 | } 488 | } 489 | 490 | // closure based methods 491 | extension Subprocess { 492 | /// Launches command with read handlers and termination handler 493 | /// 494 | /// - Parameters: 495 | /// - input: File or data to write to standard input of the process 496 | /// - outputHandler: Block called whenever new data is read from standard output of the process 497 | /// - errorHandler: Block called whenever new data is read from standard error of the process 498 | /// - terminationHandler: Block called when process has terminated and all output handlers have returned 499 | public func launch(input: Input? = nil, outputHandler: (@Sendable (Data) -> Void)? = nil, errorHandler: (@Sendable (Data) -> Void)? = nil, terminationHandler: (@Sendable (Subprocess) -> Void)? = nil) throws { 500 | process.standardInput = try input?.createPipeOrFileHandle() 501 | 502 | process.standardOutput = if let outputHandler { 503 | createPipeWithReadabilityHandler(outputHandler) 504 | } else { 505 | FileHandle.nullDevice 506 | } 507 | 508 | process.standardError = if let errorHandler { 509 | createPipeWithReadabilityHandler(errorHandler) 510 | } else { 511 | FileHandle.nullDevice 512 | } 513 | 514 | group.enter() 515 | process.terminationHandler = { [unowned self] _ in 516 | group.leave() 517 | } 518 | 519 | group.notify(queue: .main) { 520 | terminationHandler?(self) 521 | } 522 | 523 | try process.run() 524 | } 525 | 526 | /// Block type called for executing process returning data from standard out and standard error 527 | public typealias DataTerminationHandler = @Sendable (_ process: Subprocess, _ stdout: Data, _ stderr: Data) -> Void 528 | 529 | /// Launches command calling a block when process terminates 530 | /// 531 | /// - Parameters: 532 | /// - input: File or data to write to standard input of the process 533 | /// - terminationHandler: Block called with Subprocess, stdout Data, stderr Data 534 | public func launch(input: Input? = nil, terminationHandler: @escaping DataTerminationHandler) throws { 535 | let stdoutData = UnsafeData() 536 | let stderrData = UnsafeData() 537 | 538 | try launch(input: input, outputHandler: { data in 539 | stdoutData.append(data) 540 | }, errorHandler: { data in 541 | stderrData.append(data) 542 | }, terminationHandler: { selfRef in 543 | let standardOutput = stdoutData.value() 544 | let standardError = stderrData.value() 545 | 546 | terminationHandler(selfRef, standardOutput, standardError) 547 | }) 548 | } 549 | 550 | /// Waits for process to complete and all handlers to be called. Not to be 551 | /// confused with `Process.waitUntilExit()` which can return before its 552 | /// `terminationHandler` is called. 553 | /// Calling this method when using the non-deprecated methods will return immediately and not wait for the process to exit. 554 | public func waitForTermination() { 555 | group.wait() 556 | } 557 | 558 | private func createPipeWithReadabilityHandler(_ handler: @escaping @Sendable (Data) -> Void) -> Pipe { 559 | let pipe = Pipe() 560 | 561 | group.enter() 562 | 563 | let stream: AsyncStream = AsyncStream { continuation in 564 | pipe.fileHandleForReading.readabilityHandler = { handle in 565 | let data = handle.availableData 566 | 567 | guard !data.isEmpty else { 568 | handle.readabilityHandler = nil 569 | continuation.finish() 570 | return 571 | } 572 | 573 | continuation.yield(data) 574 | } 575 | 576 | continuation.onTermination = { _ in 577 | pipe.fileHandleForReading.readabilityHandler = nil 578 | } 579 | } 580 | 581 | Task { 582 | for await data in stream { 583 | handler(data) 584 | } 585 | 586 | group.leave() 587 | } 588 | 589 | return pipe 590 | } 591 | } 592 | 593 | extension Subprocess { 594 | /// Errors specific to `Subprocess`. 595 | public enum Error: LocalizedError { 596 | case nonZeroExit(status: Int32, reason: Process.TerminationReason, standardOutput: Data, standardError: String) 597 | 598 | public var errorDescription: String? { 599 | switch self { 600 | case let .nonZeroExit(status: terminationStatus, reason: _, standardOutput: _, standardError: errorString): 601 | return "Process exited with status \(terminationStatus): \(errorString)" 602 | } 603 | } 604 | } 605 | } 606 | 607 | private actor TerminationContinuation { 608 | private var continuation: UnsafeContinuation? 609 | private var didResume = false 610 | 611 | deinit { 612 | continuation?.resume() 613 | } 614 | 615 | func setContinuation(_ continuation: UnsafeContinuation) { 616 | self.continuation = continuation 617 | 618 | // in case the termination happened before the task was able to set the continuation 619 | if didResume { 620 | resume() 621 | } 622 | } 623 | 624 | func resume() { 625 | continuation?.resume() 626 | continuation = nil 627 | didResume = true 628 | } 629 | } 630 | -------------------------------------------------------------------------------- /Sources/Subprocess/SubprocessDependencyBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SubprocessDependencyBuilder.swift 3 | // Subprocess 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | #if swift(>=6.0) 29 | public import Foundation 30 | #else 31 | import Foundation 32 | #endif 33 | 34 | /// Protocol call used for dependency injection 35 | public protocol SubprocessDependencyFactory { 36 | /// Creates new Subprocess 37 | /// 38 | /// - Parameter command: Command represented as an array of strings 39 | /// - Returns: New Subprocess instance 40 | func makeProcess(command: [String]) -> Process 41 | 42 | /// Creates a FileHandle for reading 43 | /// 44 | /// - Parameter url: File URL 45 | /// - Returns: New FileHandle for reading 46 | /// - Throws: When unable to open file for reading 47 | func makeInputFileHandle(url: URL) throws -> FileHandle 48 | 49 | /// Creates a `Pipe` and writes the sequence. 50 | /// 51 | /// - Parameter sequence: An `AsyncSequence` that supplies data to be written. 52 | /// - Returns: New `Pipe` instance. 53 | func makeInputPipe(sequence: Input) throws -> Pipe where Input : AsyncSequence, Input.Element == UInt8 54 | } 55 | 56 | /// Default implementation of SubprocessDependencyFactory 57 | public struct SubprocessDependencyBuilder: SubprocessDependencyFactory { 58 | private static let queue = DispatchQueue(label: "\(Self.self)") 59 | 60 | #if compiler(<5.10) 61 | private static var _shared: any SubprocessDependencyFactory = SubprocessDependencyBuilder() 62 | #else 63 | nonisolated(unsafe) private static var _shared: any SubprocessDependencyFactory = SubprocessDependencyBuilder() 64 | #endif 65 | /// Shared instance used for dependency creation 66 | public static var shared: any SubprocessDependencyFactory { 67 | get { 68 | queue.sync { 69 | _shared 70 | } 71 | } 72 | set { 73 | queue.sync { 74 | _shared = newValue 75 | } 76 | } 77 | } 78 | 79 | public func makeProcess(command: [String]) -> Process { 80 | var tmp = command 81 | let process = Process() 82 | 83 | process.executableURL = URL(fileURLWithPath: tmp.removeFirst()) 84 | process.arguments = tmp 85 | return process 86 | } 87 | 88 | public func makeInputFileHandle(url: URL) throws -> FileHandle { 89 | return try FileHandle(forReadingFrom: url) 90 | } 91 | 92 | public func makeInputPipe(sequence: Input) throws -> Pipe where Input : AsyncSequence & Sendable, Input.Element == UInt8 { 93 | let pipe = Pipe() 94 | // see here: https://developer.apple.com/forums/thread/690382 95 | let result = fcntl(pipe.fileHandleForWriting.fileDescriptor, F_SETNOSIGPIPE, 1) 96 | 97 | guard result >= 0 else { 98 | throw NSError(domain: NSPOSIXErrorDomain, code: Int(result), userInfo: nil) 99 | } 100 | 101 | pipe.fileHandleForWriting.writeabilityHandler = { handle in 102 | handle.writeabilityHandler = nil 103 | 104 | Task { 105 | defer { 106 | try? handle.close() 107 | } 108 | 109 | // `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. 110 | // https://developer.apple.com/forums/thread/690310 111 | // According to Swift forum talk byte by byte reads _could_ be optimized by the compiler depending on how much visibility it has into methods. 112 | for try await byte in sequence { 113 | try handle.write(contentsOf: [byte]) 114 | } 115 | } 116 | } 117 | 118 | return pipe 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Sources/Subprocess/UnsafeData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UnsafeData.swift 3 | // Subprocess 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | import Foundation 29 | 30 | // Avoids errors for modifying data in concurrent contexts when we know it's safe to do so. 31 | final class UnsafeData: @unchecked Sendable { 32 | private lazy var data = Data() 33 | 34 | func set(_ data: Data) { 35 | self.data = data 36 | } 37 | 38 | func append(_ other: Data) { 39 | data.append(other) 40 | } 41 | 42 | func value() -> Data { 43 | data 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Sources/SubprocessMocks/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 2.0.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/SubprocessMocks/MockOutput.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockOutput.swift 3 | // SubprocessMocks 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | #if swift(>=6.0) 29 | public import Foundation 30 | #else 31 | import Foundation 32 | #endif 33 | 34 | /// A way to supply data to mock methods 35 | public protocol MockOutput: Sendable { 36 | var data: Data { get } 37 | } 38 | 39 | extension Data: MockOutput { 40 | public var data: Data { 41 | self 42 | } 43 | } 44 | 45 | extension String: MockOutput { 46 | public var data: Data { 47 | Data(self.utf8) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/SubprocessMocks/MockProcess.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockProcess.swift 3 | // SubprocessMocks 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | #if swift(>=6.0) 29 | public import Foundation 30 | #else 31 | import Foundation 32 | #endif 33 | import Subprocess 34 | 35 | /// Interface used for mocking a process 36 | public struct MockProcess: Sendable { 37 | 38 | /// The underlying `MockProcessReference` 39 | public var reference: MockProcessReference 40 | 41 | /// Writes given data to standard out of the mock child process 42 | /// - Parameter data: Data to write to standard out of the mock child process 43 | public func writeTo(stdout: some MockOutput) { 44 | do { 45 | try reference.standardOutputPipe?.fileHandleForWriting.write(contentsOf: stdout.data) 46 | } catch { 47 | fatalError("unexpected write failure: \(error)") 48 | } 49 | } 50 | 51 | /// Writes given data to standard error of the mock child process 52 | /// - Parameter data: Data to write to standard error of the mock child process 53 | public func writeTo(stderr: some MockOutput) { 54 | do { 55 | try reference.standardErrorPipe?.fileHandleForWriting.write(contentsOf: stderr.data) 56 | } catch { 57 | fatalError("unexpected write failure: \(error)") 58 | } 59 | } 60 | 61 | /// Completes the mock process execution 62 | /// - Parameters: 63 | /// - statusCode: Exit code of the process (Default: 0) 64 | /// - reason: Termination reason of the process (Default: .exit) 65 | public func exit(withStatus statusCode: Int32 = 0, reason: Process.TerminationReason = .exit) { 66 | reference.exit(withStatus: statusCode, reason: reason) 67 | } 68 | } 69 | 70 | /// Subclass of `Process` used for mocking 71 | open class MockProcessReference: Process, @unchecked Sendable { 72 | /// Context information and values used for overriden properties 73 | public struct Context { 74 | 75 | /// State of the mock process 76 | public enum State { 77 | case initialized 78 | case running 79 | case uncaughtSignal 80 | case exited 81 | } 82 | public var terminationStatus: Int32 = 0 83 | public var processIdentifier: Int32 = -1 84 | public var state: State = .initialized 85 | 86 | /// Block called to stub the call to launch 87 | public var runStub: (MockProcess) throws -> Void 88 | 89 | var standardInput: Any? 90 | var standardOutput: Any? 91 | var standardError: Any? 92 | var terminationHandler: (@Sendable (Process) -> Void)? 93 | } 94 | 95 | public var context: Context 96 | 97 | /// Creates a new `MockProcessReference` which throws an error on launch 98 | /// - Parameter error: Error thrown when `Process.run` is called 99 | public init(withRunError error: any Error) { 100 | context = Context(runStub: { _ in throw error }) 101 | } 102 | 103 | /// Creates a new `MockProcessReference` calling run stub block 104 | /// - Parameter block: Block used to stub `Process.run` 105 | public init(withRunBlock block: @escaping @Sendable (MockProcess) -> Void) { 106 | context = Context(runStub: { mock in 107 | Task(priority: .userInitiated) { 108 | block(mock) 109 | } 110 | }) 111 | } 112 | 113 | /// Block called when `Process.terminate` is called 114 | public var stubTerminate: ((MockProcessReference) -> Void)? 115 | 116 | /// Block called when `Process.resume` is called 117 | public var stubResume: (() -> Bool)? 118 | 119 | /// Block called when `Process.suspend` is called 120 | public var stubSuspend: (() -> Bool)? 121 | 122 | /// Block called when `Process.waitUntilExit` is called 123 | public var stubWaitUntilExit: (() -> Void)? 124 | 125 | /// standardOutput object as a Pipe 126 | public var standardOutputPipe: Pipe? { standardOutput as? Pipe } 127 | 128 | /// standardError object as a Pipe 129 | public var standardErrorPipe: Pipe? { standardError as? Pipe } 130 | 131 | /// Environment property 132 | private var _environment: [String: String]? 133 | open override var environment: [String: String]? { 134 | get { 135 | return _environment 136 | } 137 | set { 138 | _environment = newValue 139 | } 140 | } 141 | 142 | /// Completes the mock process execution 143 | /// - Parameters: 144 | /// - statusCode: Exit code of the process (Default: 0) 145 | /// - reason: Termination reason of the process (Default: .exit) 146 | public func exit(withStatus statusCode: Int32 = 0, reason: Process.TerminationReason = .exit) { 147 | guard context.state == .running else { return } 148 | context.state = (reason == .exit) ? .exited : .uncaughtSignal 149 | context.terminationStatus = statusCode 150 | try? standardOutputPipe?.fileHandleForWriting.close() 151 | try? standardErrorPipe?.fileHandleForWriting.close() 152 | 153 | guard let handler = terminationHandler else { return } 154 | terminationHandler = nil 155 | handler(self) 156 | } 157 | 158 | open override var terminationReason: TerminationReason { 159 | context.state == .uncaughtSignal ? .uncaughtSignal : .exit 160 | } 161 | 162 | open override var terminationStatus: Int32 { context.terminationStatus } 163 | 164 | open override var processIdentifier: Int32 { context.processIdentifier } 165 | 166 | open override var isRunning: Bool { context.state == .running } 167 | 168 | open override func run() throws { 169 | guard context.state == .initialized else { return } 170 | context.state = .running 171 | context.processIdentifier = .random(in: 0...Int32.max) 172 | let mock = MockProcess(reference: self) 173 | try context.runStub(mock) 174 | } 175 | 176 | open override func terminate() { 177 | if let stub = stubTerminate { 178 | stub(self) 179 | context.state = .exited 180 | } else { 181 | context.state = .uncaughtSignal 182 | } 183 | } 184 | 185 | open override var standardInput: Any? { 186 | get { context.standardInput } 187 | set { context.standardInput = newValue } 188 | } 189 | 190 | open override var standardOutput: Any? { 191 | get { context.standardOutput } 192 | set { context.standardOutput = newValue } 193 | } 194 | 195 | open override var standardError: Any? { 196 | get { context.standardError } 197 | set { context.standardError = newValue } 198 | } 199 | 200 | open override var terminationHandler: (@Sendable (Process) -> Void)? { 201 | get { context.terminationHandler } 202 | set { context.terminationHandler = newValue } 203 | } 204 | 205 | open override func resume() -> Bool { stubResume?() ?? false } 206 | 207 | open override func suspend() -> Bool { stubSuspend?() ?? false } 208 | 209 | open override func waitUntilExit() { 210 | stubWaitUntilExit?() 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /Sources/SubprocessMocks/MockShell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockShell.swift 3 | // SubprocessMocks 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | #if swift(>=6.0) 29 | public import Foundation 30 | public import Subprocess 31 | #else 32 | import Foundation 33 | import Subprocess 34 | #endif 35 | 36 | @available(*, deprecated, message: "Swift Concurrency methods in Subprocess replace Shell") 37 | extension Shell: SubprocessMockObject {} 38 | 39 | @available(*, deprecated, message: "Swift Concurrency methods in Subprocess replace Shell") 40 | public extension Shell { 41 | 42 | /// Adds a mock for a command which throws an error when `Process.run` is called 43 | /// 44 | /// - Parameters: 45 | /// - command: The command to mock 46 | /// - error: Error thrown when `Process.run` is called 47 | static func stub(_ command: [String], error: any Error) { 48 | Subprocess.stub(command, error: error) 49 | } 50 | 51 | /// Adds a mock for a command which writes the given data to the outputs and exits with the provided exit code 52 | /// 53 | /// - Parameters: 54 | /// - command: The command to mock 55 | /// - standardOutput: Data written to stdout of the process 56 | /// - standardError: Data written to stderr of the process 57 | /// - exitCode: Exit code of the process (Default: 0) 58 | static func stub(_ command: [String], 59 | standardOutput: Data? = nil, 60 | standardError: Data? = nil, 61 | exitCode: Int32 = 0) { 62 | Subprocess.stub(command) { process in 63 | if let data = standardOutput { 64 | process.writeTo(stdout: data) 65 | } 66 | if let data = standardError { 67 | process.writeTo(stderr: data) 68 | } 69 | process.exit(withStatus: exitCode) 70 | } 71 | } 72 | 73 | /// Adds a mock for a command which writes the given text to the outputs and exits with the provided exit code 74 | /// 75 | /// - Parameters: 76 | /// - command: The command to mock 77 | /// - stdout: String written to stdout of the process 78 | /// - stderr: String written to stderr of the process (Default: nil) 79 | /// - exitCode: Exit code of the process (Default: 0) 80 | static func stub(_ command: [String], 81 | stdout: String, 82 | stderr: String? = nil, 83 | exitCode: Int32 = 0) { 84 | Subprocess.stub(command) { process in 85 | process.writeTo(stdout: stdout) 86 | if let text = stderr { 87 | process.writeTo(stderr: text) 88 | } 89 | process.exit(withStatus: exitCode) 90 | } 91 | } 92 | 93 | /// Adds a mock for a command which writes the given text to stderr and exits with the provided exit code 94 | /// 95 | /// - Parameters: 96 | /// - command: The command to mock 97 | /// - stdout: String written to stdout of the process 98 | /// - stderr: String written to stderr of the process 99 | /// - exitCode: Exit code of the process (Default: 0) 100 | static func stub(_ command: [String], 101 | stderr: String, 102 | exitCode: Int32 = 0) { 103 | Subprocess.stub(command) { process in 104 | process.writeTo(stderr: stderr) 105 | process.exit(withStatus: exitCode) 106 | } 107 | } 108 | 109 | /// Adds a mock for a command which writes the given property list to stdout and exits with the provided exit code 110 | /// 111 | /// - Parameters: 112 | /// - command: The command to mock 113 | /// - plist: Property list object serialized and written to stdout 114 | /// - exitCode: Exit code of the process (Default: 0) 115 | /// - Throws: Error when serializing property list object 116 | static func stub(_ command: [String], 117 | plist: Any, 118 | exitCode: Int32 = 0) throws { 119 | let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) 120 | Shell.stub(command, standardOutput: data, exitCode: exitCode) 121 | } 122 | 123 | /// Adds a mock for a command which writes the given JSON object to stdout and exits with the provided exit code 124 | /// 125 | /// - Parameters: 126 | /// - command: The command to mock 127 | /// - plist: JSON object serialized and written to stdout 128 | /// - exitCode: Exit code of the process (Default: 0) 129 | /// - Throws: Error when serializing JSON object 130 | static func stub(_ command: [String], 131 | json: Any, 132 | exitCode: Int32 = 0) throws { 133 | let data = try JSONSerialization.data(withJSONObject: json, options: []) 134 | Shell.stub(command, standardOutput: data, exitCode: exitCode) 135 | } 136 | 137 | /// Adds a mock for a command which writes the given encodable object as a property list to stdout 138 | /// and exits with the provided exit code 139 | /// 140 | /// - Parameters: 141 | /// - command: The command to mock 142 | /// - plistObject: Encodable object written to stdout as a property list 143 | /// - exitCode: Exit code of the process (Default: 0) 144 | /// - Throws: Error when encoding the provided object 145 | static func stub(_ command: [String], 146 | plistObject: T, 147 | exitCode: Int32 = 0) throws { 148 | let data = try PropertyListEncoder().encode(plistObject) 149 | Shell.stub(command, standardOutput: data, exitCode: exitCode) 150 | } 151 | 152 | /// Adds a mock for a command which writes the given encodable object as JSON to stdout 153 | /// and exits with the provided exit code 154 | /// 155 | /// - Parameters: 156 | /// - command: The command to mock 157 | /// - jsonObject: Encodable object written to stdout as JSON 158 | /// - exitCode: Exit code of the process (Default: 0) 159 | /// - Throws: Error when encoding the provided object 160 | static func stub(_ command: [String], 161 | jsonObject: T, 162 | exitCode: Int32 = 0) throws { 163 | let data = try JSONEncoder().encode(jsonObject) 164 | Shell.stub(command, standardOutput: data, exitCode: exitCode) 165 | } 166 | 167 | // MARK: - 168 | 169 | /// Adds an expected mock for a command which throws an error when `Process.run` is called 170 | /// 171 | /// - Parameters: 172 | /// - command: The command to mock 173 | /// - input: The expected input of the process 174 | /// - error: Error thrown when `Process.run` is called 175 | /// - file: Source file where expect was called (Default: #file) 176 | /// - line: Line number of source file where expect was called (Default: #line) 177 | static func expect(_ command: [String], 178 | input: Input? = nil, 179 | error: any Swift.Error, 180 | file: StaticString = #file, 181 | line: UInt = #line) { 182 | Subprocess.expect(command, input: input, error: error, file: file, line: line) 183 | } 184 | 185 | /// Adds an expected mock for a command which writes the given data to the outputs 186 | /// and exits with the provided exit code 187 | /// 188 | /// - Parameters: 189 | /// - command: The command to mock 190 | /// - input: The expected input of the process 191 | /// - standardOutput: Data written to stdout of the process 192 | /// - standardError: Data written to stderr of the process 193 | /// - exitCode: Exit code of the process (Default: 0) 194 | /// - file: Source file where expect was called (Default: #file) 195 | /// - line: Line number of source file where expect was called (Default: #line) 196 | static func expect(_ command: [String], 197 | input: Input? = nil, 198 | standardOutput: Data? = nil, 199 | standardError: Data? = nil, 200 | exitCode: Int32 = 0, 201 | file: StaticString = #file, 202 | line: UInt = #line) { 203 | Subprocess.expect(command, input: input, file: file, line: line) { process in 204 | if let data = standardOutput { 205 | process.writeTo(stdout: data) 206 | } 207 | if let data = standardError { 208 | process.writeTo(stderr: data) 209 | } 210 | process.exit(withStatus: exitCode) 211 | } 212 | } 213 | 214 | /// Adds an expected mock for a command which writes the given text to the outputs 215 | /// and exits with the provided exit code 216 | /// 217 | /// - Parameters: 218 | /// - command: The command to mock 219 | /// - input: The expected input of the process 220 | /// - stdout: String written to stdout of the process 221 | /// - stderr: String written to stderr of the process (Default: nil) 222 | /// - exitCode: Exit code of the process (Default: 0) 223 | /// - file: Source file where expect was called (Default: #file) 224 | /// - line: Line number of source file where expect was called (Default: #line) 225 | static func expect(_ command: [String], 226 | input: Input? = nil, 227 | stdout: String, 228 | stderr: String? = nil, 229 | exitCode: Int32 = 0, 230 | file: StaticString = #file, 231 | line: UInt = #line) { 232 | Subprocess.expect(command, input: input, file: file, line: line) { process in 233 | process.writeTo(stdout: stdout) 234 | if let text = stderr { 235 | process.writeTo(stderr: text) 236 | } 237 | process.exit(withStatus: exitCode) 238 | } 239 | } 240 | 241 | /// Adds an expected mock for a command which writes the given text to stderr and exits with the provided exit code 242 | /// 243 | /// - Parameters: 244 | /// - command: The command to mock 245 | /// - input: The expected input of the process 246 | /// - stdout: String written to stdout of the process 247 | /// - stderr: String written to stderr of the process 248 | /// - exitCode: Exit code of the process (Default: 0) 249 | /// - file: Source file where expect was called (Default: #file) 250 | /// - line: Line number of source file where expect was called (Default: #line) 251 | static func expect(_ command: [String], 252 | input: Input? = nil, 253 | stderr: String, 254 | exitCode: Int32 = 0, 255 | file: StaticString = #file, 256 | line: UInt = #line) { 257 | Subprocess.expect(command, input: input, file: file, line: line) { process in 258 | process.writeTo(stderr: stderr) 259 | process.exit(withStatus: exitCode) 260 | } 261 | } 262 | 263 | /// Adds an expected mock for a command which writes the given property list to stdout 264 | /// and exits with the provided exit code 265 | /// 266 | /// - Parameters: 267 | /// - command: The command to mock 268 | /// - input: The expected input of the process 269 | /// - plist: Property list object serialized and written to stdout 270 | /// - exitCode: Exit code of the process (Default: 0) 271 | /// - file: Source file where expect was called (Default: #file) 272 | /// - line: Line number of source file where expect was called (Default: #line) 273 | /// - Throws: Error when serializing property list object 274 | static func expect(_ command: [String], 275 | input: Input? = nil, 276 | plist: Any, 277 | exitCode: Int32 = 0, 278 | file: StaticString = #file, 279 | line: UInt = #line) throws { 280 | let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) 281 | Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line) 282 | } 283 | 284 | /// Adds an expected mock for a command which writes the given JSON object to stdout 285 | /// and exits with the provided exit code 286 | /// 287 | /// - Parameters: 288 | /// - command: The command to mock 289 | /// - input: The expected input of the process 290 | /// - plist: JSON object serialized and written to stdout 291 | /// - exitCode: Exit code of the process (Default: 0) 292 | /// - file: Source file where expect was called (Default: #file) 293 | /// - line: Line number of source file where expect was called (Default: #line) 294 | /// - Throws: Error when serializing JSON object 295 | static func expect(_ command: [String], 296 | input: Input? = nil, 297 | json: Any, 298 | exitCode: Int32 = 0, 299 | file: StaticString = #file, 300 | line: UInt = #line) throws { 301 | let data = try JSONSerialization.data(withJSONObject: json, options: []) 302 | Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line) 303 | } 304 | 305 | /// Adds an expected mock for a command which writes the given encodable object as a property list to stdout 306 | /// and exits with the provided exit code 307 | /// 308 | /// - Parameters: 309 | /// - command: The command to mock 310 | /// - input: The expected input of the process 311 | /// - plistObject: Encodable object written to stdout as a property list 312 | /// - exitCode: Exit code of the process (Default: 0) 313 | /// - file: Source file where expect was called (Default: #file) 314 | /// - line: Line number of source file where expect was called (Default: #line) 315 | /// - Throws: Error when encoding the provided object 316 | static func expect(_ command: [String], 317 | input: Input? = nil, 318 | plistObject: T, 319 | exitCode: Int32 = 0, 320 | file: StaticString = #file, 321 | line: UInt = #line) throws { 322 | let data = try PropertyListEncoder().encode(plistObject) 323 | Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line) 324 | } 325 | 326 | /// Adds an expected mock for a command which writes the given encodable object as JSON to stdout 327 | /// and exits with the provided exit code 328 | /// 329 | /// - Parameters: 330 | /// - command: The command to mock 331 | /// - input: The expected input of the process 332 | /// - jsonObject: Encodable object written to stdout as JSON 333 | /// - exitCode: Exit code of the process (Default: 0) 334 | /// - file: Source file where expect was called (Default: #file) 335 | /// - line: Line number of source file where expect was called (Default: #line) 336 | /// - Throws: Error when encoding the provided object 337 | static func expect(_ command: [String], 338 | input: Input? = nil, 339 | jsonObject: T, 340 | exitCode: Int32 = 0, 341 | file: StaticString = #file, 342 | line: UInt = #line) throws { 343 | let data = try JSONEncoder().encode(jsonObject) 344 | Shell.expect(command, input: input, standardOutput: data, exitCode: exitCode, file: file, line: line) 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /Sources/SubprocessMocks/MockSubprocess.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockSubprocess.swift 3 | // SubprocessMocks 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | #if swift(>=6.0) 29 | public import Foundation 30 | public import Combine 31 | public import Subprocess 32 | #else 33 | import Foundation 34 | import Combine 35 | import Subprocess 36 | #endif 37 | 38 | extension Subprocess: SubprocessMockObject {} 39 | 40 | public extension Subprocess { 41 | 42 | /// Adds a mock for a given command which throws an error when `Process.run` is called 43 | /// 44 | /// - Parameters: 45 | /// - command: The command to mock 46 | /// - error: Error thrown when `Process.run` is called 47 | static func stub(_ command: [String], error: any Swift.Error) { 48 | let mock = MockProcessReference(withRunError: error) 49 | MockSubprocessDependencyBuilder.shared.stub(command, process: mock) 50 | } 51 | 52 | /// Adds a mock for a given command which calls the run block to mock process execution 53 | /// 54 | /// - Important: You must call`MockProcess.exit` for the process to complete 55 | /// - Parameters: 56 | /// - command: The command to mock 57 | /// - runBlock: Block called with a `MockProcess` to mock process execution. 58 | static func stub(_ command: [String], runBlock: (@Sendable (MockProcess) -> Void)? = nil) { 59 | let mock = MockProcessReference(withRunBlock: runBlock ?? { $0.exit() }) 60 | MockSubprocessDependencyBuilder.shared.stub(command, process: mock) 61 | } 62 | 63 | /// Adds a mock for a command which writes the given data to the outputs and exits with the provided exit code 64 | /// 65 | /// - Parameters: 66 | /// - command: The command to mock 67 | /// - standardOutput: Data written to stdout of the process 68 | /// - standardError: Data written to stderr of the process 69 | /// - exitCode: Exit code of the process (Default: 0) 70 | static func stub(_ command: [String], standardOutput: (any MockOutput)? = nil, standardError: (any MockOutput)? = nil, exitCode: Int32 = 0) { 71 | stub(command) { process in 72 | if let data = standardOutput { 73 | process.writeTo(stdout: data) 74 | } 75 | 76 | if let data = standardError { 77 | process.writeTo(stderr: data) 78 | } 79 | 80 | process.exit(withStatus: exitCode) 81 | } 82 | } 83 | 84 | /// Adds a mock for a command which writes the given encodable object as JSON to stdout 85 | /// and exits with the provided exit code 86 | /// 87 | /// - Parameters: 88 | /// - command: The command to mock 89 | /// - content: Encodable object written to stdout 90 | /// - encoder: `TopLevelEncoder` used to encoder `content` into `Data`. 91 | /// - exitCode: Exit code of the process (Default: 0) 92 | /// - Throws: Error when encoding the provided object 93 | static func stub(_ command: [String], content: Content, encoder: Encoder, exitCode: Int32 = 0) throws where Content : Encodable, Encoder : TopLevelEncoder, Encoder.Output == Data { 94 | let data: Data = try encoder.encode(content) 95 | 96 | stub(command, standardOutput: data, exitCode: exitCode) 97 | } 98 | 99 | // MARK: - 100 | 101 | /// Adds an expected mock for a given command which throws an error when `Process.run` is called 102 | /// 103 | /// - Parameters: 104 | /// - command: The command to mock 105 | /// - input: The expected input of the process 106 | /// - error: Error thrown when `Process.run` is called 107 | /// - file: Source file where expect was called (Default: #file) 108 | /// - line: Line number of source file where expect was called (Default: #line) 109 | static func expect(_ command: [String], input: Input? = nil, error: any Swift.Error, file: StaticString = #file, line: UInt = #line) { 110 | let mock = MockProcessReference(withRunError: error) 111 | MockSubprocessDependencyBuilder.shared.expect(command, input: input, process: mock, file: file, line: line) 112 | } 113 | 114 | /// Adds an expected mock for a given command which calls the run block to mock process execution 115 | /// 116 | /// - Important: You must call`MockProcess.exit` for the process to complete 117 | /// - Parameters: 118 | /// - command: The command to mock 119 | /// - input: The expected input of the process 120 | /// - file: Source file where expect was called (Default: #file) 121 | /// - line: Line number of source file where expect was called (Default: #line) 122 | /// - runBlock: Block called with a `MockProcess` to mock process execution 123 | static func expect(_ command: [String], input: Input? = nil, file: StaticString = #file, line: UInt = #line, runBlock: (@Sendable (MockProcess) -> Void)? = nil) { 124 | let mock = MockProcessReference(withRunBlock: runBlock ?? { $0.exit() }) 125 | MockSubprocessDependencyBuilder.shared.expect(command, input: input, process: mock, file: file, line: line) 126 | } 127 | 128 | /// Adds a mock for a command which writes the given data to the outputs and exits with the provided exit code 129 | /// 130 | /// - Parameters: 131 | /// - command: The command to mock 132 | /// - standardOutput: Data written to stdout of the process 133 | /// - standardError: Data written to stderr of the process 134 | /// - exitCode: Exit code of the process (Default: 0) 135 | 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) { 136 | expect(command, input: input, file: file, line: line) { process in 137 | if let data = standardOutput { 138 | process.writeTo(stdout: data) 139 | } 140 | 141 | if let data = standardError { 142 | process.writeTo(stderr: data) 143 | } 144 | 145 | process.exit(withStatus: exitCode) 146 | } 147 | } 148 | 149 | /// Adds a mock for a command which writes the given encodable object as JSON to stdout 150 | /// and exits with the provided exit code 151 | /// 152 | /// - Parameters: 153 | /// - command: The command to mock 154 | /// - content: Encodable object written to stdout 155 | /// - encoder: `TopLevelEncoder` used to encoder `content` into `Data`. 156 | /// - exitCode: Exit code of the process (Default: 0) 157 | /// - Throws: Error when encoding the provided object 158 | 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 { 159 | let data: Data = try encoder.encode(content) 160 | 161 | expect(command, standardOutput: data, input: input, exitCode: exitCode, file: file, line: line) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Sources/SubprocessMocks/MockSubprocessDependencyBuilder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockSubprocessDependencyBuilder.swift 3 | // SubprocessMocks 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | #if swift(>=6.0) 29 | public import Foundation 30 | #else 31 | import Foundation 32 | #endif 33 | import Subprocess 34 | 35 | /// Error representing a failed call to Subprocess.expect or Shell.expect 36 | public struct ExpectationError: Error { 37 | /// Source file where expect was called 38 | public var file: StaticString 39 | /// Line number where expect was called 40 | public var line: UInt 41 | /// Error message 42 | public var message: String 43 | } 44 | 45 | /// Type representing possible errors thrown 46 | public enum MockSubprocessError: Error { 47 | /// Error containing command thrown when a process is launched that was not stubbed 48 | case missingMock([String]) 49 | /// List of expectations which failed 50 | case missedExpectations([ExpectationError]) 51 | } 52 | 53 | public protocol SubprocessMockObject {} 54 | 55 | public extension SubprocessMockObject { 56 | 57 | /// Verifies expected stubs and resets mocking 58 | /// - Throws: A `MockSubprocessError.missedExpectations` containing failed expectations 59 | static func verify() throws { try MockSubprocessDependencyBuilder.shared.verify() } 60 | 61 | /// Verifies expected stubs and resets mocking 62 | /// - Parameter missedBlock: Block called for each failed expectation 63 | static func verify(missedBlock: (ExpectationError) -> Void) { 64 | MockSubprocessDependencyBuilder.shared.verify(missedBlock: missedBlock) 65 | } 66 | 67 | /// Resets mocking 68 | static func reset() { MockSubprocessDependencyBuilder.shared.reset() } 69 | } 70 | 71 | public class MockFileHandle: FileHandle, @unchecked Sendable { 72 | public var url: URL? 73 | } 74 | 75 | public final class MockPipe: Pipe, @unchecked Sendable { 76 | private static let queue = DispatchQueue(label: "\(MockPipe.self)") 77 | private var _data: Data? 78 | public var data: Data? { 79 | get { 80 | Self.queue.sync { 81 | _data 82 | } 83 | } 84 | set { 85 | Self.queue.sync { 86 | _data = newValue 87 | } 88 | } 89 | } 90 | } 91 | 92 | class MockSubprocessDependencyBuilder { 93 | class MockItem { 94 | var used = false 95 | var command: [String] 96 | var input: Input? 97 | var process: MockProcessReference 98 | var file: StaticString? 99 | var line: UInt? 100 | 101 | init(command: [String], input: Input?, process: MockProcessReference, file: StaticString?, line: UInt?) { 102 | self.command = command 103 | self.input = input 104 | self.process = process 105 | self.file = file 106 | self.line = line 107 | } 108 | } 109 | 110 | var mocks: [MockItem] = [] 111 | 112 | nonisolated(unsafe) static let shared = MockSubprocessDependencyBuilder() 113 | 114 | init() { SubprocessDependencyBuilder.shared = self } 115 | 116 | func stub(_ command: [String], process: MockProcessReference) { 117 | let mock = MockItem(command: command, input: nil, process: process, file: nil, line: nil) 118 | mocks.append(mock) 119 | } 120 | 121 | func expect(_ command: [String], input: Input?, process: MockProcessReference, file: StaticString, line: UInt) { 122 | let mock = MockItem(command: command, input: input, process: process, file: file, line: line) 123 | mocks.append(mock) 124 | } 125 | 126 | func verify(missedBlock block: (ExpectationError) -> Void) { 127 | defer { reset() } 128 | // All of the mocks for errors 129 | mocks.forEach { 130 | // Check if the file and line properties were set, this indicates it was an expected mock 131 | guard let file = $0.file, let line = $0.line else { return } 132 | 133 | // Check if the mock was used 134 | guard $0.used else { return block(ExpectationError(file: file, line: line, message: "Command not called")) } 135 | 136 | // Check the expected input 137 | let expectedData: Data? 138 | let expectedFile: URL? 139 | 140 | switch $0.input?.value { 141 | case .data(let data): 142 | expectedData = data 143 | expectedFile = nil 144 | case .text(let string): 145 | expectedData = Data(string.utf8) 146 | expectedFile = nil 147 | case .file(let url): 148 | expectedData = nil 149 | expectedFile = url 150 | default: 151 | expectedData = nil 152 | expectedFile = nil 153 | } 154 | 155 | let inputFile: URL? = ($0.process.standardInput as? MockFileHandle)?.url 156 | let inputData: Data? = ($0.process.standardInput as? MockPipe)?.data 157 | 158 | let fileError = verifyInputFile(inputURL: inputFile, expectedURL: expectedFile, file: file, line: line) 159 | if let error = fileError { return block(error) } 160 | 161 | let dataError = verifyInputData(inputData: inputData, expectedData: expectedData, file: file, line: line) 162 | guard let error = dataError else { return } 163 | block(error) 164 | } 165 | } 166 | 167 | private func verifyInputFile(inputURL: URL?, 168 | expectedURL: URL?, 169 | file: StaticString, 170 | line: UInt) -> ExpectationError? { 171 | if let expected = expectedURL { 172 | if let url = inputURL { 173 | if expected != url { 174 | return ExpectationError(file: file, 175 | line: line, 176 | message: "Input file URLs do not match \(expected) != \(url)") 177 | } 178 | } else { 179 | return ExpectationError(file: file, line: line, message: "Missing file input") 180 | } 181 | } else if let url = inputURL { 182 | return ExpectationError(file: file, line: line, message: "Unexpected input file \(url)") 183 | } 184 | return nil 185 | } 186 | 187 | private func verifyInputData(inputData: Data?, 188 | expectedData: Data?, 189 | file: StaticString, 190 | line: UInt) -> ExpectationError? { 191 | if let expectedData = expectedData { 192 | if let inputData = inputData { 193 | if inputData != expectedData { 194 | if let input = String(data: inputData, encoding: .utf8), 195 | let expected = String(data: inputData, encoding: .utf8) { 196 | let errorText = "Input text does not match expected input text \(input) != \(expected)" 197 | return ExpectationError(file: file, 198 | line: line, 199 | message: errorText) 200 | } else { 201 | return ExpectationError(file: file, 202 | line: line, 203 | message: "Input data does not match expected input data") 204 | } 205 | } 206 | } else { 207 | return ExpectationError(file: file, line: line, message: "Missing data input") 208 | } 209 | } else if let unexpectedData = inputData { 210 | if let input = String(data: unexpectedData, encoding: .utf8) { 211 | return ExpectationError(file: file, line: line, message: "Unexpected input text: \(input)") 212 | } else { 213 | return ExpectationError(file: file, line: line, message: "Unexpected input data") 214 | } 215 | } 216 | return nil 217 | } 218 | 219 | func verify() throws { 220 | var errors: [ExpectationError] = [] 221 | verify { errors.append($0) } 222 | if errors.isEmpty { return } 223 | throw MockSubprocessError.missedExpectations(errors) 224 | } 225 | 226 | func reset() { 227 | mocks = [] 228 | } 229 | } 230 | 231 | extension MockSubprocessDependencyBuilder: SubprocessDependencyFactory { 232 | func makeProcess(command: [String]) -> Process { 233 | if let item = mocks.first(where: { !$0.used && $0.command == command }) { 234 | item.used = true 235 | return item.process 236 | } 237 | return MockProcessReference(withRunError: MockSubprocessError.missingMock(command)) 238 | } 239 | 240 | func makeInputFileHandle(url: URL) throws -> FileHandle { 241 | let handle = MockFileHandle() 242 | handle.url = url 243 | return handle 244 | } 245 | 246 | func makeInputPipe(sequence: Input) throws -> Pipe where Input : AsyncSequence & Sendable, Input.Element == UInt8 { 247 | let semaphore = DispatchSemaphore(value: 0) 248 | let pipe = MockPipe() 249 | 250 | Task { 251 | pipe.data = try await sequence.data() 252 | semaphore.signal() 253 | } 254 | 255 | semaphore.wait() 256 | return pipe 257 | } 258 | } 259 | -------------------------------------------------------------------------------- /Sources/SubprocessMocks/SubprocessMocks.h: -------------------------------------------------------------------------------- 1 | // 2 | // SubprocessMocks.h 3 | // SubprocessMocks 4 | // 5 | // MIT License 6 | // 7 | // Copyright (c) 2023 Jamf 8 | // 9 | // Permission is hereby granted, free of charge, to any person obtaining a copy 10 | // of this software and associated documentation files (the "Software"), to deal 11 | // in the Software without restriction, including without limitation the rights 12 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | // copies of the Software, and to permit persons to whom the Software is 14 | // furnished to do so, subject to the following conditions: 15 | // 16 | // The above copyright notice and this permission notice shall be included in all 17 | // copies or substantial portions of the Software. 18 | // 19 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | // SOFTWARE. 26 | // 27 | 28 | #import 29 | 30 | //! Project version number for SubprocessMocks. 31 | FOUNDATION_EXPORT double SubprocessMocksVersionNumber; 32 | 33 | //! Project version string for SubprocessMocks. 34 | FOUNDATION_EXPORT const unsigned char SubprocessMocksVersionString[]; 35 | -------------------------------------------------------------------------------- /Subprocess.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'Subprocess' 3 | s.version = '3.0.1' 4 | s.summary = 'Wrapper for NSTask used for running processes and shell commands on macOS.' 5 | s.license = { :type => 'MIT', :text => "" } 6 | s.description = <<-DESC 7 | Everything related to creating processes and running shell commands on macOS. 8 | DESC 9 | s.homepage = 'https://github.com/jamf/Subprocess' 10 | s.authors = { 'Michael Link' => 'michael.link@jamf.com' } 11 | s.source = { :git => "https://github.com/jamf/Subprocess.git", :tag => s.version.to_s } 12 | s.platform = :osx, '10.15.4' 13 | s.osx.deployment_target = '10.15.4' 14 | s.swift_version = '5.9' 15 | s.default_subspec = 'Core' 16 | 17 | s.pod_target_xcconfig = { 'OTHER_SWIFT_FLAGS' => '-DCOCOA_PODS' } 18 | 19 | s.subspec 'Core' do |ss| 20 | ss.source_files = 'Sources/Subprocess/*.swift' 21 | end 22 | 23 | s.subspec 'Mocks' do |ss| 24 | ss.source_files = 'Sources/SubprocessMocks/*.swift' 25 | ss.dependency 'Subprocess/Core' 26 | end 27 | 28 | s.test_spec 'UnitTests' do |test_spec| 29 | test_spec.source_files = [ 'Tests/UnitTests/*.swift' ] 30 | test_spec.dependency 'Subprocess/Core' 31 | test_spec.dependency 'Subprocess/Mocks' 32 | end 33 | 34 | s.test_spec 'SystemTests' do |test_spec| 35 | test_spec.source_files = [ 'Tests/SystemTests/*.swift' ] 36 | test_spec.dependency 'Subprocess/Core' 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /Subprocess.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Subprocess.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Subprocess.xcodeproj/xcshareddata/xcschemes/Subprocess.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 32 | 33 | 39 | 40 | 41 | 42 | 44 | 50 | 51 | 52 | 54 | 60 | 61 | 62 | 63 | 64 | 74 | 75 | 81 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /Subprocess.xcodeproj/xcshareddata/xcschemes/SubprocessMocks.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Tests/SystemTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests/SystemTests/ShellSystemTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Subprocess 3 | 4 | @available(*, deprecated, message: "Swift Concurrency methods in Subprocess replace Shell") 5 | final class ShellSystemTests: XCTestCase { 6 | 7 | override func setUp() { 8 | SubprocessDependencyBuilder.shared = SubprocessDependencyBuilder() 9 | } 10 | 11 | let softwareVersionFilePath = "/System/Library/CoreServices/SystemVersion.plist" 12 | 13 | // MARK: Input values 14 | 15 | func testExecWithDataInput() { 16 | // Given 17 | let inputText = "This is a text\nabc123\nHello World" 18 | var result: String? 19 | 20 | // When 21 | XCTAssertNoThrow(result = try Shell(["/usr/bin/grep", "abc123"]).exec(input: .text(inputText), encoding: .utf8)) 22 | 23 | // Then 24 | XCTAssertFalse(result?.isEmpty ?? true) 25 | } 26 | 27 | func testExecWithFileInput() { 28 | // Given 29 | let cmd = ["/usr/bin/grep", "ProductVersion"] 30 | var result: String? 31 | 32 | // When 33 | XCTAssertNoThrow(result = try Shell(cmd).exec(input: .file(path: softwareVersionFilePath), encoding: .utf8)) 34 | 35 | // Then 36 | XCTAssertFalse(result?.isEmpty ?? true) 37 | } 38 | 39 | // MARK: String transform 40 | 41 | func testExecReturningBoolFromString() { 42 | // Given 43 | var result = false 44 | let username = NSUserName() 45 | 46 | // When 47 | XCTAssertNoThrow(result = try Shell(["/usr/bin/dscl", ".", "list", "/Users"]).exec(encoding: .utf8, 48 | transformBlock: { _, txt in 49 | return txt.contains(username) 50 | })) 51 | 52 | // Then 53 | XCTAssertTrue(result) 54 | } 55 | 56 | // MARK: String 57 | 58 | func testExecReturningString() { 59 | // Given 60 | var result: String? 61 | let username = NSUserName() 62 | 63 | // When 64 | XCTAssertNoThrow(result = try Shell(["/usr/bin/dscl", ".", "list", "/Users"]).exec(encoding: .utf8)) 65 | 66 | // Then 67 | XCTAssertTrue(result?.contains(username) ?? false) 68 | } 69 | 70 | // MARK: JSON object 71 | 72 | func testExecReturningJSONObject() { 73 | // Given 74 | var result: [[String: Any]]? 75 | 76 | // When 77 | XCTAssertNoThrow(result = try Shell(["/usr/bin/log", "show", "--style", "json", "--last", "1m"]).execJSON()) 78 | 79 | // Then 80 | XCTAssertFalse(result?.isEmpty ?? true) 81 | } 82 | 83 | // MARK: Property list object 84 | 85 | func testExecReturningPropertyList() { 86 | // Given 87 | let fullVersionString = ProcessInfo.processInfo.operatingSystemVersionString 88 | var result: [String: Any]? 89 | 90 | // When 91 | XCTAssertNoThrow(result = try Shell(["/bin/cat", softwareVersionFilePath]).execPropertyList()) 92 | 93 | // Then 94 | guard let versionNumber = result?["ProductVersion"] as? String else { return XCTFail("Unable to find version") } 95 | XCTAssertTrue(fullVersionString.contains(versionNumber)) 96 | } 97 | 98 | // MARK: Decodable object from JSON 99 | 100 | // swiftlint:disable nesting 101 | 102 | func testExecReturningDecodableObjectFromJSON() { 103 | // Given 104 | struct LogMessage: Codable { 105 | var subsystem: String 106 | var category: String 107 | var machTimestamp: UInt64 108 | } 109 | var result: [LogMessage]? 110 | let cmd = ["/usr/bin/log", "show", "--style", "json", "--last", "1m"] 111 | // When 112 | XCTAssertNoThrow(result = try Shell(cmd).exec(decoder: JSONDecoder())) 113 | 114 | // Then 115 | XCTAssertFalse(result?.isEmpty ?? true) 116 | } 117 | 118 | // MARK: Decodable object from property list 119 | 120 | func testExecReturningDecodableObjectFromPropertyList() { 121 | struct SystemVersion: Codable { 122 | enum CodingKeys: String, CodingKey { 123 | case version = "ProductVersion" 124 | } 125 | var version: String 126 | } 127 | 128 | // Given 129 | let fullVersionString = ProcessInfo.processInfo.operatingSystemVersionString 130 | var result: SystemVersion? 131 | 132 | // When 133 | XCTAssertNoThrow(result = try Shell(["/bin/cat", softwareVersionFilePath]).exec(decoder: PropertyListDecoder())) 134 | 135 | // Then 136 | guard let versionNumber = result?.version else { return XCTFail("Result is nil") } 137 | XCTAssertTrue(fullVersionString.contains(versionNumber)) 138 | } 139 | // swiftlint:enable nesting 140 | } 141 | -------------------------------------------------------------------------------- /Tests/SystemTests/SubprocessSystemTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Subprocess 3 | 4 | final class SubprocessSystemTests: XCTestCase { 5 | let softwareVersionFilePath = "/System/Library/CoreServices/SystemVersion.plist" 6 | 7 | override func setUp() { 8 | SubprocessDependencyBuilder.shared = SubprocessDependencyBuilder() 9 | } 10 | 11 | @available(macOS 12.0, *) 12 | func testRunWithOutput() async throws { 13 | let result = try await Subprocess(["/bin/cat", softwareVersionFilePath]).run().standardOutput.lines.first(where: { $0.contains("ProductName") }) != nil 14 | 15 | XCTAssertTrue(result) 16 | } 17 | 18 | @available(macOS 12.0, *) 19 | func testRunWithStandardOutput() async throws { 20 | let result = try await Subprocess(["/bin/cat", softwareVersionFilePath]).run(options: .standardOutput).standardOutput.lines.first(where: { $0.contains("ProductName") }) != nil 21 | 22 | XCTAssertTrue(result) 23 | } 24 | 25 | @available(macOS 12.0, *) 26 | func testRunWithStandardError() async throws { 27 | let result = try await Subprocess(["/bin/cat", "/non/existent/path/file.txt"]).run(options: .standardError).standardError.lines.first(where: { $0.contains("No such file or directory") }) != nil 28 | 29 | XCTAssertTrue(result) 30 | } 31 | 32 | func testRunWithCombinedOutput() async throws { 33 | let process = Subprocess(["/bin/cat", softwareVersionFilePath]) 34 | let (standardOutput, standardError, waitForExit) = try process.run() 35 | async let (stdout, stderr) = (standardOutput, standardError) 36 | let combinedOutput = await [stdout.string(), stderr.string()] 37 | 38 | await waitForExit() 39 | XCTAssertTrue(combinedOutput[0].contains("ProductName")) 40 | } 41 | 42 | @available(macOS 12.0, *) 43 | func testInteractiveRun() async throws { 44 | var input: AsyncStream.Continuation! 45 | let stream: AsyncStream = AsyncStream { continuation in 46 | input = continuation 47 | } 48 | let subprocess = Subprocess(["/bin/cat"]) 49 | let (standardOutput, _, _) = try subprocess.run(standardInput: stream) 50 | 51 | input.yield("hello\n") 52 | 53 | for await line in standardOutput.lines { 54 | XCTAssertEqual("hello", line) 55 | break 56 | } 57 | 58 | input.yield("world\n") 59 | input.finish() 60 | 61 | for await line in standardOutput.lines { 62 | XCTAssertEqual("world", line) 63 | break 64 | } 65 | } 66 | 67 | @available(macOS 12.0, *) 68 | func testInteractiveAsyncRun() throws { 69 | let exp = expectation(description: "\(#file):\(#line)") 70 | let (stream, input) = { 71 | var input: AsyncStream.Continuation! 72 | let stream: AsyncStream = AsyncStream { continuation in 73 | input = continuation 74 | } 75 | 76 | return (stream, input!) 77 | }() 78 | 79 | let subprocess = Subprocess(["/bin/cat"]) 80 | let (standardOutput, _, _) = try subprocess.run(standardInput: stream) 81 | 82 | input.yield("hello\n") 83 | 84 | Task { 85 | for await line in standardOutput.lines { 86 | switch line { 87 | case "hello": 88 | Task { 89 | _ = input.yield("world\n") 90 | } 91 | case "world": 92 | input.yield("and\nuniverse") 93 | input.finish() 94 | case "universe": 95 | break 96 | default: 97 | continue 98 | } 99 | } 100 | 101 | exp.fulfill() 102 | } 103 | 104 | wait(for: [exp]) 105 | } 106 | 107 | func testData() async throws { 108 | let data = try await Subprocess.data(for: ["/bin/cat", softwareVersionFilePath]) 109 | 110 | XCTAssert(!data.isEmpty) 111 | } 112 | 113 | func testDataWithInput() async throws { 114 | let data = try await Subprocess.data(for: ["/bin/cat"], standardInput: Data("hello".utf8)) 115 | 116 | XCTAssertEqual(String(decoding: data, as: UTF8.self), "hello") 117 | } 118 | 119 | @available(macOS 13.0, *) 120 | func testDataCancel() async throws { 121 | let exp = expectation(description: "\(#file):\(#line)") 122 | let task = Task { 123 | do { 124 | _ = try await Subprocess.data(for: ["/bin/cat"], standardInput: URL(filePath: "/dev/random")) 125 | 126 | XCTFail("expected task to be canceled") 127 | } catch { 128 | exp.fulfill() 129 | } 130 | } 131 | 132 | try await Task.sleep(nanoseconds: 1_000_000_000) 133 | task.cancel() 134 | await fulfillment(of: [exp]) 135 | } 136 | 137 | func testDataCancelWithoutInput() async throws { 138 | let exp = expectation(description: "\(#file):\(#line)") 139 | let task = Task { 140 | do { 141 | _ = try await Subprocess.data(for: ["/bin/cat", "/dev/random"]) 142 | 143 | XCTFail("expected task to be canceled") 144 | } catch { 145 | exp.fulfill() 146 | } 147 | } 148 | 149 | try await Task.sleep(nanoseconds: 1_000_000_000) 150 | task.cancel() 151 | await fulfillment(of: [exp]) 152 | } 153 | 154 | func testString() async throws { 155 | let username = NSUserName() 156 | let result = try await Subprocess.string(for: ["/usr/bin/dscl", ".", "list", "/Users"]) 157 | 158 | XCTAssertTrue(result.contains(username)) 159 | } 160 | 161 | func testStringWithStringInput() async throws { 162 | let result = try await Subprocess.string(for: ["/bin/cat"], standardInput: "hello") 163 | 164 | XCTAssertEqual("hello", result) 165 | } 166 | 167 | @available(macOS 13.0, *) 168 | func testStringWithFileInput() async throws { 169 | let result = try await Subprocess.string(for: ["/bin/cat"], standardInput: URL(filePath: softwareVersionFilePath)) 170 | 171 | XCTAssertEqual(try String(contentsOf: URL(filePath: softwareVersionFilePath)), result) 172 | } 173 | 174 | func testReturningJSON() async throws { 175 | struct LogMessage: Codable { 176 | var subsystem: String 177 | var category: String 178 | var machTimestamp: UInt64 179 | } 180 | 181 | let result: [LogMessage] = try await Subprocess.value(for: ["/usr/bin/log", "show", "--style", "json", "--last", "30s"], decoder: JSONDecoder()) 182 | 183 | XCTAssertTrue(!result.isEmpty) 184 | } 185 | 186 | func testReturningPropertyList() async throws { 187 | struct SystemVersion: Codable { 188 | enum CodingKeys: String, CodingKey { 189 | case version = "ProductVersion" 190 | } 191 | var version: String 192 | } 193 | 194 | let fullVersionString = ProcessInfo.processInfo.operatingSystemVersionString 195 | let result: SystemVersion = try await Subprocess.value(for: ["/bin/cat", softwareVersionFilePath], decoder: PropertyListDecoder()) 196 | let versionNumber = result.version 197 | 198 | XCTAssertTrue(fullVersionString.contains(versionNumber)) 199 | } 200 | 201 | func testNonZeroExit() async { 202 | do { 203 | _ = try await Subprocess.string(for: ["/bin/cat", "/non/existent/path/file.txt"]) 204 | XCTFail("expected failure") 205 | } catch Subprocess.Error.nonZeroExit { 206 | // expected 207 | } catch { 208 | XCTFail("unexpected error: \(error)") 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /Tests/UnitTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests/UnitTests/ShellTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Subprocess 3 | #if !COCOA_PODS 4 | @testable import SubprocessMocks 5 | #endif 6 | 7 | struct TestCodableObject: Codable, Equatable { 8 | let uuid: UUID 9 | init() { uuid = UUID() } 10 | } 11 | 12 | // swiftlint:disable control_statement duplicated_key_in_dictionary_literal 13 | @available(*, deprecated, message: "Swift Concurrency methods in Subprocess replace Shell") 14 | final class ShellTests: XCTestCase { 15 | 16 | let command = [ "/usr/local/bin/somefakeCommand", "foo", "bar" ] 17 | 18 | override func setUp() { 19 | // This is only needed for SwiftPM since it runs all of the test suites as a single test run 20 | SubprocessDependencyBuilder.shared = MockSubprocessDependencyBuilder.shared 21 | } 22 | 23 | override func tearDown() { 24 | Shell.reset() 25 | } 26 | 27 | // MARK: Data 28 | 29 | func testExecReturningDataWhenExitCodeIsNoneZero() { 30 | // Given 31 | let exitCode = Int32.random(in: 1...Int32.max) 32 | let stdoutData = "stdout example".data(using: .utf8) 33 | let stderrData = "stderr example".data(using: .utf8) 34 | Shell.expect(command, input: nil, standardOutput: stdoutData, standardError: stderrData, exitCode: exitCode) 35 | 36 | // When 37 | XCTAssertThrowsError(_ = try Shell(command).exec()) { error in 38 | switch (error as? SubprocessError) { 39 | case .exitedWithNonZeroStatus(let status, let errorMessage): 40 | XCTAssertEqual(status, exitCode) 41 | let failMsg = "error message should have contained the results from only stdout but was \(errorMessage)" 42 | XCTAssertTrue(errorMessage.contains("stdout example"), failMsg) 43 | XCTAssertEqual(errorMessage, error.localizedDescription, "should have also set localizedDescription") 44 | let nsError = error as NSError 45 | XCTAssertEqual(Int(status), nsError.code, "should have also set the NSError exit code") 46 | default: XCTFail("Unexpected error type: \(error)") 47 | } 48 | } 49 | 50 | // Then 51 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 52 | } 53 | 54 | func testExecReturningDataFromStandardOutput() { 55 | // Given 56 | var result: Data? 57 | let expected = Data([ UInt8.random(in: 0...UInt8.max), 58 | UInt8.random(in: 0...UInt8.max), 59 | UInt8.random(in: 0...UInt8.max) ]) 60 | let errorData = Data([ UInt8.random(in: 0...UInt8.max) ]) 61 | Shell.expect(command, input: nil, standardOutput: expected, standardError: errorData) 62 | 63 | // When 64 | XCTAssertNoThrow(result = try Shell(command).exec()) 65 | 66 | // Then 67 | XCTAssertEqual(expected, result) 68 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 69 | } 70 | 71 | func testExecReturningDataFromStandardError() { 72 | // Given 73 | var result: Data? 74 | let expected = Data([ UInt8.random(in: 0...UInt8.max), 75 | UInt8.random(in: 0...UInt8.max), 76 | UInt8.random(in: 0...UInt8.max) ]) 77 | let stdOutData = Data([ UInt8.random(in: 0...UInt8.max) ]) 78 | Shell.expect(command, input: nil, standardOutput: stdOutData, standardError: expected) 79 | 80 | // When 81 | XCTAssertNoThrow(result = try Shell(command).exec(options: .stderr)) 82 | 83 | // Then 84 | XCTAssertEqual(expected, result) 85 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 86 | } 87 | 88 | func testExecReturningDataFromBothOutputs() { 89 | // Given 90 | var result = Data() 91 | let expectedStdout = UUID().uuidString 92 | let expectedStderr = UUID().uuidString 93 | Shell.expect(command, 94 | input: nil, 95 | standardOutput: expectedStdout.data(using: .utf8) ?? Data(), 96 | standardError: expectedStderr.data(using: .utf8) ?? Data()) 97 | 98 | // When 99 | XCTAssertNoThrow(result = try Shell(command).exec(options: .combined)) 100 | 101 | // Then 102 | let text = String(data: result, encoding: .utf8) 103 | XCTAssertEqual("\(expectedStdout)\(expectedStderr)", text, "should have combined stdout and stderror") 104 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 105 | } 106 | 107 | // MARK: String 108 | 109 | func testExecReturningStringWhenExitCodeIsNoneZero() { 110 | // Given 111 | let exitCode = Int32.random(in: 1...Int32.max) 112 | let stdoutText = "should not show up" 113 | let stderrText = "should show up" 114 | Shell.expect(command, input: nil, stdout: stdoutText, stderr: stderrText, exitCode: exitCode) 115 | // When 116 | XCTAssertThrowsError(_ = try Shell(command).exec(options: .stderr, encoding: .utf8)) { error in 117 | switch (error as? SubprocessError) { 118 | case .exitedWithNonZeroStatus(let status, let errorMessage): 119 | XCTAssertEqual(status, exitCode) 120 | let failMsg = "should have put just stderr in the error: \(errorMessage)" 121 | XCTAssertEqual("should show up", errorMessage, failMsg) 122 | XCTAssertEqual(errorMessage, error.localizedDescription, "also should have set localizedDescription") 123 | default: XCTFail("Unexpected error type: \(error)") 124 | } 125 | } 126 | 127 | // Then 128 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 129 | } 130 | 131 | func testExecReturningStringWhenOutputEncodingErrorOccurs() { 132 | // Given 133 | let invalidData = Data([ 0xFF, 0xFF, 0xFF, 0xFF ]) 134 | Shell.expect(command, input: nil, standardOutput: invalidData) 135 | 136 | // When 137 | XCTAssertThrowsError(_ = try Shell(command).exec(encoding: .utf8)) { 138 | switch ($0 as? SubprocessError) { 139 | case .outputStringEncodingError: break 140 | default: XCTFail("Unexpected error type: \($0)") 141 | } 142 | } 143 | 144 | // Then 145 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 146 | } 147 | 148 | func testExecReturningStringFromStandardOutput() { 149 | // Given 150 | var result: String? 151 | let expected = UUID().uuidString 152 | Shell.expect(command, input: nil, stdout: expected, stderr: UUID().uuidString) 153 | 154 | // When 155 | XCTAssertNoThrow(result = try Shell(command).exec(encoding: .utf8)) 156 | 157 | // Then 158 | XCTAssertEqual(expected, result) 159 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 160 | } 161 | 162 | func testExecReturningStringFromStandardError() { 163 | // Given 164 | var result: String? 165 | let expected = UUID().uuidString 166 | Shell.expect(command, input: nil, stdout: UUID().uuidString, stderr: expected) 167 | 168 | // When 169 | XCTAssertNoThrow(result = try Shell(command).exec(options: .stderr, encoding: .utf8)) 170 | 171 | // Then 172 | XCTAssertEqual(expected, result) 173 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 174 | } 175 | 176 | func testExecReturningStringFromBothOutputs() { 177 | // Given 178 | var result: String? 179 | let expectedStdout = UUID().uuidString 180 | let expectedStderr = UUID().uuidString 181 | Shell.expect(command, input: nil, stdout: expectedStdout, stderr: expectedStderr) 182 | 183 | // When 184 | XCTAssertNoThrow(result = try Shell(command).exec(options: .combined, encoding: .utf8)) 185 | 186 | // Then 187 | XCTAssertTrue(result?.contains(expectedStdout) ?? false) 188 | XCTAssertTrue(result?.contains(expectedStderr) ?? false) 189 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 190 | } 191 | 192 | // MARK: JSON object 193 | 194 | func testExecReturningJSONArray() { 195 | // Given 196 | var result: [String]? 197 | let expected: [String] = [ 198 | UUID().uuidString, 199 | UUID().uuidString 200 | ] 201 | XCTAssertNoThrow(try Shell.expect(command, input: nil, json: expected)) 202 | 203 | // When 204 | XCTAssertNoThrow(result = try Shell(command).execJSON()) 205 | 206 | // Then 207 | XCTAssertEqual(expected, result) 208 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 209 | } 210 | 211 | func testExecReturningJSONDictionary() { 212 | // Given 213 | var result: [String: [String: String]]? 214 | let expected: [String: [String: String]] = [ 215 | UUID().uuidString: [ 216 | UUID().uuidString: UUID().uuidString 217 | ], 218 | UUID().uuidString: [ 219 | UUID().uuidString: UUID().uuidString 220 | ] 221 | ] 222 | XCTAssertNoThrow(try Shell.expect(command, input: nil, json: expected)) 223 | 224 | // When 225 | XCTAssertNoThrow(result = try Shell(command).execJSON()) 226 | 227 | // Then 228 | XCTAssertEqual(expected, result) 229 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 230 | } 231 | 232 | func testExecReturningJSONWithInvalidCast() { 233 | // Given 234 | var result: [String]? 235 | let expected: [String: [String: String]] = [ 236 | UUID().uuidString: [ 237 | UUID().uuidString: UUID().uuidString 238 | ], 239 | UUID().uuidString: [ 240 | UUID().uuidString: UUID().uuidString 241 | ] 242 | ] 243 | XCTAssertNoThrow(try Shell.expect(command, input: nil, json: expected)) 244 | 245 | // When 246 | XCTAssertThrowsError(result = try Shell(command).execJSON()) { 247 | switch ($0 as? SubprocessError) { 248 | case .unexpectedJSONObject(let type): 249 | XCTAssertEqual(type, "__NSDictionaryI") 250 | default: XCTFail("Unexpected error type: \($0)") 251 | } 252 | } 253 | 254 | // Then 255 | XCTAssertNil(result) 256 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 257 | } 258 | 259 | // MARK: Property list object 260 | 261 | func testExecReturningPropertyListArray() { 262 | // Given 263 | var result: [String]? 264 | let expected: [String] = [ 265 | UUID().uuidString, 266 | UUID().uuidString 267 | ] 268 | XCTAssertNoThrow(try Shell.expect(command, input: nil, plist: expected)) 269 | 270 | // When 271 | XCTAssertNoThrow(result = try Shell(command).execPropertyList()) 272 | 273 | // Then 274 | XCTAssertEqual(expected, result) 275 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 276 | } 277 | 278 | func testExecReturningPropertyListDictionary() { 279 | // Given 280 | var result: [String: [String: String]]? 281 | let expected: [String: [String: String]] = [ 282 | UUID().uuidString: [ 283 | UUID().uuidString: UUID().uuidString 284 | ], 285 | UUID().uuidString: [ 286 | UUID().uuidString: UUID().uuidString 287 | ] 288 | ] 289 | XCTAssertNoThrow(try Shell.expect(command, input: nil, plist: expected)) 290 | 291 | // When 292 | XCTAssertNoThrow(result = try Shell(command).execPropertyList()) 293 | 294 | // Then 295 | XCTAssertEqual(expected, result) 296 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 297 | } 298 | 299 | func testExecReturningPropertyListWithInvalidCast() { 300 | // Given 301 | var result: [String]? 302 | let expected: [String: [String: String]] = [ 303 | UUID().uuidString: [ 304 | UUID().uuidString: UUID().uuidString 305 | ], 306 | UUID().uuidString: [ 307 | UUID().uuidString: UUID().uuidString 308 | ] 309 | ] 310 | XCTAssertNoThrow(try Shell.expect(command, input: nil, plist: expected)) 311 | 312 | // When 313 | XCTAssertThrowsError(result = try Shell(command).execPropertyList()) { 314 | switch ($0 as? SubprocessError) { 315 | case .unexpectedPropertyListObject(let type): 316 | XCTAssertEqual(type, "__NSDictionaryM") 317 | default: XCTFail("Unexpected error type: \($0)") 318 | } 319 | } 320 | 321 | // Then 322 | XCTAssertNil(result) 323 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 324 | } 325 | 326 | // MARK: Decodable object from JSON 327 | 328 | func testExecReturningDecodableObjectFromJSON() { 329 | // Given 330 | var result: TestCodableObject? 331 | let expectObject: TestCodableObject = TestCodableObject() 332 | XCTAssertNoThrow(try Shell.expect(command, input: nil, jsonObject: expectObject)) 333 | 334 | // When 335 | XCTAssertNoThrow(result = try Shell(command).exec(decoder: JSONDecoder())) 336 | 337 | // Then 338 | XCTAssertEqual(expectObject, result) 339 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 340 | } 341 | 342 | // MARK: Decodable object from property list 343 | 344 | func testExecReturningDecodableObjectFromPropertyList() { 345 | // Given 346 | var result: TestCodableObject? 347 | let expectObject: TestCodableObject = TestCodableObject() 348 | XCTAssertNoThrow(try Shell.expect(command, input: nil, plistObject: expectObject)) 349 | 350 | // When 351 | XCTAssertNoThrow(result = try Shell(command).exec(decoder: PropertyListDecoder())) 352 | 353 | // Then 354 | XCTAssertEqual(expectObject, result) 355 | Shell.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 356 | } 357 | } 358 | // swiftlint:enable control_statement duplicated_key_in_dictionary_literal 359 | -------------------------------------------------------------------------------- /Tests/UnitTests/SubprocessTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Subprocess 3 | #if !COCOA_PODS 4 | @testable import SubprocessMocks 5 | #endif 6 | 7 | // swiftlint:disable duplicated_key_in_dictionary_literal 8 | final class SubprocessTests: XCTestCase { 9 | 10 | let command = [ "/usr/local/bin/somefakeCommand", "foo", "bar" ] 11 | 12 | override func setUp() { 13 | // This is only needed for SwiftPM since it runs all of the test suites as a single test run 14 | SubprocessDependencyBuilder.shared = MockSubprocessDependencyBuilder.shared 15 | } 16 | 17 | override func tearDown() { 18 | Subprocess.reset() 19 | } 20 | 21 | // MARK: Input 22 | 23 | func testInputData() { 24 | // Given 25 | var pipeOrFileHandle: Any? 26 | let expected = Data([ UInt8.random(in: 0...UInt8.max), 27 | UInt8.random(in: 0...UInt8.max), 28 | UInt8.random(in: 0...UInt8.max) ]) 29 | 30 | // When 31 | let input = Input.data(expected) 32 | XCTAssertNoThrow(pipeOrFileHandle = try input.createPipeOrFileHandle()) 33 | 34 | // Then 35 | switch input.value { 36 | case .data(let data): XCTAssertEqual(data, expected) 37 | default: XCTFail("Unexpected type") 38 | } 39 | guard let pipe = pipeOrFileHandle as? MockPipe else { return XCTFail("Unable to cast MockPipe") } 40 | XCTAssertEqual(pipe.data, expected) 41 | } 42 | 43 | func testInputText() { 44 | // Given 45 | var pipeOrFileHandle: Any? 46 | let expected = UUID().uuidString 47 | 48 | // When 49 | let input = Input.text(expected) 50 | XCTAssertNoThrow(pipeOrFileHandle = try input.createPipeOrFileHandle()) 51 | 52 | // Then 53 | switch input.value { 54 | case .text(let text): 55 | XCTAssertEqual(text, expected) 56 | default: XCTFail("Unexpected type") 57 | } 58 | guard let pipe = pipeOrFileHandle as? MockPipe else { return XCTFail("Unable to cast MockPipe") } 59 | guard let data = pipe.data, let text = String(data: data, encoding: .utf8) else { 60 | return XCTFail("Failed to convert pipe data to string") 61 | } 62 | XCTAssertEqual(text, expected) 63 | } 64 | 65 | func testInputFilePath() { 66 | // Given 67 | var pipeOrFileHandle: Any? 68 | let expected = "/some/fake/path/\(UUID().uuidString)" 69 | 70 | // When 71 | let input = Input.file(path: expected) 72 | XCTAssertNoThrow(pipeOrFileHandle = try input.createPipeOrFileHandle()) 73 | 74 | // Then 75 | switch input.value { 76 | case .file(let url): XCTAssertEqual(url.path, expected) 77 | default: XCTFail("Unexpected type") 78 | } 79 | guard let pipe = pipeOrFileHandle as? MockFileHandle else { return XCTFail("Unable to cast MockFileHandle") } 80 | XCTAssertEqual(pipe.url?.path, expected) 81 | } 82 | 83 | func testInputFileURL() { 84 | // Given 85 | var pipeOrFileHandle: Any? 86 | let expected = URL(fileURLWithPath: "/some/fake/path/\(UUID().uuidString)") 87 | 88 | // When 89 | let input = Input.file(url: expected) 90 | XCTAssertNoThrow(pipeOrFileHandle = try input.createPipeOrFileHandle()) 91 | 92 | // Then 93 | switch input.value { 94 | case .file(let url): XCTAssertEqual(url, expected) 95 | default: XCTFail("Unexpected type") 96 | } 97 | guard let pipe = pipeOrFileHandle as? MockFileHandle else { return XCTFail("Unable to cast MockFileHandle") } 98 | XCTAssertEqual(pipe.url, expected) 99 | } 100 | 101 | // MARK: PID 102 | 103 | func testGetPID() throws { 104 | // Given 105 | let mockCalled = expectation(description: "Mock setup called") 106 | nonisolated(unsafe) var expectedPID: Int32? 107 | Subprocess.expect(command) { mock in 108 | expectedPID = mock.reference.processIdentifier 109 | mockCalled.fulfill() 110 | } 111 | 112 | // When 113 | let subprocess = Subprocess(command) 114 | _ = try subprocess.run() 115 | 116 | // Then 117 | wait(for: [mockCalled], timeout: 5.0) 118 | XCTAssertEqual(subprocess.pid, expectedPID) 119 | } 120 | 121 | // MARK: launch with termination handler 122 | 123 | func testLaunchWithTerminationHandler() { 124 | // Given 125 | let terminationExpectation = expectation(description: "Termination block called") 126 | let expectedExitCode = Int32.random(in: Int32.min...Int32.max) 127 | let expectedStdout = Data([ UInt8.random(in: 0...UInt8.max), 128 | UInt8.random(in: 0...UInt8.max), 129 | UInt8.random(in: 0...UInt8.max) ]) 130 | let expectedStderr = Data([ UInt8.random(in: 0...UInt8.max), 131 | UInt8.random(in: 0...UInt8.max), 132 | UInt8.random(in: 0...UInt8.max) ]) 133 | Subprocess.expect(command) { mock in 134 | mock.writeTo(stdout: expectedStdout) 135 | mock.writeTo(stderr: expectedStderr) 136 | mock.exit(withStatus: expectedExitCode, reason: .uncaughtSignal) 137 | } 138 | 139 | // When 140 | let subprocess = Subprocess(command) 141 | XCTAssertNoThrow(try subprocess.launch(terminationHandler: { (process, standardOutput, standardError) in 142 | XCTAssertEqual(standardOutput, expectedStdout) 143 | XCTAssertEqual(standardError, expectedStderr) 144 | XCTAssertEqual(process.terminationReason, .uncaughtSignal) 145 | XCTAssertEqual(process.exitCode, expectedExitCode) 146 | terminationExpectation.fulfill() 147 | })) 148 | 149 | // Then 150 | wait(for: [terminationExpectation], timeout: 5.0) 151 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 152 | } 153 | 154 | func testRunhWithWaitUntilExit() async throws { 155 | // Given 156 | let expectedExitCode = Int32.random(in: Int32.min...Int32.max) 157 | let expectedStdout = Data([ UInt8.random(in: 0...UInt8.max), 158 | UInt8.random(in: 0...UInt8.max), 159 | UInt8.random(in: 0...UInt8.max) ]) 160 | let expectedStderr = Data([ UInt8.random(in: 0...UInt8.max), 161 | UInt8.random(in: 0...UInt8.max), 162 | UInt8.random(in: 0...UInt8.max) ]) 163 | Subprocess.expect(command) { mock in 164 | mock.writeTo(stdout: expectedStdout) 165 | mock.writeTo(stderr: expectedStderr) 166 | mock.exit(withStatus: expectedExitCode, reason: .uncaughtSignal) 167 | } 168 | 169 | // When 170 | let subprocess = Subprocess(command) 171 | let (standardOutput, standardError, waitUntilExit) = try subprocess.run() 172 | async let (stdout, stderr) = (standardOutput, standardError) 173 | let combinedOutput = await [stdout.data(), stderr.data()] 174 | 175 | await waitUntilExit() 176 | 177 | XCTAssertEqual(combinedOutput[0], expectedStdout) 178 | XCTAssertEqual(combinedOutput[1], expectedStderr) 179 | XCTAssertEqual(subprocess.terminationReason, .uncaughtSignal) 180 | XCTAssertEqual(subprocess.exitCode, expectedExitCode) 181 | 182 | // Then 183 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 184 | } 185 | 186 | // MARK: suspend 187 | 188 | @MainActor func testSuspend() throws { 189 | // Given 190 | let semaphore = DispatchSemaphore(value: 0) 191 | let suspendCalled = expectation(description: "Suspend called") 192 | Subprocess.expect(command) { mock in 193 | mock.reference.stubSuspend = { 194 | suspendCalled.fulfill() 195 | return true 196 | } 197 | semaphore.signal() 198 | } 199 | let subprocess = Subprocess(command) 200 | _ = try subprocess.run() 201 | semaphore.wait() 202 | 203 | // When 204 | XCTAssertTrue(subprocess.suspend()) 205 | 206 | // Then 207 | waitForExpectations(timeout: 5.0) 208 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 209 | } 210 | 211 | // MARK: resume 212 | 213 | @MainActor func testResume() throws { 214 | // Given 215 | let semaphore = DispatchSemaphore(value: 0) 216 | let resumeCalled = expectation(description: "Resume called") 217 | Subprocess.expect(command) { mock in 218 | mock.reference.stubResume = { 219 | resumeCalled.fulfill() 220 | return true 221 | } 222 | semaphore.signal() 223 | } 224 | let subprocess = Subprocess(command) 225 | _ = try subprocess.run() 226 | semaphore.wait() 227 | 228 | // When 229 | XCTAssertTrue(subprocess.resume()) 230 | 231 | // Then 232 | waitForExpectations(timeout: 5.0) 233 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 234 | } 235 | 236 | // MARK: kill 237 | 238 | @MainActor func testKill() throws { 239 | // Given 240 | let semaphore = DispatchSemaphore(value: 0) 241 | let terminateCalled = expectation(description: "Terminate called") 242 | Subprocess.expect(command) { mock in 243 | mock.reference.stubTerminate = { _ in 244 | terminateCalled.fulfill() 245 | } 246 | semaphore.signal() 247 | } 248 | let subprocess = Subprocess(command) 249 | _ = try subprocess.run() 250 | semaphore.wait() 251 | 252 | // When 253 | XCTAssertTrue(subprocess.isRunning) 254 | subprocess.kill() 255 | 256 | // Then 257 | waitForExpectations(timeout: 5.0) 258 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 259 | } 260 | 261 | func testEnvironmentProperty() { 262 | // Given 263 | let subprocess = Subprocess(["/bin/echo"]) 264 | let environmentVariableName = "FOO" 265 | let environmentVariableValue = "BAR" 266 | 267 | // When 268 | subprocess.environment = [environmentVariableName: environmentVariableValue] 269 | 270 | // Then 271 | XCTAssertEqual(subprocess.environment?[environmentVariableName], environmentVariableValue, 272 | "The environment property did not store the value correctly.") 273 | } 274 | 275 | // MARK: Data 276 | 277 | func testReturningDataWhenExitCodeIsNoneZero() async { 278 | // Given 279 | let exitCode = Int32.random(in: 1...Int32.max) 280 | let stdoutData = Data("stdout example".utf8) 281 | let stderrData = Data("stderr example".utf8) 282 | Subprocess.expect(command, standardOutput: stdoutData, standardError: stderrData, exitCode: exitCode) 283 | 284 | // When 285 | do { 286 | _ = try await Subprocess.data(for: command) 287 | } catch let Subprocess.Error.nonZeroExit(status: status, reason: _, standardOutput: stdout, standardError: stderr) { 288 | XCTAssertEqual(status, exitCode) 289 | XCTAssertTrue(stderr.contains("stderr example")) 290 | XCTAssertEqual(stdoutData, stdout) 291 | } catch { 292 | XCTFail("Unexpected error type: \(error)") 293 | } 294 | 295 | // Then 296 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 297 | } 298 | 299 | func testReturningDataFromStandardOutput() async throws { 300 | // Given 301 | let expected = Data([ UInt8.random(in: 0...UInt8.max), 302 | UInt8.random(in: 0...UInt8.max), 303 | UInt8.random(in: 0...UInt8.max) ]) 304 | let errorData = Data([ UInt8.random(in: 0...UInt8.max) ]) 305 | Subprocess.expect(command, standardOutput: expected, standardError: errorData) 306 | 307 | // When 308 | let result = try await Subprocess.data(for: command) 309 | 310 | // Then 311 | XCTAssertEqual(expected, result) 312 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 313 | } 314 | 315 | func testReturningDataFromStandardError() async throws { 316 | // Given 317 | let expected = Data([ UInt8.random(in: 0...UInt8.max), 318 | UInt8.random(in: 0...UInt8.max), 319 | UInt8.random(in: 0...UInt8.max) ]) 320 | let stdOutData = Data([ UInt8.random(in: 0...UInt8.max) ]) 321 | Subprocess.expect(command, standardOutput: stdOutData, standardError: expected) 322 | 323 | // When 324 | let result = try await Subprocess.data(for: command, options: .returnStandardError) 325 | 326 | // Then 327 | XCTAssertEqual(expected, result) 328 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 329 | } 330 | 331 | // MARK: String 332 | 333 | func testReturningStringWhenExitCodeIsNoneZero() async throws { 334 | // Given 335 | let exitCode = Int32.random(in: 1...Int32.max) 336 | let stdoutText = "should not show up" 337 | let stderrText = "should show up" 338 | Subprocess.expect(command, standardOutput: stdoutText, standardError: stderrText, exitCode: exitCode) 339 | 340 | // When 341 | do { 342 | _ = try await Subprocess.string(for: command) 343 | } catch let Subprocess.Error.nonZeroExit(status: status, reason: _, standardOutput: stdout, standardError: stderr) { 344 | XCTAssertEqual(status, exitCode) 345 | XCTAssertTrue(stderr.contains("should show up")) 346 | XCTAssertEqual(stdoutText, String(decoding: stdout, as: UTF8.self)) 347 | } catch { 348 | XCTFail("Unexpected error type: \(error)") 349 | } 350 | 351 | // Then 352 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 353 | } 354 | 355 | func testReturningStringFromStandardOutput() async throws { 356 | // Given 357 | let expected = UUID().uuidString 358 | Subprocess.expect(command, standardOutput: expected, standardError: UUID().uuidString) 359 | 360 | // When 361 | let result = try await Subprocess.string(for: command) 362 | 363 | // Then 364 | XCTAssertEqual(expected, result) 365 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 366 | } 367 | 368 | func testReturningStringFromStandardError() async throws { 369 | // Given 370 | let expected = UUID().uuidString 371 | Subprocess.expect(command, standardOutput: UUID().uuidString, standardError: expected) 372 | 373 | // When 374 | let result = try await Subprocess.string(for: command, options: .returnStandardError) 375 | 376 | // Then 377 | XCTAssertEqual(expected, result) 378 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 379 | } 380 | 381 | // MARK: JSON object 382 | 383 | func testReturningJSONArray() async throws { 384 | // Given 385 | let expected: [String] = [ 386 | UUID().uuidString, 387 | UUID().uuidString 388 | ] 389 | 390 | XCTAssertNoThrow(try Subprocess.expect(command, content: expected, encoder: JSONEncoder())) 391 | 392 | // When 393 | let result: [String] = try await Subprocess.value(for: command, decoder: JSONDecoder()) 394 | 395 | // Then 396 | XCTAssertEqual(expected, result) 397 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 398 | } 399 | 400 | func testExecReturningJSONDictionary() async throws { 401 | // Given 402 | let expected: [String: [String: String]] = [ 403 | UUID().uuidString: [ 404 | UUID().uuidString: UUID().uuidString 405 | ], 406 | UUID().uuidString: [ 407 | UUID().uuidString: UUID().uuidString 408 | ] 409 | ] 410 | XCTAssertNoThrow(try Subprocess.expect(command, content: expected, encoder: JSONEncoder())) 411 | 412 | // When 413 | let result: [String: [String: String]] = try await Subprocess.value(for: command, decoder: JSONDecoder()) 414 | 415 | // Then 416 | XCTAssertEqual(expected, result) 417 | Subprocess.verify { XCTFail($0.message, file: $0.file, line: $0.line) } 418 | } 419 | } 420 | // swiftlint:enable duplicated_key_in_dictionary_literal 421 | --------------------------------------------------------------------------------