diff --git a/Sources/TSCBasic/Path.swift b/Sources/TSCBasic/Path.swift index 6575cd7f..2559cd85 100644 --- a/Sources/TSCBasic/Path.swift +++ b/Sources/TSCBasic/Path.swift @@ -479,11 +479,12 @@ private struct UNIXPath: Path { defer { fsr.deallocate() } let path: String = String(cString: fsr) - return path.withCString(encodedAs: UTF16.self) { + let result: String = path.withCString(encodedAs: UTF16.self) { let data = UnsafeMutablePointer(mutating: $0) PathCchRemoveFileSpec(data, path.count) return String(decodingCString: data, as: UTF16.self) } + return result.isEmpty ? "." : result #else // FIXME: This method seems too complicated; it should be simplified, // if possible, and certainly optimized (using UTF8View). @@ -715,6 +716,12 @@ private struct UNIXPath: Path { init(validatingAbsolutePath path: String) throws { #if os(Windows) + // Explicitly handle the empty path, since retrieving + // `fileSystemRepresentation` of it is illegal. + guard !path.isEmpty else { + throw PathValidationError.invalidAbsolutePath(path) + } + let fsr: UnsafePointer = path.fileSystemRepresentation defer { fsr.deallocate() } @@ -737,6 +744,12 @@ private struct UNIXPath: Path { init(validatingRelativePath path: String) throws { #if os(Windows) + // Explicitly handle the empty path, since retrieving + // `fileSystemRepresentation` of it is illegal. + guard !path.isEmpty else { + throw PathValidationError.invalidRelativePath(path) + } + let fsr: UnsafePointer = path.fileSystemRepresentation defer { fsr.deallocate() } diff --git a/Sources/TSCBasic/Process.swift b/Sources/TSCBasic/Process.swift index 2d717aca..e70c8c66 100644 --- a/Sources/TSCBasic/Process.swift +++ b/Sources/TSCBasic/Process.swift @@ -14,6 +14,7 @@ import var Foundation.NSLocalizedDescriptionKey #if os(Windows) import Foundation +import WinSDK #endif @_implementationOnly import TSCclibc @@ -462,6 +463,13 @@ public final class Process { if localFileSystem.isExecutableFile(abs) { return abs } +#if os(Windows) + if abs.extension != "exe" && abs.extension != "", + case let abs = abs.parentDirectory.appending(component: abs.basename + executableFileSuffix), + localFileSystem.isExecutableFile(abs) { + return abs + } +#endif } return nil } @@ -471,10 +479,28 @@ public final class Process { pathString: ProcessEnv.path, currentWorkingDirectory: cwdOpt ) + var searchPaths: [AbsolutePath] = [] +#if os(Windows) + // NOTE: `CreateProcess` the Windows system API always searchs System and Windows directories first. + // See https://docs.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw#parameters + var buffer = Array(repeating: 0, count: Int(MAX_PATH + 1)) + // The 32-bit Windows system directory + if GetSystemDirectoryW(&buffer, .init(buffer.count)) > 0 { + searchPaths.append(AbsolutePath(String(decodingCString: buffer, as: UTF16.self))) + } + if GetWindowsDirectoryW(&buffer, .init(buffer.count)) > 0 { + let windowsDirectory = String(decodingCString: buffer, as: UTF16.self) + // The 16-bit Windows system directory + searchPaths.append(AbsolutePath("\(windowsDirectory)\\System")) + // The Windows directory + searchPaths.append(AbsolutePath(windowsDirectory)) + } +#endif + searchPaths.append(contentsOf: envSearchPaths) let value = lookupExecutablePath( filename: program, currentWorkingDirectory: cwdOpt, - searchPaths: envSearchPaths + searchPaths: searchPaths ) return value } diff --git a/Sources/TSCBasic/misc.swift b/Sources/TSCBasic/misc.swift index c999bec1..2cea465b 100644 --- a/Sources/TSCBasic/misc.swift +++ b/Sources/TSCBasic/misc.swift @@ -224,6 +224,11 @@ public func lookupExecutablePath( guard let value = value, !value.isEmpty else { return nil } +#if os(Windows) + let isFileName = !value.contains(":") && !value.contains("\\") && !value.contains("/") +#else + let isFileName = !value.contains("/") +#endif var paths: [AbsolutePath] = [] @@ -236,10 +241,15 @@ public func lookupExecutablePath( paths.append(absPath) } - // Ensure the value is not a path. - if !value.contains("/") { + // Only search in PATH if the value is a single path component. + if isFileName { // Try to locate in search paths. paths.append(contentsOf: searchPaths.map({ $0.appending(component: value) })) +#if os(Windows) + if !value.contains(".") { + paths.append(contentsOf: searchPaths.map({ $0.appending(component: value + executableFileSuffix) })) + } +#endif } return paths.first(where: { localFileSystem.isExecutableFile($0) }) diff --git a/Sources/TSCTestSupport/misc.swift b/Sources/TSCTestSupport/misc.swift index 6d58aa17..ece8b255 100644 --- a/Sources/TSCTestSupport/misc.swift +++ b/Sources/TSCTestSupport/misc.swift @@ -9,6 +9,7 @@ */ import func XCTest.XCTFail +import struct XCTest.XCTSkip import class Foundation.NSDate import class Foundation.Thread @@ -61,6 +62,10 @@ public func systemQuietly(_ args: String...) throws { /// from different threads, the environment will neither be setup nor restored /// correctly. public func withCustomEnv(_ env: [String: String], body: () throws -> Void) throws { +#if os(Windows) + // FIXME + throw XCTSkip("Test helper 'withCustomEnv' is known to be broken on Windows") +#endif let state = Array(env.keys).map({ ($0, ProcessEnv.vars[$0]) }) let restore = { for (key, value) in state { diff --git a/Tests/TSCBasicTests/ProcessTests.swift b/Tests/TSCBasicTests/ProcessTests.swift index 1ff03416..ddf6ee78 100644 --- a/Tests/TSCBasicTests/ProcessTests.swift +++ b/Tests/TSCBasicTests/ProcessTests.swift @@ -108,12 +108,13 @@ class ProcessTests: XCTestCase { } func testFindExecutable() throws { + #if !os(Windows) try testWithTemporaryDirectory { tmpdir in // This process should always work. - XCTAssertTrue(Process.findExecutable("ls") != nil) + XCTAssertNotNil(Process.findExecutable("ls")) - XCTAssertEqual(Process.findExecutable("nonExistantProgram"), nil) - XCTAssertEqual(Process.findExecutable(""), nil) + XCTAssertNil(Process.findExecutable("nonExistantProgram")) + XCTAssertNil(Process.findExecutable("")) // Create a local nonexecutable file to test. let tempExecutable = tmpdir.appending(component: "nonExecutableProgram") @@ -124,9 +125,40 @@ class ProcessTests: XCTestCase { """) try withCustomEnv(["PATH": tmpdir.pathString]) { - XCTAssertEqual(Process.findExecutable("nonExecutableProgram"), nil) + XCTAssertNil(Process.findExecutable("nonExecutableProgram")) } } + #else + try testWithTemporaryDirectory { tmpdir in + // Test System32 without .exe suffix. + XCTAssertNotNil(Process.findExecutable("cmd")) + + // Test Windows with .exe suffix. + XCTAssertNotNil(Process.findExecutable("explorer.exe")) + + // Test non-existant programs. + XCTAssertNil(Process.findExecutable("nonExistantProgram")) + XCTAssertNil(Process.findExecutable("")) + + // Copy an executable file to test. + let tempExecutable = tmpdir.appending(component: "executableProgram.exe") + try localFileSystem.copy(from: Process.findExecutable("cmd")!, to: tempExecutable) + + // Create a non-executable file to test. + let tempNonExecutable = tmpdir.appending(component: "program.bat") + try localFileSystem.writeFileContents(tempNonExecutable, bytes: """ + @echo off + exit + + """) + + try withCustomEnv(["Path": tmpdir.pathString]) { + XCTAssertNotNil(Process.findExecutable("executableProgram.exe")) + XCTAssertNotNil(Process.findExecutable("executableProgram")) + XCTAssertNil(Process.findExecutable("program.bat")) + } + } + #endif } func testNonExecutableLaunch() throws {