From 9a746a4c7d9aa22f18e85677e506b8a4c8586c08 Mon Sep 17 00:00:00 2001 From: Saleem Abdulrasool Date: Sat, 25 Nov 2023 15:02:02 -0800 Subject: [PATCH] TSCBasic: support case insensitivity for environment Windows is case insensitive for the environment control block, which we did not honour. This causes issues when Swift is used with programs which are incorrectly cased (e.g. emacs). Introduce an explicit wrapper type for Windows to make the lookup case insensitive, canonicalising the name to lowercase. This allows us to treat `Path` and `PATH` identically (along with any other environment variable and case matching) which respects the Windows behaviour. Additionally, migrate away from the POSIX variants which do not handle the case properly to the Windows version which does. Fixes: #446 --- Sources/TSCBasic/Process.swift | 82 ++++++++++++++++++++++++------- Sources/TSCBasic/ProcessEnv.swift | 73 +++++++++++++++++++++------ 2 files changed, 121 insertions(+), 34 deletions(-) diff --git a/Sources/TSCBasic/Process.swift b/Sources/TSCBasic/Process.swift index 5a396823..f012bb63 100644 --- a/Sources/TSCBasic/Process.swift +++ b/Sources/TSCBasic/Process.swift @@ -23,6 +23,50 @@ import Dispatch import _Concurrency +public struct ProcessEnvironmentBlock { +#if os(Windows) + internal typealias Key = CaseInsensitiveString +#else + internal typealias Key = String +#endif + + private var storage: Dictionary + + public init(dictionary: Dictionary) { +#if os(Windows) + self.storage = .init(uniqueKeysWithValues: dictionary.map { + (CaseInsensitiveString($0), $1) + }) +#else + self.storage = dictionary +#endif + } + + internal init(uniqueKeysWithValues keysAndValues: S) + where S.Element == (Key, String) { + storage = .init(uniqueKeysWithValues: keysAndValues) + } + + public var dictionary: Dictionary { +#if os(Windows) + return Dictionary(uniqueKeysWithValues: storage.map { + ($0.value, $1) + }) +#else + return storage +#endif + } + + public subscript(_ key: String) -> String? { +#if os(Windows) + return storage[CaseInsensitiveString(key)] +#else + return storage[key] +#endif + } +} + + /// Process result data which is available after process termination. public struct ProcessResult: CustomStringConvertible, Sendable { @@ -53,7 +97,7 @@ public struct ProcessResult: CustomStringConvertible, Sendable { public let arguments: [String] /// The environment with which the process was launched. - public let environment: [String: String] + public let environment: ProcessEnvironmentBlock /// The exit status of the process. public let exitStatus: ExitStatus @@ -71,7 +115,7 @@ public struct ProcessResult: CustomStringConvertible, Sendable { /// See `waitpid(2)` for information on the exit status code. public init( arguments: [String], - environment: [String: String], + environment: ProcessEnvironmentBlock, exitStatusCode: Int32, normal: Bool, output: Result<[UInt8], Swift.Error>, @@ -99,7 +143,7 @@ public struct ProcessResult: CustomStringConvertible, Sendable { /// Create an instance using an exit status and output result. public init( arguments: [String], - environment: [String: String], + environment: ProcessEnvironmentBlock, exitStatus: ExitStatus, output: Result<[UInt8], Swift.Error>, stderrOutput: Result<[UInt8], Swift.Error> @@ -285,7 +329,7 @@ public final class Process { public let arguments: [String] /// The environment with which the process was executed. - public let environment: [String: String] + public let environment: ProcessEnvironmentBlock /// The path to the directory under which to run the process. public let workingDirectory: AbsolutePath? @@ -359,7 +403,7 @@ public final class Process { @available(macOS 10.15, *) public init( arguments: [String], - environment: [String: String] = ProcessEnv.vars, + environment: ProcessEnvironmentBlock = ProcessEnv.vars, workingDirectory: AbsolutePath, outputRedirection: OutputRedirection = .collect, startNewProcessGroup: Bool = true, @@ -379,7 +423,7 @@ public final class Process { @available(macOS 10.15, *) public convenience init( arguments: [String], - environment: [String: String] = ProcessEnv.vars, + environment: ProcessEnvironmentBlock = ProcessEnv.vars, workingDirectory: AbsolutePath, outputRedirection: OutputRedirection = .collect, verbose: Bool, @@ -411,7 +455,7 @@ public final class Process { /// - loggingHandler: Handler for logging messages public init( arguments: [String], - environment: [String: String] = ProcessEnv.vars, + environment: ProcessEnvironmentBlock = ProcessEnv.vars, outputRedirection: OutputRedirection = .collect, startNewProcessGroup: Bool = true, loggingHandler: LoggingHandler? = .none @@ -428,7 +472,7 @@ public final class Process { @available(*, deprecated, message: "use version without verbosity flag") public convenience init( arguments: [String], - environment: [String: String] = ProcessEnv.vars, + environment: ProcessEnvironmentBlock = ProcessEnv.vars, outputRedirection: OutputRedirection = .collect, verbose: Bool = Process.verbose, startNewProcessGroup: Bool = true @@ -444,7 +488,7 @@ public final class Process { public convenience init( args: String..., - environment: [String: String] = ProcessEnv.vars, + environment: ProcessEnvironmentBlock = ProcessEnv.vars, outputRedirection: OutputRedirection = .collect, loggingHandler: LoggingHandler? = .none ) { @@ -536,7 +580,7 @@ public final class Process { process.currentDirectoryURL = workingDirectory.asURL } process.executableURL = executablePath.asURL - process.environment = environment + process.environment = environment.dictionary let stdinPipe = Pipe() process.standardInput = stdinPipe @@ -989,7 +1033,7 @@ extension Process { @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) static public func popen( arguments: [String], - environment: [String: String] = ProcessEnv.vars, + environment: ProcessEnvironmentBlock = ProcessEnv.vars, loggingHandler: LoggingHandler? = .none ) async throws -> ProcessResult { let process = Process( @@ -1012,7 +1056,7 @@ extension Process { @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) static public func popen( args: String..., - environment: [String: String] = ProcessEnv.vars, + environment: ProcessEnvironmentBlock = ProcessEnv.vars, loggingHandler: LoggingHandler? = .none ) async throws -> ProcessResult { try await popen(arguments: args, environment: environment, loggingHandler: loggingHandler) @@ -1030,7 +1074,7 @@ extension Process { @discardableResult static public func checkNonZeroExit( arguments: [String], - environment: [String: String] = ProcessEnv.vars, + environment: ProcessEnvironmentBlock = ProcessEnv.vars, loggingHandler: LoggingHandler? = .none ) async throws -> String { let result = try await popen(arguments: arguments, environment: environment, loggingHandler: loggingHandler) @@ -1053,7 +1097,7 @@ extension Process { @discardableResult static public func checkNonZeroExit( args: String..., - environment: [String: String] = ProcessEnv.vars, + environment: ProcessEnvironmentBlock = ProcessEnv.vars, loggingHandler: LoggingHandler? = .none ) async throws -> String { try await checkNonZeroExit(arguments: args, environment: environment, loggingHandler: loggingHandler) @@ -1075,7 +1119,7 @@ extension Process { // #endif static public func popen( arguments: [String], - environment: [String: String] = ProcessEnv.vars, + environment: ProcessEnvironmentBlock = ProcessEnv.vars, loggingHandler: LoggingHandler? = .none, queue: DispatchQueue? = nil, completion: @escaping (Result) -> Void @@ -1113,7 +1157,7 @@ extension Process { @discardableResult static public func popen( arguments: [String], - environment: [String: String] = ProcessEnv.vars, + environment: ProcessEnvironmentBlock = ProcessEnv.vars, loggingHandler: LoggingHandler? = .none ) throws -> ProcessResult { let process = Process( @@ -1140,7 +1184,7 @@ extension Process { @discardableResult static public func popen( args: String..., - environment: [String: String] = ProcessEnv.vars, + environment: ProcessEnvironmentBlock = ProcessEnv.vars, loggingHandler: LoggingHandler? = .none ) throws -> ProcessResult { return try Process.popen(arguments: args, environment: environment, loggingHandler: loggingHandler) @@ -1160,7 +1204,7 @@ extension Process { @discardableResult static public func checkNonZeroExit( arguments: [String], - environment: [String: String] = ProcessEnv.vars, + environment: ProcessEnvironmentBlock = ProcessEnv.vars, loggingHandler: LoggingHandler? = .none ) throws -> String { let process = Process( @@ -1192,7 +1236,7 @@ extension Process { @discardableResult static public func checkNonZeroExit( args: String..., - environment: [String: String] = ProcessEnv.vars, + environment: ProcessEnvironmentBlock = ProcessEnv.vars, loggingHandler: LoggingHandler? = .none ) throws -> String { return try checkNonZeroExit(arguments: args, environment: environment, loggingHandler: loggingHandler) diff --git a/Sources/TSCBasic/ProcessEnv.swift b/Sources/TSCBasic/ProcessEnv.swift index bf7e3c62..f96fbccc 100644 --- a/Sources/TSCBasic/ProcessEnv.swift +++ b/Sources/TSCBasic/ProcessEnv.swift @@ -9,25 +9,73 @@ */ import Foundation +#if os(Windows) +import WinSDK +#else import TSCLibc +#endif + +internal struct CaseInsensitiveString { + internal var value: String + + internal init(_ value: String) { + self.value = value + } +} + +extension CaseInsensitiveString: ExpressibleByStringLiteral { + internal init(stringLiteral value: String) { + self.value = value + } +} + +extension CaseInsensitiveString: Equatable { + internal static func == (_ lhs: Self, _ rhs: Self) -> Bool { + return lhs.value.lowercased() == rhs.value.lowercased() + } +} + +extension CaseInsensitiveString: Hashable { + internal func hash(into hasher: inout Hasher) { + self.value.lowercased().hash(into: &hasher) + } +} /// Provides functionality related a process's enviorment. public enum ProcessEnv { /// Returns a dictionary containing the current environment. - public static var vars: [String: String] { _vars } - private static var _vars = ProcessInfo.processInfo.environment + public static var vars: ProcessEnvironmentBlock { _vars } +#if os(Windows) + private static var _vars = + ProcessEnvironmentBlock(uniqueKeysWithValues: ProcessInfo.processInfo.environment.map { + (CaseInsensitiveString($0), $1) + }) +#else + private static var _vars = + ProcessEnvironmentBlock(dictionary: ProcessInfo.processInfo.environment) +#endif /// Invalidate the cached env. public static func invalidateEnv() { - _vars = ProcessInfo.processInfo.environment +#if os(Windows) + _vars = ProcessEnvironmentBlock(uniqueKeysWithValues: ProcessInfo.processInfo.environment.map { + (CaseInsensitiveString($0), $1) + }) +#else + _vars = ProcessEnvironmentBlock(dictionary: ProcessInfo.processInfo.environment) +#endif } /// Set the given key and value in the process's environment. public static func setVar(_ key: String, value: String) throws { #if os(Windows) - guard TSCLibc._putenv("\(key)=\(value)") == 0 else { - throw SystemError.setenv(Int32(GetLastError()), key) + try key.withCString(encodedAs: UTF16.self) { pwszKey in + try value.withCString(encodedAs: UTF16.self) { pwszValue in + guard SetEnvironmentVariableW(pwszKey, pwszValue) else { + throw SystemError.setenv(Int32(GetLastError()), key) + } + } } #else guard TSCLibc.setenv(key, value, 1) == 0 else { @@ -40,7 +88,9 @@ public enum ProcessEnv { /// Unset the give key in the process's environment. public static func unsetVar(_ key: String) throws { #if os(Windows) - guard TSCLibc._putenv("\(key)=") == 0 else { + guard (key.withCString(encodedAs: UTF16.self) { + SetEnvironmentVariableW($0, nil) + }) else { throw SystemError.unsetenv(Int32(GetLastError()), key) } #else @@ -53,12 +103,7 @@ public enum ProcessEnv { /// `PATH` variable in the process's environment (`Path` under Windows). public static var path: String? { -#if os(Windows) - let pathArg = "Path" -#else - let pathArg = "PATH" -#endif - return vars[pathArg] + return vars["PATH"] } /// The current working directory of the process. @@ -70,9 +115,7 @@ public enum ProcessEnv { public static func chdir(_ path: AbsolutePath) throws { let path = path.pathString #if os(Windows) - guard path.withCString(encodedAs: UTF16.self, { - SetCurrentDirectoryW($0) - }) else { + guard path.withCString(encodedAs: UTF16.self, SetCurrentDirectoryW) else { throw SystemError.chdir(Int32(GetLastError()), path) } #else