Skip to content

Executable.name(_:) searches the current working directory before PATH — decide and document the contract #357

Description

@jakepetroules

Summary

Executable.name(_:) is documented as a PATH lookup, but the resolver searches the current working directory as well — and searches it before PATH. The details of which current directory, and at what point it is consulted, differ between the eager resolveExecutablePath(in:) API and the spawn-time search, and between Unix and Windows. Notably the eager API and the spawn path disagree with each other on both platforms, in opposite directions (measurements below).

This is not a crash or a leak; it is an unspecified contract. Whichever behavior we want, developers currently cannot predict it from the documentation, and the two platforms cannot both be satisfied by the same mental model. This issue lays out what the code does today, the precedents, and the options, so we can pick one deliberately: document the current behavior, or change it (ideally uniformly).

Filed as a follow-up to the access(dir, X_OK) directory false-positive fix (#358), which is a separate, narrower bug: a directory named like the executable was accepted as a runnable candidate. That fix does not address, and deliberately does not change, the question below.

The documented contract

Sources/Subprocess/Configuration.swift (Executable.name(_:)):

/// Locates the executable by name.
///
/// Subprocess searches for the executable by name in the directories listed
/// in the `PATH` environment variable, in order, and runs the first match it
/// finds. To run an executable at a known location instead, use ``path(_:)``.
public static func name(_ executableName: String) -> Self

Documentation.docc/GettingStarted.md says the same thing twice: ".name("ls") looks the command up using the PATH" and "it throws a SubprocessError … when Executable/name(_:) finds nothing in PATH".

No mention of the current working directory anywhere.

What actually happens (verified)

Unix

Measured on macOS 26.0 / arm64, Swift 6.4-dev, at eee1243. Setup: a tool script exists only in /tmp/cwdprobe/scratch, which is not on PATH (PATH=/usr/bin:/bin, passed explicitly as Environment.custom).

let env = Environment.custom(["PATH": "/usr/bin:/bin"])

// 1. eager resolve
try await Executable.name("tool").resolveExecutablePath(in: env)

// 2. spawn with an explicit workingDirectory
try await Subprocess.run(
    .name("tool"),
    environment: env,
    workingDirectory: FilePath("/tmp/cwdprobe/scratch"),
    output: .string(limit: 64)
)

Run with the process's own CWD set to /tmp/cwdprobe/scratch:

1. eager resolve (parent CWD has ./tool):      resolved -> tool
2. run(workingDirectory: dir with ./tool):     ran -> CWD-TOOL-RAN

Run again from /tmp, which does not contain ./tool:

1. eager resolve (parent CWD has ./tool):      threw Executable "tool" is not found or cannot be executed.
2. run(workingDirectory: dir with ./tool):     ran -> CWD-TOOL-RAN

Three things fall out of that:

  1. The CWD is searched, and searched first. A ./tool beats every PATH entry.
  2. resolveExecutablePath(in:) returns a bare relative string (tool, not an absolute path) when the CWD match wins — so the "resolved path" is only meaningful while the caller's CWD stays put. Passing it onward as .path("tool") from a different CWD, or into a child with a different workingDirectory, silently resolves somewhere else or fails.
  3. The eager API and the spawn path disagree about which directory "current" means. Eager resolution uses the parent's CWD (access(2) in this process). The spawn path emits the bare name as candidate #0 and the child resolves it after chdir to workingDirectory — so case 2 succeeds even when the parent's CWD has no tool at all. workingDirectory: therefore silently participates in executable resolution, which is documented nowhere.

Windows

Measured on Windows 11 / arm64, Swift 6.3.2. Setup: a real tool.exe (a copy of cmd.exe) exists only in the current directory E:\probe3\cwd; PATH holds the Swift runtime directories plus an empty E:\probe3\empty, so anything found had to come from somewhere other than PATH.

SearchPathW(lpPath: "E:\probe3\empty") name="tool.exe" -> <not found, GetLastError=2>
SearchPathW(lpPath: nil)               name="tool.exe" -> E:\probe3\cwd\tool.exe
CreateProcessW built-in search         "tool.exe"      -> created -> E:\probe3\cwd\tool.exe

So Windows has the same eager-versus-spawn split as Unix, but the other way round:

  1. The spawn path does search the CWD, because it delegates to CreateProcessW, whose documented order puts the app directory and the CWD ahead of PATH. Nothing in Subprocess can change that short of not delegating.
  2. The eager API does not search the CWD in the normal case. SearchPathW only consults the registry-dependent system search path (app dir, CWD, system dirs, PATH) when lpPath is nil; given an explicit lpPath it searches only those directories. Subprocess passes the PATH value, which is non-nil whenever the environment has a PATH — i.e. almost always. So resolveExecutablePath(in:) on Windows is PATH-only in practice, and only falls back to the CWD-inclusive system search path when the environment has no PATH at all.

That makes the cross-platform story less uniform than it first looks: .name("tool") can resolve eagerly on Unix but not on Windows for a ./tool in the CWD, while Subprocess.run finds it on both.

Where it comes from

Unix (Sources/Subprocess/Platforms/Subprocess+Unix.swift, as of eee1243):

  • Executable.resolveExecutablePath(withPathValue:) (~line 370) tests the raw name first: if Configuration.pathAccessible(executableName, mode: X_OK) { return executableName }. Evaluated relative to the process CWD.
  • Executable.possibleExecutablePaths(withPathValue:) (~line 389) seeds the candidate set with the raw name — results.insert(executableName) — before any PATH entry.
  • Both spawn loops iterate that set: Subprocess+Unix.swift ~line 563 and Subprocess+Darwin.swift ~line 224. Candidate #0 is the bare name, exec'd by the child after any chdir.

This diverges from execvp(3), which searches PATH for names without a slash and only treats the argument as a path when it contains a /. . is not on PATH on any modern Unix by default.

Windows (Sources/Subprocess/Platforms/Subprocess+Windows.swift, line numbers as of eee1243; behavior since verified on Windows 11 arm64):

  • possibleExecutablePaths (~line 760) deliberately replicates CreateProcessW's documented lpCommandLine search order, and step 2 of that order is "the current directory for the calling process" (~line 807). The app's own directory is step 1. This is called out in a comment, with the MS doc link — it is intentional, and it matches the platform norm.
  • resolveExecutablePath (~line 695) delegates to SearchPathW, passing the PATH value as lpPath. Because lpPath is non-nil, that search covers only those directories — not the CWD, and not the app directory (see the measurements above). The CWD-inclusive system search path is used only when the environment carries no PATH. Don't resolve a directory as an executable #358 adds one more wrinkle: when SearchPathW matches a directory, resolution now continues over possibleExecutablePaths, which does include the app directory and the CWD, so in that narrow case the eager API can return a CWD match. That is consistent with the spawn-time order, but it is one more reason to settle the contract deliberately.
  • The fast path (~line 79) hands the name to CreateProcessW and lets the OS search, so the OS's CWD behavior applies there whether we like it or not. The candidate loop (used only when argument 0 is overridden) walks possibleExecutablePaths instead, which replicates the same order by hand.

So the spawn-time behavior (CWD ahead of PATH) agrees across platforms, but for opposite reasons: on Windows it's a faithful implementation of the platform contract; on Unix it's an accident that contradicts the platform contract. The eager behavior does not agree at all — Unix searches the CWD first, Windows normally does not search it.

Why it matters

Correctness surprises. The original report that led here: a repo containing a docker/ directory made .name("docker") resolve to that directory instead of the real binary. The directory half of that is fixed in #358, but the general shape remains — a file named git, swift, node, make in a checkout will win over the real tool. Build systems, test harnesses and CLIs run with their CWD set to a user-controlled project directory, which is exactly where collisions live.

Security. Searching the CWD before PATH is the classic dot-in-PATH hazard: it turns "clone this repo and run the tool" into arbitrary code execution, because an attacker who controls a checkout controls what .name("tool") resolves to. On Unix this is not something callers opt into — there is no way to turn it off short of using .path(_:) and doing the PATH walk by hand.

Cross-platform predictability. Since workingDirectory: feeds the spawn-time search on Unix, the same Configuration can resolve to different binaries in the parent and the child. Any per-platform decision here should still be internally consistent.

Prior art

  • POSIX / execvp(3): PATH only; a name containing / is a path. . is not on the default PATH. Shells require ./tool for exactly this reason.
  • Python (verified locally, macOS, PATH=/usr/bin:/bin, ./probetool present and executable): subprocess.run(["probetool"])FileNotFoundError. POSIX subprocess goes through execvp, so no CWD. On Windows it goes through CreateProcess, so CWD is searched — i.e. Python ships the divergence rather than papering over it.
  • Go: os/exec historically resolved relative to the CWD on Windows; Go 1.19 deliberately stopped honoring such resolutions and added exec.ErrDot, citing the security hazard — https://pkg.go.dev/os/exec#hdr-Executables_in_the_current_directory. Precedent for breaking platform-native behavior on purpose.
  • CreateProcessW: app dir, CWD, System32, 16-bit system dir, Windows dir, then PATHhttps://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw

Options

A. Document the current behavior, change no code

Update Executable.name(_:), resolveExecutablePath(in:), and GettingStarted.md to state that the current working directory is searched first, that on Unix the spawn-time search uses the child's workingDirectory, and that resolveExecutablePath(in:) may return a relative path.

  • Pro: zero source-compat risk; no behavior change to reason about; cheapest.
  • Con: documents a security hazard as a feature; leaves .name unusable for callers who need a trustworthy PATH lookup; the eager/spawn CWD split is hard to describe without it reading like a bug report; keeps Subprocess more permissive than execvp, sh, and Python on Unix.

B. PATH-only on Unix (execvp parity); leave Windows as CreateProcessW

Drop the raw-name early return and the raw-name candidate seed; keep names containing / as CWD-relative paths.

  • Pro: each platform matches its own norm, which is what most Swift developers will predict; kills the Unix security hazard; makes resolveExecutablePath always return something absolute (or an explicit relative path the caller wrote); eliminates the workingDirectory-affects-resolution surprise on Unix.
  • Con: the cross-platform contract stays split, so "does .name see my CWD?" is answered "depends"; it is a behavior break for anyone relying on the CWD hit (probably rare and probably accidental, but unversioned).

C. PATH-only on both platforms

Also stop seeding the app directory and the CWD on Windows. The eager Windows API needs almost nothing here — it is already PATH-only whenever a PATH exists — so the work is in possibleExecutablePaths and in no longer letting CreateProcessW run its own search.

  • Pro: one rule everywhere; most predictable; safest; the documented contract becomes true as written.
  • Con: breaks Windows platform expectations — notably "ship a helper .exe next to your app and launch it by name", which works today via step 1 of the CreateProcessW order; the fast path would have to stop delegating to CreateProcessW's built-in search, so every Windows spawn pays the manual candidate loop (a real perf and behavioral change, including PATHEXT handling); most Windows tooling does find things in the app dir, so we would be the odd one out.

D. Make it explicit and opt-in

PATH-only by default (as C), plus an API to request the platform-native search — e.g. Executable.name("tool", search: .pathOnly | .platformDefault), or a Configuration/PlatformOptions knob.

  • Pro: safe default, no capability lost, self-documenting at the call site; callers who genuinely want "act like the shell/CreateProcessW" can say so.
  • Con: new public API surface for a fairly niche need; a policy enum invites "what exactly is in .platformDefault?" questions that we'd have to pin down and document per platform anyway; more to test.

Recommendation

B, with the internal-consistency fixes below, and revisit D if anyone reports a real need for the CWD/app-dir search. B is the smallest change that makes the documented contract true on Unix, aligns with execvp/sh/Python, and removes the hazard; C's cost lands almost entirely on Windows users for uniformity's sake, and Windows' behavior is at least documented and expected there.

Whatever we choose, these should be fixed

Independent of the CWD question:

  1. Eager and spawn-time resolution should agree. On Unix, resolveExecutablePath(in:) consults the parent's CWD while the spawn loop lets the child resolve the bare name after chdir. On Windows the split runs the other way: the eager API is effectively PATH-only while the spawn path searches the app directory and the CWD. Same Configuration, two answers, on both platforms.
  2. resolveExecutablePath(in:) should not return a bare relative name. A "resolved path" that depends on the caller's CWD at use time is a trap, and it is how the original docker/ report turned into a confusing executableNotFound("docker") at spawn time rather than at resolve time.
  3. workingDirectory: should not silently participate in executable resolution unless we say it does.
  4. Tests should pin whichever contract we pick, on both platforms — including the name-contains-a-slash case, which is the part of execvp semantics that must keep working.

Related

  • Don't resolve a directory as an executable #358 — the directory false-positive fix, now covering both platforms: access(dir, X_OK) succeeds for a directory on Unix, and on Windows GetFileAttributesW succeeds for one while SearchPathW matches directories outright. Narrow, and intentionally scoped to not answer this question: each platform gets an executableAccessible(_:) helper (regular file on Unix, "exists and is not a directory" on Windows) that the resolvers use. It leaves the Unix possibleExecutablePaths(withPathValue:), the Unix spawn loops, and the CWD ordering on both platforms exactly as they are. Two Windows-only exceptions worth knowing about here, since they touch code this issue discusses: the arg0-override spawn loop now treats ERROR_ACCESS_DENIED on a directory candidate as a miss instead of failing the spawn, and the eager resolver falls back to walking possibleExecutablePaths when SearchPathW matches a directory (see the Windows note under "Where it comes from"). All of its tests are PATH-based — none assert anything about the working directory — so they will not need revisiting whichever option above we take.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions