Skip to content

enh(File): proper implementation of File::getExecutablePath#5186

Merged
matejk merged 4 commits intomainfrom
5180-proper-get-executable-path
Feb 8, 2026
Merged

enh(File): proper implementation of File::getExecutablePath#5186
matejk merged 4 commits intomainfrom
5180-proper-get-executable-path

Conversation

@matejk
Copy link
Copy Markdown
Contributor

@matejk matejk commented Feb 6, 2026

Summary

  • getExecutablePathImpl() is now a full resolver: on Unix it searches cwd + PATH via findInPath(), then verifies executability using euid/egid stat checks; on Windows it uses SearchPathW (two-call pattern for paths > MAX_PATH) with PATHEXT iteration. Paths containing a directory separator are resolved to absolute via Path::makeAbsolute().
  • canExecuteImpl() on Unix now uses euid/egid stat-based permission checks (consistent with canReadImpl/canWriteImpl) with an S_ISREG guard. Windows checks extension against PATHEXT. VxWorks treats any regular file as executable (no permission bits).
  • absolutePath() no longer searches PATH — it only resolves relative paths against cwd
  • existsAnywhere() reimplemented via shared findInPath() helper, fixed to not search PATH for absolute paths
  • findInPath() extracted into File.cpp — always returns absolute paths via Path::makeAbsolute(), with cached PATH tokenization
  • Performance optimizations:
    • PATH caching: tokenized once and cached with change detection (thread-safe with mutex)
    • PATHEXT caching (Windows): tokenized once at startup (thread-safe by design)
    • Combined stat checks: Unix/VxWorks now check existence and executability in a single stat() call
  • ProcessRunner::start() updated to use getExecutablePath() / canExecute()
  • VxWorks File_VX.cpp synced with Unix/Windows implementations
  • Tests updated: added non-executable file test, directory test, relative path with separator test, non-existent relative path test
  • Documentation: Added note about Windows UNC path limitations with SearchPathW

Follows up on #5180.

Performance Improvements

  • Reduced syscalls: Unix/VxWorks now use 1 stat() call instead of 2 for paths with directory separators
  • Cached PATH parsing: Avoids repeated Environment::get("PATH") and string tokenization (thread-safe)
  • Cached PATHEXT parsing (Windows): One-time initialization eliminates repeated parsing (thread-safe)
  • Thread-safe: All caching implementations are thread-safe for concurrent calls

Bug Fixes

  • Fixed existsAnywhere() incorrectly searching PATH for absolute paths
  • Eliminated TOCTOU race condition in getExecutablePathImpl() by removing redundant existence check

Test plan

  • Build Foundation library and testsuite on macOS, Linux, Windows
  • Run Foundation-testrunner — FileTest and ProcessRunnerTest must pass
  • Verify File("ls").getExecutablePath() returns absolute path like /usr/bin/ls (Unix)
  • Verify File("nonexistent").getExecutablePath() returns empty string
  • Verify non-executable file returns empty from getExecutablePath()
  • Verify directory path returns empty from getExecutablePath()

@matejk matejk added this to the Release 1.15.0 milestone Feb 6, 2026
@matejk matejk requested a review from aleks-f February 6, 2026 14:08
@matejk matejk force-pushed the 5180-proper-get-executable-path branch 2 times, most recently from 0c3342b to d333c0c Compare February 6, 2026 15:49
@matejk matejk force-pushed the 5180-proper-get-executable-path branch 2 times, most recently from ddcfe7a to d79620a Compare February 6, 2026 18:10
@matejk matejk requested a review from aleks-f February 7, 2026 08:44
@aleks-f aleks-f requested a review from Copilot February 7, 2026 09:41
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves executable resolution semantics in Poco::File (and related ProcessRunner behavior) by making getExecutablePath() a resolver (CWD/PATH on Unix; SearchPathW + PATHEXT on Windows), simplifying canExecute(), and aligning tests with the new behavior.

Changes:

  • Reworked File::absolutePath(), File::getExecutablePath(), File::canExecute(), and existsAnywhere() behavior; introduced findInPath() helper.
  • Implemented new platform-specific executable resolution logic on Unix/Windows.
  • Updated ProcessRunner::start() and FileTest expectations to use getExecutablePath()/canExecute().

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
Foundation/testsuite/src/FileTest.cpp Updates tests to validate the new executable-resolution behavior.
Foundation/src/ProcessRunner.cpp Uses getExecutablePath()/canExecute() to validate commands before launching.
Foundation/src/File_WIN32U.cpp Implements Windows executable resolution with SearchPathW and PATHEXT; updates WinAPI calls.
Foundation/src/File_UNIX.cpp Implements Unix executable resolution and simplifies canExecuteImpl() using access(X_OK).
Foundation/src/File.cpp Adds shared findInPath() helper; updates absolutePath(), existsAnywhere(), and canExecute().
Foundation/include/Poco/File.h Updates documentation for the changed absolutePath(), getExecutablePath(), existsAnywhere(), and canExecute() semantics.
Comments suppressed due to low confidence (1)

