Skip to content

Commit fdfb719

Browse files
committed
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
1 parent 8732961 commit fdfb719

File tree

2 files changed

+112
-34
lines changed

2 files changed

+112
-34
lines changed

Sources/TSCBasic/Process.swift

+67-19
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,54 @@ import Dispatch
2323

2424
import _Concurrency
2525

26+
public struct ProcessEnvironmentBlock {
27+
#if os(Windows)
28+
internal typealias Key = CaseInsensitiveString
29+
#else
30+
internal typealias Key = String
31+
#endif
32+
33+
private var storage: Dictionary<Key, String>
34+
35+
public init(dictionary: Dictionary<String, String>) {
36+
#if os(Windows)
37+
self.storage = .init(uniqueKeysWithValues: dictionary.map {
38+
(CaseInsensitiveString($0), $1)
39+
})
40+
#else
41+
self.storage = dictionary
42+
#endif
43+
}
44+
45+
internal init<S: Sequence>(uniqueKeysWithValues keysAndValues: S)
46+
where S.Element == (Key, String) {
47+
storage = .init(uniqueKeysWithValues: keysAndValues)
48+
}
49+
50+
public var dictionary: Dictionary<String, String> {
51+
#if os(Windows)
52+
return Dictionary<String, String>(uniqueKeysWithValues: storage.map {
53+
($0.value, $1)
54+
})
55+
#else
56+
return storage
57+
#endif
58+
}
59+
60+
public subscript(_ key: String) -> String? {
61+
#if os(Windows)
62+
return storage[CaseInsensitiveString(key)]
63+
#else
64+
return storage[key]
65+
#endif
66+
}
67+
68+
public func contains(_ key: String) -> Bool {
69+
return storage.keys.contains(Key(key))
70+
}
71+
}
72+
73+
2674
/// Process result data which is available after process termination.
2775
public struct ProcessResult: CustomStringConvertible, Sendable {
2876

@@ -53,7 +101,7 @@ public struct ProcessResult: CustomStringConvertible, Sendable {
53101
public let arguments: [String]
54102

55103
/// The environment with which the process was launched.
56-
public let environment: [String: String]
104+
public let environment: ProcessEnvironmentBlock
57105

58106
/// The exit status of the process.
59107
public let exitStatus: ExitStatus
@@ -71,7 +119,7 @@ public struct ProcessResult: CustomStringConvertible, Sendable {
71119
/// See `waitpid(2)` for information on the exit status code.
72120
public init(
73121
arguments: [String],
74-
environment: [String: String],
122+
environment: ProcessEnvironmentBlock,
75123
exitStatusCode: Int32,
76124
normal: Bool,
77125
output: Result<[UInt8], Swift.Error>,
@@ -99,7 +147,7 @@ public struct ProcessResult: CustomStringConvertible, Sendable {
99147
/// Create an instance using an exit status and output result.
100148
public init(
101149
arguments: [String],
102-
environment: [String: String],
150+
environment: ProcessEnvironmentBlock,
103151
exitStatus: ExitStatus,
104152
output: Result<[UInt8], Swift.Error>,
105153
stderrOutput: Result<[UInt8], Swift.Error>
@@ -285,7 +333,7 @@ public final class Process {
285333
public let arguments: [String]
286334

287335
/// The environment with which the process was executed.
288-
public let environment: [String: String]
336+
public let environment: ProcessEnvironmentBlock
289337

290338
/// The path to the directory under which to run the process.
291339
public let workingDirectory: AbsolutePath?
@@ -359,7 +407,7 @@ public final class Process {
359407
@available(macOS 10.15, *)
360408
public init(
361409
arguments: [String],
362-
environment: [String: String] = ProcessEnv.vars,
410+
environment: ProcessEnvironmentBlock = ProcessEnv.vars,
363411
workingDirectory: AbsolutePath,
364412
outputRedirection: OutputRedirection = .collect,
365413
startNewProcessGroup: Bool = true,
@@ -379,7 +427,7 @@ public final class Process {
379427
@available(macOS 10.15, *)
380428
public convenience init(
381429
arguments: [String],
382-
environment: [String: String] = ProcessEnv.vars,
430+
environment: ProcessEnvironmentBlock = ProcessEnv.vars,
383431
workingDirectory: AbsolutePath,
384432
outputRedirection: OutputRedirection = .collect,
385433
verbose: Bool,
@@ -411,7 +459,7 @@ public final class Process {
411459
/// - loggingHandler: Handler for logging messages
412460
public init(
413461
arguments: [String],
414-
environment: [String: String] = ProcessEnv.vars,
462+
environment: ProcessEnvironmentBlock = ProcessEnv.vars,
415463
outputRedirection: OutputRedirection = .collect,
416464
startNewProcessGroup: Bool = true,
417465
loggingHandler: LoggingHandler? = .none
@@ -428,7 +476,7 @@ public final class Process {
428476
@available(*, deprecated, message: "use version without verbosity flag")
429477
public convenience init(
430478
arguments: [String],
431-
environment: [String: String] = ProcessEnv.vars,
479+
environment: ProcessEnvironmentBlock = ProcessEnv.vars,
432480
outputRedirection: OutputRedirection = .collect,
433481
verbose: Bool = Process.verbose,
434482
startNewProcessGroup: Bool = true
@@ -444,7 +492,7 @@ public final class Process {
444492

445493
public convenience init(
446494
args: String...,
447-
environment: [String: String] = ProcessEnv.vars,
495+
environment: ProcessEnvironmentBlock = ProcessEnv.vars,
448496
outputRedirection: OutputRedirection = .collect,
449497
loggingHandler: LoggingHandler? = .none
450498
) {
@@ -536,7 +584,7 @@ public final class Process {
536584
process.currentDirectoryURL = workingDirectory.asURL
537585
}
538586
process.executableURL = executablePath.asURL
539-
process.environment = environment
587+
process.environment = environment.dictionary
540588

541589
let stdinPipe = Pipe()
542590
process.standardInput = stdinPipe
@@ -989,7 +1037,7 @@ extension Process {
9891037
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
9901038
static public func popen(
9911039
arguments: [String],
992-
environment: [String: String] = ProcessEnv.vars,
1040+
environment: ProcessEnvironmentBlock = ProcessEnv.vars,
9931041
loggingHandler: LoggingHandler? = .none
9941042
) async throws -> ProcessResult {
9951043
let process = Process(
@@ -1012,7 +1060,7 @@ extension Process {
10121060
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
10131061
static public func popen(
10141062
args: String...,
1015-
environment: [String: String] = ProcessEnv.vars,
1063+
environment: ProcessEnvironmentBlock = ProcessEnv.vars,
10161064
loggingHandler: LoggingHandler? = .none
10171065
) async throws -> ProcessResult {
10181066
try await popen(arguments: args, environment: environment, loggingHandler: loggingHandler)
@@ -1030,7 +1078,7 @@ extension Process {
10301078
@discardableResult
10311079
static public func checkNonZeroExit(
10321080
arguments: [String],
1033-
environment: [String: String] = ProcessEnv.vars,
1081+
environment: ProcessEnvironmentBlock = ProcessEnv.vars,
10341082
loggingHandler: LoggingHandler? = .none
10351083
) async throws -> String {
10361084
let result = try await popen(arguments: arguments, environment: environment, loggingHandler: loggingHandler)
@@ -1053,7 +1101,7 @@ extension Process {
10531101
@discardableResult
10541102
static public func checkNonZeroExit(
10551103
args: String...,
1056-
environment: [String: String] = ProcessEnv.vars,
1104+
environment: ProcessEnvironmentBlock = ProcessEnv.vars,
10571105
loggingHandler: LoggingHandler? = .none
10581106
) async throws -> String {
10591107
try await checkNonZeroExit(arguments: args, environment: environment, loggingHandler: loggingHandler)
@@ -1075,7 +1123,7 @@ extension Process {
10751123
// #endif
10761124
static public func popen(
10771125
arguments: [String],
1078-
environment: [String: String] = ProcessEnv.vars,
1126+
environment: ProcessEnvironmentBlock = ProcessEnv.vars,
10791127
loggingHandler: LoggingHandler? = .none,
10801128
queue: DispatchQueue? = nil,
10811129
completion: @escaping (Result<ProcessResult, Swift.Error>) -> Void
@@ -1113,7 +1161,7 @@ extension Process {
11131161
@discardableResult
11141162
static public func popen(
11151163
arguments: [String],
1116-
environment: [String: String] = ProcessEnv.vars,
1164+
environment: ProcessEnvironmentBlock = ProcessEnv.vars,
11171165
loggingHandler: LoggingHandler? = .none
11181166
) throws -> ProcessResult {
11191167
let process = Process(
@@ -1140,7 +1188,7 @@ extension Process {
11401188
@discardableResult
11411189
static public func popen(
11421190
args: String...,
1143-
environment: [String: String] = ProcessEnv.vars,
1191+
environment: ProcessEnvironmentBlock = ProcessEnv.vars,
11441192
loggingHandler: LoggingHandler? = .none
11451193
) throws -> ProcessResult {
11461194
return try Process.popen(arguments: args, environment: environment, loggingHandler: loggingHandler)
@@ -1160,7 +1208,7 @@ extension Process {
11601208
@discardableResult
11611209
static public func checkNonZeroExit(
11621210
arguments: [String],
1163-
environment: [String: String] = ProcessEnv.vars,
1211+
environment: ProcessEnvironmentBlock = ProcessEnv.vars,
11641212
loggingHandler: LoggingHandler? = .none
11651213
) throws -> String {
11661214
let process = Process(
@@ -1192,7 +1240,7 @@ extension Process {
11921240
@discardableResult
11931241
static public func checkNonZeroExit(
11941242
args: String...,
1195-
environment: [String: String] = ProcessEnv.vars,
1243+
environment: ProcessEnvironmentBlock = ProcessEnv.vars,
11961244
loggingHandler: LoggingHandler? = .none
11971245
) throws -> String {
11981246
return try checkNonZeroExit(arguments: args, environment: environment, loggingHandler: loggingHandler)

Sources/TSCBasic/ProcessEnv.swift

+45-15
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,60 @@
99
*/
1010

1111
import Foundation
12+
#if os(Windows)
13+
import WinSDK
14+
#else
1215
import TSCLibc
16+
#endif
17+
18+
internal struct CaseInsensitiveString {
19+
internal var value: String
20+
21+
internal init(_ value: String) {
22+
self.value = value
23+
}
24+
}
25+
26+
extension CaseInsensitiveString: ExpressibleByStringLiteral {
27+
internal init(stringLiteral value: String) {
28+
self.value = value
29+
}
30+
}
31+
32+
extension CaseInsensitiveString: Equatable {
33+
internal static func == (_ lhs: Self, _ rhs: Self) -> Bool {
34+
return lhs.value.lowercased() == rhs.value.lowercased()
35+
}
36+
}
37+
38+
extension CaseInsensitiveString: Hashable {
39+
internal func hash(into hasher: inout Hasher) {
40+
self.value.lowercased().hash(into: &hasher)
41+
}
42+
}
1343

1444
/// Provides functionality related a process's enviorment.
1545
public enum ProcessEnv {
1646

1747
/// Returns a dictionary containing the current environment.
18-
public static var vars: [String: String] { _vars }
19-
private static var _vars = ProcessInfo.processInfo.environment
48+
public static var vars: ProcessEnvironmentBlock { _vars }
49+
private static var _vars =
50+
ProcessEnvironmentBlock(dictionary: ProcessInfo.processInfo.environment)
2051

2152
/// Invalidate the cached env.
2253
public static func invalidateEnv() {
23-
_vars = ProcessInfo.processInfo.environment
54+
_vars = ProcessEnvironmentBlock(dictionary: ProcessInfo.processInfo.environment)
2455
}
2556

2657
/// Set the given key and value in the process's environment.
2758
public static func setVar(_ key: String, value: String) throws {
2859
#if os(Windows)
29-
guard TSCLibc._putenv("\(key)=\(value)") == 0 else {
30-
throw SystemError.setenv(Int32(GetLastError()), key)
60+
try key.withCString(encodedAs: UTF16.self) { pwszKey in
61+
try value.withCString(encodedAs: UTF16.self) { pwszValue in
62+
guard SetEnvironmentVariableW(pwszKey, pwszValue) else {
63+
throw SystemError.setenv(Int32(GetLastError()), key)
64+
}
65+
}
3166
}
3267
#else
3368
guard TSCLibc.setenv(key, value, 1) == 0 else {
@@ -40,7 +75,9 @@ public enum ProcessEnv {
4075
/// Unset the give key in the process's environment.
4176
public static func unsetVar(_ key: String) throws {
4277
#if os(Windows)
43-
guard TSCLibc._putenv("\(key)=") == 0 else {
78+
guard (key.withCString(encodedAs: UTF16.self) {
79+
SetEnvironmentVariableW($0, nil)
80+
}) else {
4481
throw SystemError.unsetenv(Int32(GetLastError()), key)
4582
}
4683
#else
@@ -53,12 +90,7 @@ public enum ProcessEnv {
5390

5491
/// `PATH` variable in the process's environment (`Path` under Windows).
5592
public static var path: String? {
56-
#if os(Windows)
57-
let pathArg = "Path"
58-
#else
59-
let pathArg = "PATH"
60-
#endif
61-
return vars[pathArg]
93+
return vars["PATH"]
6294
}
6395

6496
/// The current working directory of the process.
@@ -70,9 +102,7 @@ public enum ProcessEnv {
70102
public static func chdir(_ path: AbsolutePath) throws {
71103
let path = path.pathString
72104
#if os(Windows)
73-
guard path.withCString(encodedAs: UTF16.self, {
74-
SetCurrentDirectoryW($0)
75-
}) else {
105+
guard path.withCString(encodedAs: UTF16.self, SetCurrentDirectoryW) else {
76106
throw SystemError.chdir(Int32(GetLastError()), path)
77107
}
78108
#else

0 commit comments

Comments
 (0)