From 13c840318f9348b1a013cee2fcc1931f52004223 Mon Sep 17 00:00:00 2001 From: Jake Petroules Date: Thu, 4 Sep 2025 15:19:13 -0700 Subject: [PATCH] Make Environment keys case-insensitive on Windows This is how the platform treats them, and helps avoid issues where indexing into an Environment dictionary with the wrong casing fails to return a value. Closes #134 --- Sources/Subprocess/Configuration.swift | 88 +++++++++++++++++-- .../Platforms/Subprocess+Unix.swift | 10 +-- .../Platforms/Subprocess+Windows.swift | 23 ++--- 3 files changed, 93 insertions(+), 28 deletions(-) diff --git a/Sources/Subprocess/Configuration.swift b/Sources/Subprocess/Configuration.swift index 34d7f9f..de019e4 100644 --- a/Sources/Subprocess/Configuration.swift +++ b/Sources/Subprocess/Configuration.swift @@ -401,8 +401,8 @@ extension Arguments: CustomStringConvertible, CustomDebugStringConvertible { /// A set of environment variables to use when executing the subprocess. public struct Environment: Sendable, Hashable { internal enum Configuration: Sendable, Hashable { - case inherit([String: String]) - case custom([String: String]) + case inherit([Key: String]) + case custom([Key: String]) #if !os(Windows) case rawBytes([[UInt8]]) #endif @@ -419,11 +419,11 @@ public struct Environment: Sendable, Hashable { return .init(config: .inherit([:])) } /// Override the provided `newValue` in the existing `Environment` - public func updating(_ newValue: [String: String]) -> Self { + public func updating(_ newValue: [Key: String]) -> Self { return .init(config: .inherit(newValue)) } /// Use custom environment variables - public static func custom(_ newValue: [String: String]) -> Self { + public static func custom(_ newValue: [Key: String]) -> Self { return .init(config: .custom(newValue)) } @@ -436,6 +436,17 @@ public struct Environment: Sendable, Hashable { } extension Environment: CustomStringConvertible, CustomDebugStringConvertible { + /// A key used to access values in an ``Environment``. + /// + /// This type respects the compiled platform's case sensitivity requirements. + public struct Key { + public var rawValue: String + + package init(_ rawValue: String) { + self.rawValue = rawValue + } + } + /// A textual representation of the environment. public var description: String { switch self.config { @@ -464,9 +475,9 @@ extension Environment: CustomStringConvertible, CustomDebugStringConvertible { return self.description } - internal static func currentEnvironmentValues() -> [String: String] { + internal static func currentEnvironmentValues() -> [Key: String] { return self.withCopiedEnv { environments in - var results: [String: String] = [:] + var results: [Key: String] = [:] for env in environments { let environmentString = String(cString: env) @@ -488,13 +499,76 @@ extension Environment: CustomStringConvertible, CustomDebugStringConvertible { let value = String( environmentString[environmentString.index(after: delimiter).. Bool { + // Even on windows use a stable sort order. + lhs.rawValue < rhs.rawValue + } +} + +extension Environment.Key: CustomStringConvertible { + public var description: String { self.rawValue } +} + +extension Environment.Key: Encodable { + public func encode(to encoder: any Swift.Encoder) throws { + try self.rawValue.encode(to: encoder) + } +} + +extension Environment.Key: Equatable { + public static func == (_ lhs: Self, _ rhs: Self) -> Bool { + #if os(Windows) + lhs.rawValue.lowercased() == rhs.rawValue.lowercased() + #else + lhs.rawValue == rhs.rawValue + #endif + } +} + +extension Environment.Key: ExpressibleByStringLiteral { + public init(stringLiteral rawValue: String) { + self.init(rawValue) + } +} + +extension Environment.Key: Decodable { + public init(from decoder: any Swift.Decoder) throws { + self.rawValue = try String(from: decoder) + } +} + +extension Environment.Key: Hashable { + public func hash(into hasher: inout Hasher) { + #if os(Windows) + self.rawValue.lowercased().hash(into: &hasher) + #else + self.rawValue.hash(into: &hasher) + #endif + } +} + +extension Environment.Key: RawRepresentable { + public init?(rawValue: String) { + self.rawValue = rawValue + } +} + +extension Environment.Key: Sendable {} + // MARK: - TerminationStatus /// An exit status of a subprocess. diff --git a/Sources/Subprocess/Platforms/Subprocess+Unix.swift b/Sources/Subprocess/Platforms/Subprocess+Unix.swift index 4e9f260..98eec1d 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Unix.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Unix.swift @@ -145,24 +145,22 @@ extension Execution { // MARK: - Environment Resolution extension Environment { - internal static let pathVariableName = "PATH" - internal func pathValue() -> String? { switch self.config { case .inherit(let overrides): // If PATH value exists in overrides, use it - if let value = overrides[Self.pathVariableName] { + if let value = overrides[.path] { return value } // Fall back to current process - return Self.currentEnvironmentValues()[Self.pathVariableName] + return Self.currentEnvironmentValues()[.path] case .custom(let fullEnvironment): - if let value = fullEnvironment[Self.pathVariableName] { + if let value = fullEnvironment[.path] { return value } return nil case .rawBytes(let rawBytesArray): - let needle: [UInt8] = Array("\(Self.pathVariableName)=".utf8) + let needle: [UInt8] = Array("\(Key.path.rawValue)=".utf8) for row in rawBytesArray { guard row.starts(with: needle) else { continue diff --git a/Sources/Subprocess/Platforms/Subprocess+Windows.swift b/Sources/Subprocess/Platforms/Subprocess+Windows.swift index 1a9a0aa..371ad20 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Windows.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Windows.swift @@ -926,13 +926,13 @@ extension Environment { switch self.config { case .inherit(let overrides): // If PATH value exists in overrides, use it - if let value = overrides.pathValue() { + if let value = overrides[.path] { return value } // Fall back to current process - return Self.currentEnvironmentValues().pathValue() + return Self.currentEnvironmentValues()[.path] case .custom(let fullEnvironment): - if let value = fullEnvironment.pathValue() { + if let value = fullEnvironment[.path] { return value } return nil @@ -1006,7 +1006,7 @@ extension Configuration { intendedWorkingDir: String? ) { // Prepare environment - var env: [String: String] = [:] + var env: [Environment.Key: String] = [:] switch self.environment.config { case .custom(let customValues): // Use the custom values directly @@ -1020,17 +1020,17 @@ extension Configuration { } // On Windows, the PATH is required in order to locate dlls needed by // the process so we should also pass that to the child - if env.pathValue() == nil, - let parentPath = Environment.currentEnvironmentValues().pathValue() + if env[.path] == nil, + let parentPath = Environment.currentEnvironmentValues()[.path] { - env["Path"] = parentPath + env[.path] = parentPath } // The environment string must be terminated by a double // null-terminator. Otherwise, CreateProcess will fail with // INVALID_PARMETER. let environmentString = env.map { - $0.key + "=" + $0.value + $0.key.rawValue + "=" + $0.value }.joined(separator: "\0") + "\0\0" // Prepare arguments @@ -1509,11 +1509,4 @@ internal func fillNullTerminatedWideStringBuffer( throw SubprocessError.UnderlyingError(rawValue: DWORD(ERROR_INSUFFICIENT_BUFFER)) } -// Windows environment key is case insensitive -extension Dictionary where Key == String, Value == String { - internal func pathValue() -> String? { - return self["Path"] ?? self["PATH"] ?? self["path"] - } -} - #endif // canImport(WinSDK)