Foundation/src/ProcessRunner.cpp:276

  • ProcessRunner::start() validates using getExecutablePath()/canExecute(), but on Windows getExecutablePathImpl()/canExecuteImpl() can now accept PATHEXT entries like .CMD/.BAT. Process::launchImpl() uses CreateProcessW, which cannot launch .cmd/.bat directly, so validation may succeed and launch still fails. Either restrict Windows canExecute/getExecutablePath to CreateProcess-launchable extensions (e.g. .exe/.com) or add explicit handling to run scripts via cmd.exe /c.
		std::string execPath = File(_cmd).getExecutablePath();
		if (execPath.empty())
		{
			throw Poco::FileNotFoundException(
				Poco::format("ProcessRunner::start(%s): command not found", _cmd));
		}
		else if (!File(execPath).canExecute())
		{
			throw Poco::ExecuteFileException(
				Poco::format("ProcessRunner::start(%s): cannot execute", _cmd));
		}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 747 to 751
// Test 5: Non-executable file returns empty for bare name
{
File f("subdir/myexecutable");
std::string execPath = f.getExecutablePath();
#if defined(POCO_OS_FAMILY_WINDOWS)
// Path::toString() returns native separators (backslashes on Windows)
assertEqual (R"(subdir\myexecutable.exe)", execPath);
#else
assertEqual ("subdir/myexecutable", execPath);
#endif
File f("nonexistent_file_xyz_12345");
assertTrue (f.getExecutablePath().empty());
}
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test 5 comment says "Non-executable file" but the test uses a clearly non-existent filename, so it doesn’t exercise the non-executable-path behavior. Either adjust the comment to match (non-existent) or create a real non-executable file (e.g. temp file without +x on Unix / a .txt on Windows) and assert getExecutablePath() returns empty for it.

Copilot uses AI. Check for mistakes.
Comment on lines +89 to +90
return existsImpl() ? _path : std::string{};

Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FileImpl::getExecutablePathImpl() returns _path for any existing path containing '/', without verifying executability or returning an absolute path for relative inputs (e.g. "./tool"). This contradicts File::getExecutablePath() docs (executable + fully resolved absolute path) and can return directories or non-executable files. Resolve to an absolute path and validate with canExecuteImpl() (or access(X_OK) + S_ISREG) before returning; otherwise return empty.

Suggested change
return existsImpl() ? _path : std::string{};
{
Poco::Path p(_path);
if (!p.isAbsolute())
p.makeAbsolute();
const std::string exePath = p.toString();
struct stat st;
if (::access(exePath.c_str(), X_OK) == 0 &&
::stat(exePath.c_str(), &st) == 0 &&
S_ISREG(st.st_mode))
{
return exePath;
}
return {};
}

