From bbd9ecb70e05bb9da844d7ea1106f1fda494f700 Mon Sep 17 00:00:00 2001 From: broken-circle <252359939+broken-circle@users.noreply.github.com> Date: Mon, 18 May 2026 13:00:22 -0700 Subject: [PATCH] Make `Configuration` introspectable --- Sources/Subprocess/Configuration.swift | 147 ++++++++++++- .../Platforms/Subprocess+Unix.swift | 2 +- .../Platforms/Subprocess+Windows.swift | 8 +- .../ConfigurationIntrospectionTests.swift | 195 ++++++++++++++++++ 4 files changed, 337 insertions(+), 15 deletions(-) create mode 100644 Tests/SubprocessTests/ConfigurationIntrospectionTests.swift diff --git a/Sources/Subprocess/Configuration.swift b/Sources/Subprocess/Configuration.swift index fb6fbbf..ce18807 100644 --- a/Sources/Subprocess/Configuration.swift +++ b/Sources/Subprocess/Configuration.swift @@ -330,6 +330,32 @@ extension Executable: CustomStringConvertible, CustomDebugStringConvertible { } } +// MARK: - Executable Introspection + +extension Executable { + /// The public representation of an executable's contents. + /// + /// Use this to introspect how an ``Executable`` was constructed (for + /// example, to verify in tests that a configuration builder produced the + /// expected executable reference without spawning a subprocess). + public enum Representation: Sendable, Hashable { + /// The executable is referenced by name and resolved against `PATH`. + case name(String) + /// The executable is referenced by an absolute or relative file path. + case path(FilePath) + } + + /// The contents of this executable. + public var representation: Representation { + switch self.storage { + case .executable(let name): + return .name(name) + case .path(let path): + return .path(path) + } + } +} + // MARK: - Arguments /// A collection of arguments to pass to the subprocess. @@ -338,17 +364,17 @@ public struct Arguments: Sendable, ExpressibleByArrayLiteral, Hashable { public typealias ArrayLiteralElement = String internal let storage: [StringOrRawBytes] - internal let executablePathOverride: StringOrRawBytes? + internal let _executablePathOverride: StringOrRawBytes? /// Creates an arguments value from the given literal values. public init(arrayLiteral elements: String...) { self.storage = elements.map { .string($0) } - self.executablePathOverride = nil + self._executablePathOverride = nil } /// Creates an arguments value from the given array. public init(_ array: [String]) { self.storage = array.map { .string($0) } - self.executablePathOverride = nil + self._executablePathOverride = nil } /// Creates an ``Arguments`` value using the given values, but @@ -362,9 +388,9 @@ public struct Arguments: Sendable, ExpressibleByArrayLiteral, Hashable { public init(executablePathOverride: String?, remainingValues: [String]) { self.storage = remainingValues.map { .string($0) } if let executablePathOverride = executablePathOverride { - self.executablePathOverride = .string(executablePathOverride) + self._executablePathOverride = .string(executablePathOverride) } else { - self.executablePathOverride = nil + self._executablePathOverride = nil } } #if !os(Windows) // Windows does not support non-unicode arguments @@ -379,15 +405,15 @@ public struct Arguments: Sendable, ExpressibleByArrayLiteral, Hashable { public init(executablePathOverride: [UInt8]?, remainingValues: [[UInt8]]) { self.storage = remainingValues.map { .rawBytes($0) } if let override = executablePathOverride { - self.executablePathOverride = .rawBytes(override) + self._executablePathOverride = .rawBytes(override) } else { - self.executablePathOverride = nil + self._executablePathOverride = nil } } /// Creates an arguments value from the array you provide. public init(_ array: [[UInt8]]) { self.storage = array.map { .rawBytes($0) } - self.executablePathOverride = nil + self._executablePathOverride = nil } #endif } @@ -397,7 +423,7 @@ extension Arguments: CustomStringConvertible, CustomDebugStringConvertible { public var description: String { var result: [String] = self.storage.map(\.description) - if let override = self.executablePathOverride { + if let override = self._executablePathOverride { result.insert("override\(override.description)", at: 0) } return result.description @@ -407,6 +433,63 @@ extension Arguments: CustomStringConvertible, CustomDebugStringConvertible { public var debugDescription: String { return self.description } } +// MARK: - Arguments Introspection + +extension Arguments { + /// A single argument value, preserving the form in which it was supplied. + /// + /// On POSIX platforms, arguments may be constructed from non-Unicode + /// raw bytes; on Windows, only `String` values are representable. + public enum Value: Sendable, Hashable { + /// A string argument. + case string(String) + #if !os(Windows) + /// A raw-bytes argument. + /// + /// - Note: This case is only available on POSIX platforms. + case rawBytes([UInt8]) + #endif + } + + /// The argument that overrides the executable path as `argv[0]`, or `nil` + /// if the executable path is used unchanged. + /// + /// This corresponds to the `executablePathOverride` parameter passed to + /// the initializer, and is useful for verifying configuration in tests + /// without spawning a subprocess. + public var executablePathOverride: Value? { + self._executablePathOverride.map(Value.init) + } +} + +extension Arguments: RandomAccessCollection { + public typealias Element = Value + public typealias Index = Int + + public var startIndex: Int { self.storage.startIndex } + public var endIndex: Int { self.storage.endIndex } + + public subscript(position: Int) -> Value { + Value(self.storage[position]) + } +} + +extension Arguments.Value { + internal init(_ storage: StringOrRawBytes) { + switch storage { + case .string(let s): + self = .string(s) + case .rawBytes(let b): + #if os(Windows) + // Unreachable: The Windows public API cannot construct rawBytes arguments. + fatalError("Internal inconsistency: rawBytes argument on Windows") + #else + self = .rawBytes(b) + #endif + } + } +} + // MARK: - Environment /// A set of environment variables to use when running the subprocess. @@ -545,7 +628,8 @@ extension Environment: CustomStringConvertible, CustomDebugStringConvertible { } extension Environment.Key { - package static let path: Self = "PATH" + /// The well-known key for the `PATH` environment variable. + public static let path: Self = "PATH" } extension Environment.Key: CodingKeyRepresentable {} @@ -607,6 +691,49 @@ extension Environment.Key: RawRepresentable { extension Environment.Key: Sendable {} +// MARK: - Environment Introspection + +extension Environment { + /// The public representation of an environment's contents. + /// + /// Use this to introspect how an ``Environment`` was constructed (for + /// example, to verify in tests that a configuration builder produced + /// the expected inherited overrides or custom values without spawning a + /// subprocess). + public enum Representation: Sendable, Hashable { + /// The environment inherits from the current process, with the given + /// updates applied. + /// + /// A `nil` value for a key indicates that the key is unset relative to + /// the inherited environment, rather than being set to an empty value. + case inherited(updates: [Key: String?]) + /// The environment uses the given custom values, with no inheritance + /// from the current process. + case custom([Key: String]) + #if !os(Windows) + /// The environment uses the given raw bytes, with no inheritance from + /// the current process. + /// + /// - Note: This case is only available on POSIX platforms. + case rawBytes([[UInt8]]) + #endif + } + + /// The contents of this environment. + public var representation: Representation { + switch self.config { + case .inherit(let updates): + return .inherited(updates: updates) + case .custom(let values): + return .custom(values) + #if !os(Windows) + case .rawBytes(let bytes): + return .rawBytes(bytes) + #endif + } + } +} + // MARK: - TerminationStatus /// The exit status of a subprocess. diff --git a/Sources/Subprocess/Platforms/Subprocess+Unix.swift b/Sources/Subprocess/Platforms/Subprocess+Unix.swift index 86d143a..72c93df 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Unix.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Unix.swift @@ -258,7 +258,7 @@ extension Arguments { internal func createArgs(withExecutablePath executablePath: String) -> [UnsafeMutablePointer?] { var argv: [UnsafeMutablePointer?] = self.storage.map { $0.createRawBytes() } // argv[0] = executable path - if let override = self.executablePathOverride { + if let override = self._executablePathOverride { argv.insert(override.createRawBytes(), at: 0) } else { argv.insert(strdup(executablePath), at: 0) diff --git a/Sources/Subprocess/Platforms/Subprocess+Windows.swift b/Sources/Subprocess/Platforms/Subprocess+Windows.swift index f77654d..b86fee3 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Windows.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Windows.swift @@ -72,7 +72,7 @@ extension Configuration { // user wants to override executable path in arguments, we have to use `lpApplicationName` // to specify the executable path. In this case, manually loop over all possible paths. let possibleExecutablePaths: _OrderedSet - if _fastPath(self.arguments.executablePathOverride == nil) { + if _fastPath(self.arguments._executablePathOverride == nil) { // Fast path: we can rely on `CreateProcessW`'s built in Path searching switch self.executable.storage { case .executable(let executable): @@ -266,7 +266,7 @@ extension Configuration { // user wants to override executable path in arguments, we have to use `lpApplicationName` // to specify the executable path. In this case, manually loop over all possible paths. let possibleExecutablePaths: _OrderedSet - if _fastPath(self.arguments.executablePathOverride == nil) { + if _fastPath(self.arguments._executablePathOverride == nil) { // Fast path: we can rely on `CreateProcessW`'s built in Path searching switch self.executable.storage { case .executable(let executable): @@ -1044,7 +1044,7 @@ extension Configuration { // Omit applicationName (and therefore rely on commandAndArgs // for executable path) if we don't need to override arg0 return ( - applicationName: self.arguments.executablePathOverride == nil ? nil : applicationName, + applicationName: self.arguments._executablePathOverride == nil ? nil : applicationName, commandAndArgs: commandAndArgs, environment: environmentString, intendedWorkingDir: self.workingDirectory?.string @@ -1216,7 +1216,7 @@ extension Configuration { return stringValue } - if case .string(let overrideName) = self.arguments.executablePathOverride { + if case .string(let overrideName) = self.arguments._executablePathOverride { // Use the override as argument0 and set applicationName args.insert(overrideName, at: 0) } else { diff --git a/Tests/SubprocessTests/ConfigurationIntrospectionTests.swift b/Tests/SubprocessTests/ConfigurationIntrospectionTests.swift new file mode 100644 index 0000000..04d2735 --- /dev/null +++ b/Tests/SubprocessTests/ConfigurationIntrospectionTests.swift @@ -0,0 +1,195 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +#if canImport(System) +import System +#else +import SystemPackage +#endif + +import Testing +import Subprocess + +@Suite("Configuration Introspection Tests") +struct ConfigurationIntrospectionTests {} + +// MARK: - Arguments +extension ConfigurationIntrospectionTests { + @Test func testArgumentsArrayLiteralIsIterable() { + let arguments: Arguments = ["--foo", "bar", "--baz"] + + #expect(arguments.count == 3) + #expect(arguments[0] == .string("--foo")) + #expect(arguments[1] == .string("bar")) + #expect(arguments[2] == .string("--baz")) + #expect(Array(arguments) == [.string("--foo"), .string("bar"), .string("--baz")]) + } + + @Test func testArgumentsFromStringArrayIsIterable() { + let arguments = Arguments(["a", "b", "c"]) + + #expect(arguments.count == 3) + #expect(arguments.first == .string("a")) + #expect(arguments.last == .string("c")) + } + + @Test func testArgumentsEmpty() { + let arguments: Arguments = [] + + #expect(arguments.isEmpty) + #expect(arguments.count == 0) + #expect(arguments.executablePathOverride == nil) + } + + @Test func testArgumentsExecutablePathOverrideNil() { + let arguments = Arguments(["a", "b"]) + + #expect(arguments.executablePathOverride == nil) + } + + @Test func testArgumentsExecutablePathOverrideString() { + let arguments = Arguments( + executablePathOverride: "argv0-override", + remainingValues: ["a", "b"] + ) + + #expect(arguments.executablePathOverride == .string("argv0-override")) + #expect(arguments.count == 2) + #expect(Array(arguments) == [.string("a"), .string("b")]) + } + + @Test func testArgumentsExecutablePathOverrideExplicitlyNil() { + let arguments = Arguments( + executablePathOverride: nil, + remainingValues: ["a", "b"] + ) + + #expect(arguments.executablePathOverride == nil) + #expect(arguments.count == 2) + #expect(Array(arguments) == [.string("a"), .string("b")]) + } + + #if !os(Windows) + @Test func testArgumentsFromRawBytesIsIterable() { + let arguments = Arguments([ + Array("first".utf8), + Array("second".utf8), + ]) + + #expect(arguments.count == 2) + #expect(arguments[0] == .rawBytes(Array("first".utf8))) + #expect(arguments[1] == .rawBytes(Array("second".utf8))) + } + + @Test func testArgumentsRawBytesExecutablePathOverride() { + let arguments = Arguments( + executablePathOverride: Array("argv0".utf8), + remainingValues: [Array("a".utf8), Array("b".utf8)] + ) + + #expect(arguments.executablePathOverride == .rawBytes(Array("argv0".utf8))) + #expect(arguments.count == 2) + #expect(arguments[0] == .rawBytes(Array("a".utf8))) + #expect(arguments[1] == .rawBytes(Array("b".utf8))) + } + #endif // !os(Windows) +} + +// MARK: - Environment +extension ConfigurationIntrospectionTests { + @Test func testEnvironmentInheritDefault() { + let environment: Environment = .inherit + + #expect(environment.representation == .inherited(updates: [:])) + } + + @Test func testEnvironmentInheritWithUpdates() { + let updates: [Environment.Key: String?] = [ + .path: "/custom/path", + "FOO": "foo-value", + ] + + let environment: Environment = .inherit.updating(updates) + let expected: Environment.Representation = .inherited(updates: updates) + + #expect(environment.representation == expected) + } + + @Test func testEnvironmentInheritWithUnsetDirective() { + // A `nil` value in updates means "unset relative to inherited + // environment", distinct from "absent from updates". + let updates: [Environment.Key: String?] = [ + "FOO": "foo-value", + "REMOVE_ME": nil, + ] + + let environment: Environment = .inherit.updating(updates) + let expected: Environment.Representation = .inherited(updates: updates) + + #expect(environment.representation == expected) + } + + @Test func testEnvironmentCustom() { + let updates: [Environment.Key: String] = [ + "PATH": "/usr/bin:/bin", + "HOME": "/home/user", + ] + + let environment: Environment = .custom(updates) + let expected: Environment.Representation = .custom(updates) + + #expect(environment.representation == expected) + } + + @Test func testEnvironmentCustomUpdating() { + let environment: Environment = .custom([ + "PATH": "/usr/bin:/bin" + ]).updating([ + "EXTRA": "value" + ]) + + let expected: Environment.Representation = .custom([ + "PATH": "/usr/bin:/bin", + "EXTRA": "value", + ]) + + #expect(environment.representation == expected) + } + + #if !os(Windows) + @Test func testEnvironmentRawBytes() { + let entries: [[UInt8]] = [ + Array("PATH=/usr/bin:/bin\0".utf8), + Array("HOME=/home/user\0".utf8), + ] + + let environment: Environment = .custom(entries) + + #expect(environment.representation == .rawBytes(entries)) + } + #endif // !os(Windows) +} + +// MARK: - Executable +extension ConfigurationIntrospectionTests { + @Test func testExecutableName() { + let executable: Executable = .name("git") + + #expect(executable.representation == .name("git")) + } + + @Test func testExecutablePath() { + let path: FilePath = "/usr/bin/git" + let executable: Executable = .path(path) + + #expect(executable.representation == .path(path)) + } +}