Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve finding executable on Windows #175

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion Sources/TSCBasic/Path.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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<Int8> = path.fileSystemRepresentation
defer { fsr.deallocate() }

Expand All @@ -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<Int8> = path.fileSystemRepresentation
defer { fsr.deallocate() }

Expand Down
28 changes: 27 additions & 1 deletion Sources/TSCBasic/Process.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import var Foundation.NSLocalizedDescriptionKey

#if os(Windows)
import Foundation
import WinSDK
#endif

@_implementationOnly import TSCclibc
Expand Down Expand Up @@ -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
}
Expand All @@ -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<WCHAR>(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)
compnerd marked this conversation as resolved.
Show resolved Hide resolved
let value = lookupExecutablePath(
filename: program,
currentWorkingDirectory: cwdOpt,
searchPaths: envSearchPaths
searchPaths: searchPaths
)
return value
}
Expand Down
14 changes: 12 additions & 2 deletions Sources/TSCBasic/misc.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []

Expand All @@ -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(".") {
stevapple marked this conversation as resolved.
Show resolved Hide resolved
paths.append(contentsOf: searchPaths.map({ $0.appending(component: value + executableFileSuffix) }))
}
#endif
}

return paths.first(where: { localFileSystem.isExecutableFile($0) })
Expand Down
5 changes: 5 additions & 0 deletions Sources/TSCTestSupport/misc.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import func XCTest.XCTFail
import struct XCTest.XCTSkip
import class Foundation.NSDate
import class Foundation.Thread

Expand Down Expand Up @@ -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 {
Expand Down
40 changes: 36 additions & 4 deletions Tests/TSCBasicTests/ProcessTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 {
Expand Down