Copilot uses AI. Check for mistakes.
Comment on lines +41 to +65
std::string findInPath(const std::string& name)
{
Path curPath(Path::current());
curPath.append(name);
if (File(curPath).exists())
return curPath.toString();

const std::string envPath = Environment::get("PATH", "");
if (envPath.empty()) return {};

const std::string pathSeparator(1, Path::pathSeparator());
const StringTokenizer st(envPath, pathSeparator,
StringTokenizer::TOK_IGNORE_EMPTY | StringTokenizer::TOK_TRIM);

for (const auto& dir : st)
{
try
{
std::string candidate(dir);
if (candidate.size() && candidate.back() != Path::separator())
candidate.append(1, Path::separator());
candidate.append(name);
if (File(candidate).exists())
return candidate;
}
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findInPath() returns the first existing match in CWD/PATH and may return a relative path when PATH contains relative entries. When used by getExecutablePathImpl() this can produce non-absolute and/or non-executable results. Consider either (1) making the returned candidate absolute (Path(candidate).makeAbsolute()) and (2) providing a separate helper (or predicate) for "executable" resolution so getExecutablePathImpl() can filter out non-executable matches without changing existsAnywhere() semantics.

Copilot uses AI. Check for mistakes.
Comment on lines +99 to +108
// First, try the filename as-is
{
return _path;
WCHAR buf[MAX_PATH];
DWORD len = ::SearchPathW(NULL, uname.c_str(), NULL, MAX_PATH, buf, NULL);
if (len > 0 && len < MAX_PATH)
{
std::string result;
UnicodeConverter::toUTF8(buf, len, result);
return result;
}
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windows getExecutablePathImpl(): the initial SearchPathW(NULL, name, NULL, ...) can return a match for non-executable files when the caller passes an absolute/relative path with an extension not in PATHEXT (e.g. C:\Windows\win.ini). This violates the getExecutablePath() contract of returning an executable match or empty. After SearchPathW succeeds, validate the resolved path with canExecuteImpl() (PATHEXT membership) and return empty if it’s not executable.

Copilot uses AI. Check for mistakes.
Comment on lines +101 to +123
WCHAR buf[MAX_PATH];
DWORD len = ::SearchPathW(NULL, uname.c_str(), NULL, MAX_PATH, buf, NULL);
if (len > 0 && len < MAX_PATH)
{
std::string result;
UnicodeConverter::toUTF8(buf, len, result);
return result;
}
}
return p.setExtension("exe"s).toString();

// Try each PATHEXT extension via SearchPathW
const std::string pathExt = Environment::get("PATHEXT", ".EXE");
const StringTokenizer st(pathExt, ";",
StringTokenizer::TOK_IGNORE_EMPTY | StringTokenizer::TOK_TRIM);

for (const auto& ext : st)
{
std::wstring wExt;
UnicodeConverter::toUTF16(ext, wExt);
WCHAR buf[MAX_PATH];
DWORD len = ::SearchPathW(NULL, uname.c_str(), wExt.c_str(), MAX_PATH, buf, NULL);
if (len > 0 && len < MAX_PATH)
{
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Windows getExecutablePathImpl() truncates resolution to MAX_PATH; if SearchPathW returns a required length >= MAX_PATH, the function currently falls through and returns empty even though the executable exists. Consider a two-call pattern: call SearchPathW with 0/NULL to get required length, then allocate a buffer of that size (+1) and call again.

Copilot uses AI. Check for mistakes.
}
else handleLastErrorImpl(_path);
return false;
return ::stat(absolutePath.c_str(), &st) == 0 && S_ISREG(st.st_mode) && ::access(absolutePath.c_str(), X_OK) == 0;
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UNIX canExecuteImpl() now uses ::access(X_OK), which checks permissions using the real UID/GID, while canReadImpl()/canWriteImpl() use effective IDs (geteuid/getegid). This changes behavior for setuid/setgid processes and is inconsistent within FileImpl. Consider keeping the prior euid/egid-based permission logic, or using faccessat(..., AT_EACCESS) where available with a fallback.

Suggested change
return ::stat(absolutePath.c_str(), &st) == 0 && S_ISREG(st.st_mode) && ::access(absolutePath.c_str(), X_OK) == 0;
if (::stat(absolutePath.c_str(), &st) != 0 || !S_ISREG(st.st_mode))
return false;
uid_t euid = ::geteuid();
gid_t egid = ::getegid();
// root can execute any regular file that has at least one execute bit set
if (euid == 0)
return (st.st_mode & (S_IXUSR | S_IXGRP | S_IXOTH)) != 0;
if (st.st_uid == euid)
return (st.st_mode & S_IXUSR) != 0;
else if (st.st_gid == egid)
return (st.st_mode & S_IXGRP) != 0;
else
return (st.st_mode & S_IXOTH) != 0;

Copilot uses AI. Check for mistakes.
@aleks-f
Copy link
Copy Markdown
Member

aleks-f commented Feb 7, 2026

@matejk see copilot suggestions, some make sense

@matejk matejk force-pushed the 5180-proper-get-executable-path branch 2 times, most recently from b1011b5 to 3c34255 Compare February 7, 2026 14:09
@matejk matejk force-pushed the 5180-proper-get-executable-path branch from 3c34255 to a6950e5 Compare February 7, 2026 14:11
@matejk
Copy link
Copy Markdown
Contributor Author

matejk commented Feb 7, 2026

@matejk see copilot suggestions, some make sense

They really do.

@matejk matejk requested a review from aleks-f February 7, 2026 14:14
@matejk matejk force-pushed the 5180-proper-get-executable-path branch from a6950e5 to 5d320ef Compare February 7, 2026 15:35
@matejk matejk force-pushed the 5180-proper-get-executable-path branch from 5d320ef to 7532504 Compare February 7, 2026 16:09
@matejk matejk merged commit 3a77087 into main Feb 8, 2026
100 checks passed
@matejk matejk deleted the 5180-proper-get-executable-path branch February 8, 2026 09:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants