From 07b05696b09c918adabd061626b0b660afad3267 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 | 49 ++++++++++++++--------- Sources/TSCBasic/ProcessEnv.swift | 66 ++++++++++++++++++++++++++----- 2 files changed, 88 insertions(+), 27 deletions(-) diff --git a/Sources/TSCBasic/Process.swift b/Sources/TSCBasic/Process.swift index 5a396823..d8619e98 100644 --- a/Sources/TSCBasic/Process.swift +++ b/Sources/TSCBasic/Process.swift @@ -23,6 +23,13 @@ import Dispatch import _Concurrency +#if os(Windows) +public typealias ProcessEnvironmentBlock = Dictionary +#else +public typealias ProcessEnvironmentBlock = Dictionary +#endif + + /// Process result data which is available after process termination. public struct ProcessResult: CustomStringConvertible, Sendable { @@ -53,7 +60,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 +78,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 +106,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 +292,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 +366,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 +386,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 +418,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 +435,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 +451,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 +543,13 @@ public final class Process { process.currentDirectoryURL = workingDirectory.asURL } process.executableURL = executablePath.asURL +#if os(Windows) + process.environment = .init(uniqueKeysWithValues: environment.map { + ($0.value, $1) + }) +#else process.environment = environment +#endif let stdinPipe = Pipe() process.standardInput = stdinPipe @@ -989,7 +1002,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 +1025,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 +1043,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 +1066,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 +1088,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 +1126,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 +1153,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 +1173,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 +1205,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..c964dfcf 100644 --- a/Sources/TSCBasic/ProcessEnv.swift +++ b/Sources/TSCBasic/ProcessEnv.swift @@ -9,25 +9,74 @@ */ import Foundation +#if os(Windows) +import WinSDK +#else import TSCLibc +#endif + +public struct CaseInsensitiveString { + internal var value: String + + public init(_ value: String) { + self.value = value + } +} + +extension CaseInsensitiveString: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self.value = value + } +} + +extension CaseInsensitiveString: Equatable { + public static func == (_ lhs: Self, _ rhs: Self) -> Bool { + return lhs.value.lowercased() == rhs.value.lowercased() + } +} + +extension CaseInsensitiveString: Hashable { + public 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. +#if os(Windows) + public static var vars: [CaseInsensitiveString: String] { _vars } + private static var _vars: Dictionary = { + .init(uniqueKeysWithValues: ProcessInfo.processInfo.environment.map { + (CaseInsensitiveString($0), $1) + }) + }() +#else public static var vars: [String: String] { _vars } private static var _vars = ProcessInfo.processInfo.environment +#endif /// Invalidate the cached env. public static func invalidateEnv() { +#if os(Windows) + _vars = .init(uniqueKeysWithValues: ProcessInfo.processInfo.environment.map { + (CaseInsensitiveString($0), $1) + }) +#else _vars = 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 +89,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 @@ -54,11 +105,10 @@ public enum ProcessEnv { /// `PATH` variable in the process's environment (`Path` under Windows). public static var path: String? { #if os(Windows) - let pathArg = "Path" + return vas["Path"] #else - let pathArg = "PATH" + return vas["PATH"] #endif - return vars[pathArg] } /// The current working directory of the process. @@ -70,9 +120,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