Skip to content

Don't resolve a directory as an executable - #358

Merged
iCharlesHu merged 4 commits into
swiftlang:mainfrom
jakepetroules:fix-executable-directory-false-positive
Jul 29, 2026
Merged

Don't resolve a directory as an executable#358
iCharlesHu merged 4 commits into
swiftlang:mainfrom
jakepetroules:fix-executable-directory-false-positive

Conversation

@jakepetroules

@jakepetroules jakepetroules commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Problem

Executable resolution accepted candidates that can never be executed, on both platforms, because the "can I run this?" check was really an "does this exist / is it searchable?" check.

Unix. access(_, X_OK) returns 0 for any directory the process can traverse — on a directory the execute bit means "searchable", not "runnable". Probed on macOS:

access("docker", X_OK) = 0      // a directory named `docker`
posix_spawn("docker") -> 13     // EACCES — it was never runnable

Windows. GetFileAttributesW succeeds for a directory, and SearchPathW matches directories as well as files. Probed on Windows 11 arm64, with shadow\ containing directories named tool and tool.exe and real\ containing a real tool.exe:

SearchPathW name="tool"     ext=nil    -> E:\probe\shadow\tool       (a directory)
SearchPathW name="tool"     ext=".exe" -> E:\probe\shadow\tool.exe   (a directory)
SearchPathW name="tool.exe" ext=nil    -> E:\probe\shadow\tool.exe   (a directory)
CreateProcessW lpApplicationName=E:\probe\shadow\tool.exe -> failed: 5 (ERROR_ACCESS_DENIED)

Consequences, all reachable today:

  • A PATH entry containing a docker/ (or tool.exe\) subdirectory shadows the real binary: resolveExecutablePath(in:) returns the directory instead of continuing the search.
  • The path handed back can then only fail later. On Unix, running it as .path(...) produces Executable "docker" is not found or cannot be executed., pointing at the executable name rather than at the directory that actually matched.
  • On Windows the spawn-time candidate loop — used when argument 0 is overridden — gets ERROR_ACCESS_DENIED from a directory candidate, which is not in its retry set, so it throws spawnFailed immediately instead of trying the real executable later in the list.

Two things that are not broken, and are therefore untouched here:

  • The Unix spawn loops already recover from a directory candidate: execve fails with EACCES, which is in their retry set.
  • CreateProcessW's own search, which the Windows fast path relies on, already skips directories. Verified:
    CWD = shadow (contains a tool.exe directory), PATH = real (contains a real tool.exe)
    CreateProcessW(nil, "tool.exe") -> created -> E:\probe\real\tool.exe
    CreateProcessW(nil, "tool")     -> created -> E:\probe\real\tool.exe
    

Change

Each platform gets an executableAccessible(_:) helper that answers the question the resolver actually needs, and the resolvers use it.

Unix (Subprocess+Unix.swift) — Configuration.executableAccessible(_:) requires a candidate to be a regular file (S_IFREG) in addition to being executable, and both candidate checks in resolveExecutablePath(withPathValue:) use it. possibleExecutablePaths(withPathValue:), the spawn loops, and .path(_:) are untouched.

mode_t(S_IFMT) rather than a bare &: st_mode is mode_t (UInt16 on Darwin, UInt32 on Linux) while S_IFMT/S_IFREG are octal integer macros that import as Int32 on glibc and musl. Confirmed against the musl 1.2.5 SDK headers (typedef unsigned mode_t;, #define S_IFMT 0170000).

The two checks differ deliberately — regular-file on Unix, not-a-directory on Windows — and both handle symlinks correctly as a result:

  • Unix uses stat, which follows symlinks, so a link is classified by its target: a link to an executable resolves, a link to a directory is rejected, and a dangling link fails the access check. Using lstat would break the first of those.
  • Requiring a regular file also matches what execve accepts, so it is not gratuitously stricter than reality. A FIFO with the execute bit set satisfies access(_, X_OK) and is not a directory, yet posix_spawn rejects it with EACCES — "not a directory" would let it through.
  • Windows has no S_IFREG equivalent, and GetFileAttributesW already reports reparse points by what they point at: REPARSE_POINT alone for a file symlink, DIRECTORY|REPARSE_POINT for a directory symlink or a junction. The directory-bit test therefore covers all three, and file symlinks keep resolving and running.
Unix    realexe      access(X_OK)= 0  stat S_ISREG=1 S_ISDIR=0
        link-to-exe  access(X_OK)= 0  stat S_ISREG=1 S_ISDIR=0  lstat S_ISLNK=1
        link-to-dir  access(X_OK)= 0  stat S_ISREG=0 S_ISDIR=1  lstat S_ISLNK=1
        dangling     access(X_OK)=-1  stat failed
        myfifo       access(X_OK)= 0  stat S_ISREG=0 S_ISFIFO=1  posix_spawn -> 13 (EACCES)

Windows link-to-exe      -> REPARSE_POINT            CreateProcessW -> created
        link-to-dir      -> DIRECTORY|REPARSE_POINT  CreateProcessW -> 5 (ERROR_ACCESS_DENIED)
        junction-to-dir  -> DIRECTORY|REPARSE_POINT

Windows (Subprocess+Windows.swift) — Configuration.executableAccessible(_:) (exists and is not a directory) and Configuration.isDirectory(_:) replace the unused private pathAccessible(_:), which checked existence only. Then:

  • resolveExecutablePath(withPathValue:) rejects a SearchPathW match that is a directory. SearchPathW stops at its first match and cannot be resumed, so on that outcome it falls back to walking possibleExecutablePaths(withPathValue:) — which already replicates CreateProcessW's documented search order — and takes the first candidate that is a real file. Behavior is bit-for-bit unchanged whenever SearchPathW matches a file, including the error it reports when it matches nothing. One consequence worth calling out: SearchPathW is given the PATH value as lpPath, so it searches only those directories, whereas possibleExecutablePaths also covers the app directory and the current directory. In the directory-match case only, resolution can therefore land on a candidate SearchPathW would not have considered. That matches what the spawn path already does on Windows, and it is the search order this file documents; Executable.name(_:) searches the current working directory before PATH — decide and document the contract #357 covers whether that order is the one we want.
  • the arg0-override spawn loop treats ERROR_ACCESS_DENIED as a miss only when the candidate is a directory, and tries the next candidate. Genuine permission failures on a real file still throw spawnFailed as before.

Tests

In SubprocessUnixTests and SubprocessWindowsTests, all PATH-based with no dependency on the process's working directory:

Unix Windows
directory sharing the name, earlier on PATH, is skipped in favor of the real executable testNameResolutionSkipsDirectoryInPathEntry (eager + Subprocess.run) testNameResolutionSkipsDirectoryInPathEntry
a name that is itself a directory path throws instead of resolving testNameThatIsADirectoryPathIsNotResolved testNameThatIsADirectoryPathIsNotResolved
a symlink to a directory is skipped, a symlink to an executable resolves testNameResolutionSkipsSymlinkToDirectory, testNameResolutionFollowsSymlinkToExecutable testNameResolutionClassifiesSymlinksByTarget
the new requirement doesn't weaken the rest of the check testNameResolutionSkipsNonExecutableRegularFile, testNameResolutionSkipsNonRegularFile testSpawnSkipsDirectoryCandidateWithArgumentZeroOverride

Every test above fails before this change and passes after, except testNameResolutionSkipsNonExecutableRegularFile and testNameResolutionFollowsSymlinkToExecutable, which pass in both states by design: the first guards against the access check being replaced by hand-rolled mode-bit logic, the second against stat being swapped for lstat.

Two tests are gated on a capability probe rather than on a platform check, so they skip cleanly where the environment forbids the setup:

  • The Windows symlink test needs SeCreateSymbolicLinkPrivilege, which an unelevated process only holds with Developer Mode enabled → requiresSymbolicLinkPrivilege. It did run and pass on the machine used here.
  • The FIFO test needs mkfifo to succeed in the temporary directory, which it does not on Android (CI reported mkfifo(...) → -1 at the fixture setup) → requiresFIFOCreation. Probing the capability also covers sandboxes elsewhere that deny FIFO creation. I confirmed the trait reports the test as skipped rather than failed when the probe fails, and that the test target still cross-compiles for aarch64-unknown-linux-android24.

On Windows, pre-fix, the failures are exactly as diagnosed:

× testNameResolutionSkipsDirectoryInPathEntry — resolved ...\shadow\test-executable-….exe
× testNameThatIsADirectoryPathIsNotResolved  — an error was expected but none was thrown
× testSpawnSkipsDirectoryCandidateWithArgumentZeroOverride — Failed to launch the new process. Underlying error: win32(5)
× testNameResolutionClassifiesSymlinksByTarget — resolved the directory symlink

Full suites: 160/160 on macOS 26 arm64, 139/139 on Windows 11 arm64 (Swift 6.3.2). swift format lint --strict clean.

Not addressed here

Whether Executable.name(_:) should consult the current working directory at all is a separate, larger question — the spawn path searches it ahead of PATH on both platforms, which contradicts the documented PATH-only contract, while the eager API's behavior differs per platform. That is tracked in #357 with measurements from both platforms and the options; this PR deliberately settles none of it, and none of its tests assert anything about the working directory.

`Executable.name(_:)` resolution accepted any path that `access(_, X_OK)`
succeeded on. On a directory the execute bit means "searchable", not
"runnable", so a directory whose name matched the executable was returned
as a resolved executable path. A `PATH` entry containing a `docker/`
subdirectory, for example, would shadow the real `docker` binary and the
returned path could then only fail at spawn time.

Add `Configuration.executableAccessible(_:)`, which requires a candidate
to be a regular file in addition to being executable, and use it for
every candidate in `resolveExecutablePath(withPathValue:)`.
`GetFileAttributesW` succeeds for a directory and `SearchPathW` matches
directories as well as files, so the Windows resolver had the same
false positive as the Unix one: a `tool.exe` *directory* earlier on
`PATH` shadowed the real `tool.exe`, and `resolveExecutablePath` handed
back a path that can never be run.

Verified on Windows 11 arm64:

  SearchPathW name="tool.exe" ext=nil  -> E:\probe\shadow\tool.exe  (a directory)
  CreateProcessW lpApplicationName=<that directory> -> 5 (ERROR_ACCESS_DENIED)

Add `Configuration.executableAccessible(_:)` (exists and is not a
directory) and `Configuration.isDirectory(_:)`, replacing the unused
private `pathAccessible(_:)`, then:

- `resolveExecutablePath(withPathValue:)` rejects a `SearchPathW` match
  that is a directory. `SearchPathW` cannot be resumed past a match, so
  it falls back to walking `possibleExecutablePaths(withPathValue:)`,
  which replicates the same search order, and takes the first real file.
  Behavior is unchanged whenever `SearchPathW` matches a file.
- the spawn-time candidate loop, which is only used when argument 0 is
  overridden, treats ERROR_ACCESS_DENIED on a directory as a miss and
  tries the next candidate. Genuine permission failures on a real file
  still throw.

`CreateProcessW`'s own search, used by the fast path, already skips
directories, so the fast path needs no change:

  CWD=<dir containing a tool.exe directory>, PATH=<dir with real tool.exe>
  CreateProcessW(nil, "tool.exe") -> created -> E:\probe\real\tool.exe
@jakepetroules jakepetroules changed the title Don't resolve a directory as an executable on Unix Don't resolve a directory as an executable Jul 29, 2026
The Unix check requires a regular file while the Windows one requires
"not a directory", which raises the question of how each treats
symlinks. Measured on both platforms; both are already correct, so this
adds tests rather than changing either check.

Unix uses `stat`, which follows symlinks, so a symlink is classified by
its target: a link to an executable resolves, a link to a directory is
rejected, and a dangling link fails the `access` check.

  realexe      access(X_OK)= 0  stat S_ISREG=1 S_ISDIR=0
  link-to-exe  access(X_OK)= 0  stat S_ISREG=1 S_ISDIR=0  lstat S_ISLNK=1
  link-to-dir  access(X_OK)= 0  stat S_ISREG=0 S_ISDIR=1  lstat S_ISLNK=1
  dangling     access(X_OK)=-1  stat failed

Requiring a regular file rather than merely a non-directory also matches
what `execve` itself accepts. A FIFO with the execute bit set passes
`access(_, X_OK)` and is not a directory, yet cannot be executed:

  myfifo: access(X_OK)=0 S_ISREG=0 S_ISFIFO=1  posix_spawn -> 13 (EACCES)

On Windows `GetFileAttributesW` reports reparse points by what they
point at, so the existing directory bit test is sufficient there:

  link-to-exe      -> REPARSE_POINT              CreateProcessW -> created
  link-to-dir      -> DIRECTORY|REPARSE_POINT    CreateProcessW -> 5 (ERROR_ACCESS_DENIED)
  junction-to-dir  -> DIRECTORY|REPARSE_POINT

Add tests for each case. On Unix, the non-regular-file and
symlink-to-directory tests fail before the fix; the symlink-to-executable
test passes either way and guards against switching `stat` for `lstat`.
The Windows test needs SeCreateSymbolicLinkPrivilege, so it is gated on a
`requiresSymbolicLinkPrivilege` condition trait.
Android CI fails the test at the setup step: `mkfifo` in the temporary
directory returns -1, so the fixture can never be built there.

    ✘ testNameResolutionSkipsNonRegularFile
      Expectation failed: fifo._fileSystemPath.withCString { mkfifo($0, 0o755) } == 0
        → -1

Gate the test on a `requiresFIFOCreation` condition trait that probes
`mkfifo` in the temporary directory, mirroring the
`requiresSymbolicLinkPrivilege` trait the Windows symlink test uses.
Testing the capability rather than checking for `os(Android)` also covers
sandboxes elsewhere that deny FIFO creation.

Also report `errno` when the `mkfifo` inside the test fails, so a future
failure at that line says why.

Verified that the trait skips (rather than fails) when the probe fails,
and that the test target still cross-compiles for
aarch64-unknown-linux-android24.
@iCharlesHu
iCharlesHu merged commit 2ba6e05 into swiftlang:main Jul 29, 2026
45 checks passed
@jakepetroules
jakepetroules deleted the fix-executable-directory-false-positive branch July 29, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants