Windows: is_executable returns true for a directory named git.exe (exists() should be is_file())
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 88/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- rust
- Domain
- operating-systems
Research direction
Start in the Windows implementation in src/lib.rs and review the existing platform tests in tests/tests.rs. Run the Windows test suite, including cases for a directory named with a PATHEXT extension and the current-directory path. Done means directories are rejected while regular files and symlinks retain the documented behavior.
Written by the indexing model from the issue text.
Description
Reported by Asymptotic Tech: a company focusing on scalable formal verification for securing the world's infrastructure software.
Contact: [email protected].
Summary
On Windows, is_executable returns true for a directory whose name ends in .exe, .bat, .cmd or any other PATHEXT extension.
The Windows code first checks that the path exists (src/lib.rs#L60-L63). Then, if the path has an extension, it only checks whether that extension is in PATHEXT (src/lib.rs#L66-L82). It never checks that the path is a file. Path::exists is true for a directory, and Path::extension just looks at the name. So a directory named git.exe passes both checks.
The Unix code does not have this problem. It checks metadata.is_file() (src/lib.rs#L37-L46). That check was added in 56fd034 for #3, with the test !is_executable(".").
The Windows code almost got the same check. #14 (for #13) added if !self.is_file() in 441c1c7, then changed it to exists() twenty minutes later in 82f9c68 "because symlinks are ok". But Path::is_file already follows symlinks. So the change did nothing for symlinks, and it let directories through.
We checked this at commit 5c04d86, which is the current main and the published 1.0.6.
Impact
Tools use this crate to find programs on PATH. This bug makes them treat a folder as a program.
Example: a folder named git.exe sits in a PATH folder before the real git.exe. A tool that uses this crate picks the folder, tries to run it, and fails. The real git never runs.
Anyone who can create a folder in a PATH folder can cause this. That is often easier than adding a program there.
This bug does not run any code. It only gives a wrong answer and makes a program fail to start.
Affected versions
All releases that have the PATHEXT check. 1.0.2 to 1.0.6 have the exists() check from #14. 1.0.1 has the PATHEXT check with no existence check at all, so it has the same problem. We checked the tagged sources at commit 5c04d86 (main, 2026-06-15).
Reproduction
We did not have a Windows machine. Instead, we copied the Windows check from src/lib.rs at 5c04d86 into a small Linux program, without changing it (harness.rs.in and splice.sh below). The only part left out is the GetBinaryTypeW call, which the crate only reaches for paths with no extension. Path::exists, Path::is_file and Path::extension work the same on Linux and Windows, so the copied code gives the same answers. With PATHEXT=.COM;.EXE;.BAT;.CMD;.VBS;.JS;.MSC the program prints:
directory named git.exe -> true (expected false)
regular file real-program.exe -> true (expected true)
symlink link.exe -> real-program.exe -> true (expected true)
missing.exe (does not exist) -> false (expected false)
With the fix below, the directory gives false, and the file and the symlink still give true.
Suggested fix
Use the check that 441c1c7 had:
// First, ensure that the file exists and is a regular file.
if !self.is_file() {
return false;
}
fix.patch below makes that change. test.patch adds two Windows tests to tests/tests.rs: a directory named *.exe is not executable, and . is not executable. With both patches, the crate compiles for x86_64-pc-windows-msvc and the Unix tests pass. We could not run the Windows tests, but your Windows CI can.
One thing we did not change: for a path with an extension, the answer still depends on the name only. An empty file named notes.exe is still "executable". That is how the crate documents PATHEXT, so we left it alone.
fix.patch
diff --git a/src/lib.rs b/src/lib.rs
index 1aa7767..3559005 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -57,8 +57,10 @@ mod windows {
impl IsExecutable for Path {
fn is_executable(&self) -> bool {
- // First, ensure that the file exists
- if !self.exists() {
+ // First, ensure that the file exists and is a regular file.
+ // `is_file` follows symlinks, so a symlink to an executable still
+ // passes; `exists` would also accept a directory named like one.
+ if !self.is_file() {
return false;
}
test.patch — the Windows regression tests (not executed here)
diff --git a/tests/tests.rs b/tests/tests.rs
index fd0a39a..41abd74 100644
--- a/tests/tests.rs
+++ b/tests/tests.rs
@@ -46,6 +46,20 @@ mod windows {
fn non_existent_correct_extension() {
assert!(!is_executable("./tests/non_existent.exe"));
}
+
+ #[test]
+ fn not_executable_directory_with_extension() {
+ let dir = std::env::temp_dir().join(format!("is_executable-dir-{}.exe", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let verdict = is_executable(&dir);
+ std::fs::remove_dir(&dir).unwrap();
+ assert!(!verdict, "a directory named *.exe must not be executable");
+ }
+
+ #[test]
+ fn not_executable_directory() {
+ assert!(!is_executable("."));
+ }
}
#[cfg(any(target_os = "wasi", target_family = "wasm"))]
harness.rs.in — the Linux harness (the crate's Windows block goes at the marker)
// Linux harness for the Windows implementation of `is_executable`.
//
// The body of `windows_is_executable` between the markers is NOT written
// here: run.sh splices it in from the crate's own src/lib.rs (the guard and
// the PATHEXT block of `impl IsExecutable for Path`, `self` renamed to
// `path`), so what runs is the crate's text at the checked commit. The only
// thing left out is the `GetBinaryTypeW` fallback after it, which the crate
// reaches only for paths without an extension; the harness returns false
// there. `Path::exists`, `Path::is_file`, `Path::extension` and the string
// code behave the same on every platform.
//
// Exit 0 when the directory case returns true (defect present), 1 otherwise.
use std::fs;
use std::path::Path;
fn windows_is_executable(path: &Path) -> bool {
// @@CRATE_BODY@@
false
}
fn main() {
let dir = std::env::temp_dir().join(format!("is_executable-harness-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
// A typical Windows value.
std::env::set_var("PATHEXT", ".COM;.EXE;.BAT;.CMD;.VBS;.JS;.MSC");
let evil_dir = dir.join("git.exe");
fs::create_dir(&evil_dir).unwrap();
let real = dir.join("real-program.exe");
fs::write(&real, b"MZ").unwrap();
let link = dir.join("link.exe");
std::os::unix::fs::symlink(&real, &link).unwrap();
let text = dir.join("notes.exe");
fs::write(&text, b"just text").unwrap();
let missing = dir.join("missing.exe");
let no_ext = dir.join("noext");
fs::write(&no_ext, b"MZ").unwrap();
let cases: [(&str, &Path, bool); 6] = [
("directory named git.exe", &evil_dir, false),
("regular file real-program.exe", &real, true),
("symlink link.exe -> real-program.exe", &link, true),
("empty-ish text file notes.exe (extension-only verdict, not fixed here)", &text, true),
("missing.exe (does not exist)", &missing, false),
("noext (no extension; falls through to the stubbed GetBinaryTypeW)", &no_ext, false),
];
let mut dir_verdict = None;
for (name, path, expected) in cases.iter() {
let got = windows_is_executable(path);
println!(" {:<72} -> {:<5} (expected {})", name, got, expected);
if *name == "directory named git.exe" {
dir_verdict = Some(got);
}
}
let _ = fs::remove_dir_all(&dir);
match dir_verdict {
Some(true) => println!("REPRODUCED: a directory named git.exe is judged executable by the Windows logic"),
_ => {
println!("NOT REPRODUCED: the directory is not judged executable");
std::process::exit(1);
}
}
}
splice.sh — splices the block out of src/lib.rs into the harness
#!/usr/bin/env bash
# splice.sh <lib.rs> <harness.rs.in> <out.rs>
# Extract the Windows guard + PATHEXT block from the crate's src/lib.rs (from
# the "First, ensure that the file exists" comment up to the line before the
# "Check using file properties" comment), rename `self` to `path`, and put it
# at the @@CRATE_BODY@@ marker. Fails if the markers are not found exactly once.
set -euo pipefail
LIB=$1; IN=$2; OUT=$3
START=$(grep -n "// First, ensure that the file exists" "$LIB" | cut -d: -f1)
END=$(grep -n "// Check using file properties" "$LIB" | cut -d: -f1)
[[ $(echo "$START" | wc -l) -eq 1 && $(echo "$END" | wc -l) -eq 1 && -n "$START" && -n "$END" ]] || { echo "splice: markers not found exactly once in $LIB" >&2; exit 2; }
BODY=$(sed -n "$((START)),$((END - 1))p" "$LIB")
python3 - "$IN" "$OUT" "$BODY" <<'PY'
import re
import sys
src, out, body = sys.argv[1], sys.argv[2], sys.argv[3]
# `self.` -> `path.` (done here rather than in sed: `\b` is GNU-only)
body = re.sub(r"\bself\.", "path.", body)
s = open(src).read()
assert s.count("// @@CRATE_BODY@@") == 1
open(out, "w").write(s.replace(" // @@CRATE_BODY@@", body))
PY
echo "spliced lines $START-$((END - 1)) of $LIB into $OUT"
- Dominant language
- Rust
- Stars
- 24
- Forks
- 13
- PR merge metrics
- No merged PRs in 30d
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from fitzgen/is_executable
-
Difficulty 3/5 1-2 days Newbie friendliness 35/100
fitzgen/is_executable#12 · 2 comments ·
-
Difficulty 3/5 1-2 days Newbie friendliness 35/100
fitzgen/is_executable#9 · 4 comments · 1 reaction ·
-
Check ownership too?Open
Difficulty 5/5 Over a week Newbie friendliness 28/100
fitzgen/is_executable#2 · 1 comment ·
All issues in fitzgen/is_executable
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 68/100
Maintainers usually reply within 3 days
-
state:triage-needed
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
Maintainers usually reply within 1 day
-
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
Automattic/harper#4503 ·
Maintainers usually reply within 1 day
-
Difficulty 1/5 Under an hour Newbie friendliness 92/100
Maintainers usually reply within 2 days