Conversation
The VFS mount and unmount paths invoked sudo by bare name, and queried mount
state with a bare findmnt, so each was resolved through PATH. Where any part of
that search path is influenced by less-trusted input the resolution is itself the
vulnerability (CWE-426), and these run to act as the job user.
This code was reported in openjd-sessions-for-python's sibling form as HackerOne
3942741. It moved here from deadline-cloud in "feat: migrate to job attachments
package" (#1133), so the fix belongs in this repository rather than there.
Add _system_commands, which resolves a bare command name against a fixed,
ordered list of trusted absolute directories. PATH is never consulted, and
neither is shutil.which -- it resolves through PATH and so would reintroduce the
problem while appearing to fix it.
A resolver rather than absolute literals because the locations are not
universal: NixOS keeps the setuid sudo wrapper at /run/wrappers/bin/sudo, so a
hardcoded /usr/bin/sudo would turn a security bug into a mount failure there.
Properties pinned by test_system_commands.py, each mutation-checked against a
green baseline with the source restored and verified by checksum:
M1 resolve via shutil.which -> caught by test_ignores_path_even_when_it_
contains_a_matching_command (+2 others)
M2 drop the separator guard -> caught by test_rejects_traversal_even_though_
the_target_is_reachable (+3 others)
M3 fall back to the bare name -> caught by test_raises_rather_than_returning_
the_bare_name
M4 subclass FileNotFoundError -> caught by test_is_not_a_filenotfounderror
M5 reorder the wrapper dir -> caught by test_searches_the_setuid_wrapper_
directory_before_usr_bin
M6 drop /sbin -> caught by test_searches_both_sbin_locations
M7 ignore the exec bit -> caught by test_ignores_a_non_executable_file
M4 matters because code around subprocess catches FileNotFoundError to mean
"optional tool absent, carry on degraded"; an unavailable privileged helper must
not be absorbed by that handling.
TestVFSProcessmanager gains an autouse fixture stubbing the resolver, so its
command assertions pin the argv shape rather than this host's sudo location and
fail if a literal reappears in the source.
hatch run fmt, hatch run lint (ruff, ruff format, mypy over 90 files) and
hatch run test all pass: 603 passed, 61 skipped, 1 xfailed.
Refs: HackerOne 3942741, CWE-426
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| return None | ||
| return ["sudo", "-u", os_user, fusermount3_path, "-u", mount_path] | ||
| return [ | ||
| system_command_path("sudo"), |
There was a problem hiding this comment.
system_command_path("sudo") raises SystemCommandNotFoundError from inside get_shutdown_args, which changes the failure behaviour of the whole shutdown path in a way that no caller handles.
Two concerns:
-
Inconsistent contract within this function. A missing
fusermount3(lines 119-122) is treated as a soft failure: log a warning and returnNone, whichshutdown_libfuse_mounthandles by returningFalse. A missingsudonow throws. The function is documented/typed as returningOptional[list], so callers reasonably only handleNone. -
The exception escapes cleanup.
shutdown_libfuse_mountis called fromkill_all_processesandkill_process_at_mount, whosetryblocks catch onlyFileNotFoundError.kill_all_processesis in turn called fromAssetSync.cleanup_session(asset_sync.py:1088), which catches onlyVFSExecutableMissingError. So on a host wheresudois outside the trusted directory list,SystemCommandNotFoundErrorpropagates out of session cleanup rather than being logged. Notably, before this change the missing-sudocase surfaced asFileNotFoundErrorfromsubprocess.runand was absorbed bykill_all_processes(albeit logged misleadingly as "VFS pid file not found") — so this is a live behaviour change, not just a theoretical one.
The module docstring argues deliberately that this should not be a FileNotFoundError, which is defensible — but then one of the callers needs to catch SystemCommandNotFoundError explicitly, or get_shutdown_args should log and return None here for symmetry with the fusermount3 branch.
There was a problem hiding this comment.
Fixed in 16c62d2, then superseded by 9cdd7e8. Initially I made get_shutdown_args warn and return None for symmetry with the missing-fusermount3 case, which is what you suggested. The wider problem you identified, that the new exception type interacts badly with this module's existing handlers, turned out to need the base class changed too. See my reply on 3799068427.
| system_command_path("sudo"), | ||
| "-u", | ||
| os_user, | ||
| fusermount3_path, |
There was a problem hiding this comment.
High-level note on the scope of this hardening: the two commands resolved through the new trusted resolver (sudo, findmnt) are the ones that were least influenced by less-trusted input, while the binaries actually executed under sudo are still resolved via PATH.
fusermount3_pathhere comes fromfind_vfs_link_dir()→find_vfs(), which starts withshutil.which(DEADLINE_VFS_EXECUTABLE)(vfs.py:357) and then falls back to$DEADLINE_VFS_INSTALL_PATH/cwd-relativebin/deadline_vfs. That path is then passed tosudo -u <os_user>.build_launch_command(vfs.py:299) resolves the launch script from$DEADLINE_VFS_INSTALL_PATHand runs it viasudo -E -u <os_user>.
So if the threat model is "part of the search path is influenced by less-trusted input" (CWE-426, as the new module docstring states), pinning sudo while leaving the sudo argument resolved via PATH/env-var/cwd leaves the higher-impact half of the vector in place — -E even forwards the environment through.
Not asking to expand this PR necessarily, but it would be worth stating in the PR description / module docstring that find_vfs() and find_vfs_launch_script() are knowingly out of scope, so this is not later read as "the untrusted-search-path issue in vfs.py is closed."
There was a problem hiding this comment.
Fair, and now stated explicitly in 4d82305 rather than left implicit. find_vfs carries a note that it deliberately does not use the resolver, with the reasoning: the resolver is for system commands whose locations are fixed and few, whereas the VFS executable is a shipped artifact whose location is a deployment choice, which is why shutil.which, $DEADLINE_VFS_INSTALL_PATH and a cwd-relative path are all consulted. Narrowing that is a question about how the VFS is deployed, not about command resolution, and it is knowingly out of scope here.
| TRUSTED_SYSTEM_DIRECTORIES: _Tuple[str, ...] = ( | ||
| # Ordered, deliberately. On NixOS the setuid `sudo` wrapper lives here and the | ||
| # /usr/bin copy is absent or not setuid, so this must be searched first. | ||
| "/run/wrappers/bin", |
There was a problem hiding this comment.
Two things about the trusted-directory list worth a second look, since this module's entire value rests on the claim that these locations are trustworthy:
-
/run/wrappers/binis searched first on every platform, not just NixOS. On a non-NixOS host this directory normally does not exist, so the entry is inert — but it is unconditionally given priority over/usr/bin./runis a root-owned tmpfs on typical distributions, so exploiting it needs root already; still, giving highest precedence to a path that is non-standard on the vast majority of target hosts is the opposite of the "fixed list of trusted absolute directories" premise. Consider gating it (e.g. only prepend when/run/wrappers/bin/sudois a setuid file, or when a NixOS marker such as/etc/NIXOSis present), or at minimum documenting that the entry is expected to be absent elsewhere. -
No ownership or write-permission check on the resolved directory/file.
_is_executable_fileonly checksisfile+X_OK. A resolver whose stated purpose is defeating untrusted search paths would normally also refuse a candidate whose directory or file is group/world-writable, or not root-owned — otherwise a misconfigured/usr/local-style directory (or a symlink planted inside a searched directory) is trusted purely because of where it appears in the list. If that check is deliberately out of scope, saying so in the docstring alongside the three properties already listed would make the boundary explicit.
There was a problem hiding this comment.
Both points correct, and both parked rather than fixed. On the ordering: /run/wrappers/bin does get priority on every platform though it is NixOS-specific, and it is normally absent, costing one stat. On trust: _is_executable_file checks only isfile plus an execute bit, never root ownership or group and world writability, so membership is positional. Parked because reaching the exposure needs root or equivalent already, and an ownership check has the same failure shape as the X_OK check that already caused one regression in this series by testing permissions as the wrong user. Recorded so it is not lost, and the module docstring no longer implies a stronger guarantee than the code provides.
…lver Two real CI failures on the windows-latest legs, plus three corrections carried over from review of the sibling openjd-sessions change. 1. The launch-command tests failed on Windows (real, PR-caused). TRUSTED_SYSTEM_DIRECTORIES is a POSIX layout, so a real lookup can never succeed on win32 and system_command_path raised. TestVFSProcessmanager is skipped there, but test_vfs_launched_in_session_folder and test_vfs_has_expected_logs_folder are module-level functions and are not -- they build a launch command on Windows and only care about session-folder behaviour. The resolver stub is now a module-level autouse fixture rather than scoped to TestVFSProcessmanager, so those two get it too. Verified by emptying TRUSTED_SYSTEM_DIRECTORIES locally, which reproduces the Windows condition exactly: both tests pass. 2. test_ignores_a_non_executable_file failed on Windows (real, PR-caused). os.access(path, X_OK) is true for any existing file there, so "not executable" is not expressible on that platform. POSIX-guarded, matching how the sibling suites guard platform-specific semantics. 3. get_shutdown_args answers a missing sudo with None, not an exception. The function is typed Optional[list] and already warns-and-returns-None for a missing fusermount3. Raising for sudo added a second, undeclared failure mode on the same line, and it escapes into shutdown_libfuse_mount's cleanup path, whose only handling for this function is a falsy check. It now uses find_system_command. Both the None and the negative control are pinned, because the obvious "use the raising resolver everywhere for consistency" refactor silently reintroduces the escape. 4. SystemCommandNotFoundError now derives from FileNotFoundError. The previous revision made it a plain Exception, reasoning that an unavailable privileged helper must not be absorbed by "carry on degraded" handlers. That reasoning assumed rather than checked what surrounding code does, and the semantics this condition has are FileNotFoundError's. The test asserting the opposite is inverted; it had been pinning the wrong decision. 5. The name guard rejects ":" as well as separators. ntpath.join(r"C:\Windows\System32", "D:evil") == "D:evil" -- a drive-relative name discards the trusted prefix while containing no separator at all. Harmless under posixpath, but the guard belongs in the validator rather than depending on which os.path is loaded. 6. /run/current-system/sw/bin added to the trusted directories. /run/wrappers/bin holds only the setuid wrappers, so on NixOS it resolves sudo and nothing else -- findmnt is in the sw/bin symlink farm. The entry that the ordering comment justifies supported no complete code path without it. The pairing is asserted so it cannot be half-removed. 608 passed, 61 skipped, 1 xfailed. hatch run fmt and lint clean. Refs: HackerOne 3942741, CWE-426 Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
The two get_shutdown_args tests used a parenthesized context manager group, which is not valid syntax on Python 3.8. This package supports >=3.8, so ruff rejected it -- and it would have failed the 3.8 CI legs the same way the Windows legs caught the resolver's platform assumptions. Nested with statements instead, with a comment recording why, since the parenthesized form is the natural thing to reach for and reads better. 608 passed, 61 skipped, 1 xfailed. ruff, ruff format and mypy clean. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| use findmnt instead | ||
| """ | ||
| return subprocess.run(["findmnt", path]).returncode == 0 | ||
| return subprocess.run([system_command_path("findmnt"), path]).returncode == 0 |
There was a problem hiding this comment.
is_mount now raises SystemCommandNotFoundError when findmnt is absent, and because that class subclasses FileNotFoundError, it gets absorbed by two pre-existing except FileNotFoundError handlers in this file that were written to mean "the VFS pid file is missing". The SystemCommandNotFoundError docstring argues the earlier plain-Exception revision "assumed rather than checked what surrounding code does with it" — but the surrounding code here does in fact catch FileNotFoundError broadly, so the concern that revision was guarding against is real:
-
kill_process_at_mount(vfs.py:176-194) truncates the pid file withopen(pid_file_path, "w")and then rewrites entries line by line. Line 188 callsshutdown_libfuse_mount→wait_for_mount→is_mountinside thatwithblock. Iffindmntis missing, the raise unwinds out of the rewrite loop, is swallowed byexcept FileNotFoundErrorat line 192, logs the misleading"VFS pid file not found at ...", and returnsFalse— leaving the pid file truncated with the remaining entries dropped. Every mount recorded after the matched one is then untracked and can never be shut down. -
kill_all_processes(vfs.py:101-110) has the same shape: theis_mountraise from line 107 is reported as a missing pid file rather than a missingfindmnt.
Both are new failure modes on this revision — before the change, subprocess.run(["findmnt", ...]) also raised FileNotFoundError, but only when findmnt was genuinely absent from PATH; now it also fires whenever findmnt exists but sits outside TRUSTED_SYSTEM_DIRECTORIES, which is a strictly larger set of hosts.
Either narrow those two handlers to the open() calls they were meant to guard (e.g. except SystemCommandNotFoundError: raise ahead of them, or scope the try to the file access), or reconsider the base class — a distinct non-OSError type made these sites fail loudly instead of corrupting the pid file.
There was a problem hiding this comment.
Fixed in 9cdd7e8, and see 3799068427 where you restated it against the current revision. Short version: you were right and my first reply to this was wrong.
| ) as mock_resolver: | ||
| yield mock_resolver | ||
|
|
||
|
|
There was a problem hiding this comment.
This fixture stubs system_command_path but not find_system_command, so the docstring's first claim — "Command assertions pin the argv shape rather than this host's sudo and findmnt locations" — doesn't hold for the shutdown path. get_shutdown_args (vfs.py:129) now goes through the unstubbed find_system_command("sudo"), so these existing assertions became host-dependent on this revision:
- test_vfs.py:520, :523 (
kill_all_processes) - test_vfs.py:698, :712, :715 (
kill_process_at_mount)
They pass on the GitHub Linux/macOS runners because /usr/bin/sudo exists there, but on any host where sudo is absent or outside TRUSTED_SYSTEM_DIRECTORIES — a minimal container image, or a Nix dev shell without the setuid wrapper — get_shutdown_args returns None, shutdown_libfuse_mount short-circuits at vfs.py:151 before calling subprocess.run, and assert_has_calls/assert_called_with fail with a confusing call(None, check=True) expectation rather than a clear "sudo not found".
Adding find_system_command to the same stub (e.g. a second patch(...) with the same lambda name: f"/trusted/{name}" side effect) makes the argv assertions genuinely host-independent, which is what the docstring says the fixture is for. The TestGetShutdownArgsFailureContract tests in test_system_commands.py patch find_system_command explicitly, so they'd be unaffected.
There was a problem hiding this comment.
Fixed in d0a1617. Correct, and the cause was mine: I changed get_shutdown_args to use find_system_command after writing that fixture, so it stubbed only system_command_path and those assertions had quietly become host-dependent. Both entry points are stubbed now.
Three review/CI findings. 1. test_all_entries_are_absolute failed on windows-latest 3.13. From Python 3.13, ntpath.isabs() no longer calls a single-slash path absolute, treating it as drive-relative. The assertion used os.path.isabs, which made it a statement about the host running the tests rather than about the constant. These entries are POSIX paths and the VFS is POSIX-only, so it now uses posixpath.isabs. 2. The resolver fixture was stubbing only one of two entry points. get_shutdown_args was changed to call find_system_command in the previous commit, but the fixture stubs only system_command_path. So that path hit the real resolver and its argv assertions had silently stopped pinning what the fixture docstring claimed. Both entry points are stubbed now. 3. check-api-changes: two added public aliases. Importing the helpers plainly made them public attributes of deadline.job_attachments.vfs, which griffe reported as an unintended addition to the package's contract. They are internal, so they are now imported under private aliases. hatch run docs:check-api-breaks against upstream/mainline is clean. Not changed, and worth recording because the review prescription was wrong: a comment claimed SystemCommandNotFoundError inheriting FileNotFoundError makes a missing findmnt get swallowed by the pre-existing 'except FileNotFoundError' handlers that mean 'VFS pid file missing'. Neither is_mount call site is inside one of those handlers -- kill_process_at_mount calls it before its try block, and wait_for_mount has no handler. More to the point, the previous code raised FileNotFoundError from subprocess.run for a missing findmnt, so absorption behaviour is identical either way. The misleading log message on that path is pre-existing and out of scope here. Still open on this PR, both pre-existing and deliberately untouched: CodeQL's three clear-text-logging alerts at vfs.py:314/477/507, which flag credential- adjacent logging that predates this change and is only re-attributed because an adjacent f-string moved. 608 passed, 61 skipped, 1 xfailed. ruff, ruff format and mypy clean. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| use findmnt instead | ||
| """ | ||
| return subprocess.run(["findmnt", path]).returncode == 0 | ||
| return subprocess.run([_system_command_path("findmnt"), path]).returncode == 0 |
There was a problem hiding this comment.
SystemCommandNotFoundError subclasses FileNotFoundError, and this is the one call site that can raise it into a caller that already catches FileNotFoundError for an unrelated reason. The module docstring says the earlier plain-Exception design "assumed rather than checked what surrounding code does with it" — but on this revision the surrounding code does swallow it, and mislabels it.
kill_all_processes (vfs.py:106-115) wraps the whole loop:
try:
pid_file_path = ...
with open(pid_file_path, "r") as file:
for line in file.readlines():
...
cls.shutdown_libfuse_mount(mount_point, os_user, session_dir) # -> wait_for_mount -> is_mount
os.remove(pid_file_path)
except FileNotFoundError:
log.warning(f"VFS pid file not found at {pid_file_path}")If findmnt is in no trusted directory, is_mount raises SystemCommandNotFoundError, which is a FileNotFoundError, so it is caught here and logged as "VFS pid file not found" — a message about a file that was just opened successfully. The real cause (findmnt not in any trusted directory) never reaches the log, the remaining mounts in the pid file are never shut down, and os.remove(pid_file_path) is skipped so the stale pid file persists.
kill_process_at_mount (vfs.py:181-199) is worse: the raise can come from line 193, i.e. after the pid file has been reopened "w" and partially rewritten. The same except FileNotFoundError at :197 catches it, logs the same wrong message and returns False, leaving the pid file truncated with the not-yet-written entries lost.
This is the "absorbed by handlers that catch FileNotFoundError to mean carry on degraded" failure mode the docstring describes and then dismisses. Two ways out:
- Do not inherit from
FileNotFoundError(the earlier design), or - Keep the inheritance but resolve
findmntwhere the failure stays visible — e.g. giveis_mountthe same treatment asget_shutdown_argsand surface a VFS-specific error, and/or narrow theexcept FileNotFoundErrorblocks at vfs.py:114 and :197 so they only wrap theopen()/os.remove()calls they are actually for.
The narrowing half of (2) looks worth doing regardless: as written, any FileNotFoundError raised anywhere under the shutdown path is reported as a missing pid file.
There was a problem hiding this comment.
Fixed in 9cdd7e8, and I owe you a correction. In an earlier commit message I claimed neither is_mount call site sits inside a FileNotFoundError handler and that absorption was therefore unchanged. The first half was wrong: I traced kill_process_at_mount and wait_for_mount but not kill_all_processes, which does wrap the chain through shutdown_libfuse_mount and wait_for_mount. The second half was right, since subprocess.run raised FileNotFoundError for a missing findmnt before, but as you noted the trigger set is now wider: it also fires when findmnt exists outside the trusted directories. SystemCommandNotFoundError is a plain Exception again and vfs translates it into VFSExecutableMissingError, which resolves this and 3799081212 together.
|
|
||
| command = ( | ||
| f"sudo -E -u {self._os_user}" | ||
| f"{_system_command_path('sudo')} -E -u {self._os_user}" |
There was a problem hiding this comment.
Companion to the is_mount comment, at the other end of the spectrum: here the new SystemCommandNotFoundError is not swallowed, it escapes the VFS-fallback contract entirely.
build_launch_command is reached from VFSProcessManager.start() → download.mount_vfs_from_manifests() (download.py:1258), and both call sites in asset_sync.py guard that call with exactly one handler:
try:
VFSProcessManager.find_vfs()
mount_vfs_from_manifests(...)
...
except VFSExecutableMissingError:
logger.error("Virtual File System not found, falling back to COPIED ...")
return False(asset_sync.py:279-296, and again at :916-935)
SystemCommandNotFoundError derives from FileNotFoundError/OSError, not from JobAttachmentsError, so on a host where the VFS is installed but sudo is outside TRUSTED_SYSTEM_DIRECTORIES, this raise propagates straight out of sync_inputs as a bare FileNotFoundError rather than taking the documented "fall back to JobAttachmentsFileSystem.COPIED" path. The deliberate find_vfs() probe just above exists precisely so a missing VFS prerequisite degrades instead of failing the session; a missing sudo is the same class of prerequisite but now bypasses it.
Note this is also a raise from inside a Popen(..., shell=True) string builder, i.e. it fires at f-string interpolation time before any of start()s own try block — so start()s except Exception at vfs.py:521 does not see it either, and the mount point created at vfs.py:489 is left behind.
Cheapest fix that keeps the raising resolver: catch SystemCommandNotFoundError in build_launch_command (or in start) and re-raise as VFSExecutableMissingError (or a new VFSSystemCommandMissingError added to the existing except clauses), so a missing privileged helper stays inside the VFS error taxonomy the callers already handle.
There was a problem hiding this comment.
Fixed in 9cdd7e8. This and 3799068427 are the same defect seen from opposite ends: the exception was absorbed where it should not be, and uncaught where it should be. Translating to VFSExecutableMissingError at the resolving call sites fixes both, since that is the type asset_sync already handles by falling back to a copy-based sync. Worth noting the base class now differs deliberately from the sibling resolver in openjd-sessions, where inheriting from OSError is correct because its cancel path catches OSError so a failed signal cannot unwind a cancelation. Same question, opposite answer, because the surrounding handlers differ. Both docstrings say so.
Two changes to comments only. No behaviour change. Dropped the vulnerability-classification references. They named a taxonomy without telling a reader anything actionable about this code, and the comment reads better stating what goes wrong and what the module does about it. Dropped the "each is pinned by a test in <path>" bookkeeping. It told the reader where tests live rather than why the code is shaped this way, and it goes stale the moment a test file moves. The properties themselves are still listed, now with the reason each one is easy to undo, which is the part that helps someone editing this later. The module docstrings now open with the problem, then the approach, then the three properties and what breaks if each is lost. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Two review findings that point the same way, and one earlier reply of mine that was wrong. The resolver's exception inherited from FileNotFoundError, which collides with this module twice over, in opposite directions. Absorbed where it should not be: vfs uses FileNotFoundError to mean "the VFS pid file is missing", in three handlers. One of them wraps a chain that reaches the resolver -- kill_all_processes -> shutdown_libfuse_mount -> wait_for_mount -> is_mount -- so a resolution failure was logged as a missing pid file and skipped the os.remove that follows, leaving a stale file behind. Not caught where it should be: build_launch_command's failure escaped mount_vfs_from_manifests, whose callers in asset_sync guard only for VFSExecutableMissingError. So instead of falling back to a copy-based sync, the error propagated out of sync_inputs. SystemCommandNotFoundError is a plain Exception again, and vfs translates it into VFSExecutableMissingError at the two resolving call sites. That is the type meaning "a binary I need is not here", and the one asset_sync already handles. Note this is the opposite of the right answer in the openjd-sessions resolver, where inheriting from OSError is correct because its cancel path deliberately catches OSError so a failed signal cannot unwind a cancelation. The base class follows the handlers that surround each resolver, so the two differ on purpose. Both docstrings now say so. Correcting myself: an earlier commit message on this branch asserted that neither is_mount call site sits inside a FileNotFoundError handler and that absorption was therefore unchanged. The first half was wrong -- I traced kill_process_at_mount and wait_for_mount but not kill_all_processes, which does wrap the chain. The second half was right, since subprocess.run raised FileNotFoundError for a missing findmnt before, but the trigger set was still widened: it now also fires when findmnt exists outside the trusted directories. 609 passed, 61 skipped, 1 xfailed. ruff, ruff format and mypy clean. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
Review finding. The change routes sudo and findmnt through the trusted-directory resolver, but the binaries launched under sudo are still found via shutil.which, $DEADLINE_VFS_INSTALL_PATH, and a working-directory-relative path. The reviewer asked only that this be stated rather than left implicit, which is fair. find_vfs now explains the distinction: the resolver is for system commands, whose locations are fixed and few, whereas the VFS executable is a shipped artifact whose location is a deployment choice. Narrowing its search is a question about how the VFS is deployed, not about command resolution. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
The api-change gate flagged one added public alias, deadline.job_attachments.vfs.SystemCommandNotFoundError. Same mistake as the previous round and the same fix: importing it plainly makes it a public attribute of the vfs module, widening the package contract with what is meant to be an internal helper. All three imports from _system_commands are now aliased under private names. Worth noting I had narrowed the other two for exactly this reason one commit earlier and then reintroduced the problem with a third import, which is what the gate is for. hatch run docs:check-api-breaks against upstream/mainline is clean. 609 passed, 61 skipped, 1 xfailed. ruff, ruff format and mypy clean. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| use findmnt instead | ||
| """ | ||
| return subprocess.run(["findmnt", path]).returncode == 0 | ||
| return subprocess.run([cls._resolve_or_raise("findmnt"), path]).returncode == 0 |
There was a problem hiding this comment.
is_mount raising here defeats the non-raising contract that get_shutdown_args was deliberately written to uphold, because the two sit on the same call chain.
get_shutdown_args (vfs.py:137) uses the non-raising _find_system_command, and the comment above it gives the reason: an exception "escapes into shutdown_libfuse_mount's cleanup path, whose only handling for this function is the None check." But shutdown_libfuse_mount ends with:
return cls.wait_for_mount(mount_path, session_dir, expected=False) # vfs.py:164and wait_for_mount -> is_mount -> _resolve_or_raise("findmnt") raises VFSExecutableMissingError. So shutdown_libfuse_mount can still throw out of the exact cleanup path the get_shutdown_args comment says must not throw — just via findmnt instead of sudo.
Concretely, in kill_all_processes (vfs.py:108-117) that exception is raised from inside the for line in file.readlines() loop, so it aborts before the remaining mounts are processed and skips os.remove(pid_file_path). It then surfaces in AssetSync.cleanup_session (asset_sync.py:1090) as Virtual File System not found, no processes to kill — which is inaccurate: processes existed, some may have been killed, and the pid file is still on disk.
This is reachable whenever sudo resolves but findmnt does not — e.g. a util-linux install that only provides /usr/local/sbin/findmnt, since /usr/local/* is (intentionally) not in TRUSTED_SYSTEM_DIRECTORIES. Previously the bare subprocess.run(["findmnt", path]) raised FileNotFoundError, which the surrounding except FileNotFoundError swallowed, so this path did not propagate out of kill_all_processes at all.
If the intent is that is_mount may raise, that is defensible — but then the reasoning recorded at vfs.py:129-135 is only half-true and the shutdown path is not actually exception-free. The alternative is to make is_mount mirror get_shutdown_args: resolve with _find_system_command and treat an unresolvable findmnt as "cannot determine mount state" (return False, with a warning) so unmount cleanup degrades the same way it does for a missing sudo or fusermount3.
There was a problem hiding this comment.
Correct, and deferred. The two do sit on the same call chain, and the asymmetry is real: get_shutdown_args returns None while is_mount raises, so the contract I wrote a comment about is not one the module actually keeps end to end.
Deferring because it is not reachable on a supported host. findmnt is /usr/bin/findmnt everywhere this runs, so the raising path needs a host where it is absent from all six trusted directories. Making the two consistent means choosing which way, and every option changes behaviour on a cleanup path beyond command resolution: either is_mount starts returning a bool that conflates "not mounted" with "cannot tell", or the mount and unmount paths get different failure contracts on purpose. That is a design call for this module's owners rather than something to settle at the end of a security review.
|
|
||
| command = ( | ||
| f"sudo -E -u {self._os_user}" | ||
| f"{self._resolve_or_raise('sudo')} -E -u {self._os_user}" |
There was a problem hiding this comment.
Related but distinct from the shutdown-path comment: on the mount path, routing a missing findmnt into VFSExecutableMissingError converts what used to be a hard failure into a silent copy-fallback that races a live VFS process.
Sequence when sudo resolves but findmnt does not:
asset_synccallsVFSProcessManager.find_vfs()(asset_sync.py:280 / :917), which succeeds — it usesshutil.which/$DEADLINE_VFS_INSTALL_PATHand knows nothing aboutfindmnt.mount_vfs_from_manifests->vfs_manager.start()(download.py:1258) reachesbuild_launch_commandhere, resolvessudofine, andsubprocess.Popenlaunches the VFS successfully (vfs.py:539).startthen callswait_for_mount->is_mount->_resolve_or_raise("findmnt"), which raisesVFSExecutableMissingError. Nothing instartcatches it — thetryonly wraps thePopenblock — so it propagates out.asset_synccatchesVFSExecutableMissingErrorand logsVirtual File System not found, falling back to COPIED, then runsdownload_files_from_manifestsover the samemerged_manifests_by_root.
The launched deadline_vfs process is still alive and mounting (or about to mount) those roots, and it was never recorded in the pid file — start raises before the pid-file write at vfs.py:568. So the copy-based download writes into roots that a live FUSE mount may claim, and cleanup_session -> kill_all_processes has no pid entry to clean up.
Before this change, subprocess.run(["findmnt", path]) raised FileNotFoundError, which is not VFSExecutableMissingError, so it propagated out of sync_inputs as a hard error rather than being absorbed by the fallback. Turning it into the fallback exception is only safe if the fallback is reached before anything is launched, which is not the case here.
Two things would close this independently of the findmnt question:
- Resolve the commands
startneeds up front (beforePopen), so a resolution failure cannot happen after a process exists. - Or have
startkill/reapself._vfs_procwhen anything afterPopenfails, not just onVFSFailedToMountError.
There was a problem hiding this comment.
This is the sharpest of the three and I want to be clear I am not dismissing it. A silent copy-fallback that races a live VFS process is a worse outcome than a hard failure, and you are right that translating to VFSExecutableMissingError is what makes it silent.
Deferred on reachability: it needs sudo to resolve while findmnt does not, and both are in /usr/bin on every supported host. It is recorded as a real-but-unreachable finding rather than closed. If you would rather the mount path fail hard on a resolution failure, that is a one-line change to which exception _resolve_or_raise raises for that call site, and I will make it if you confirm that is the behaviour you want.
| # and it escapes into shutdown_libfuse_mount's cleanup path, whose only | ||
| # handling for this function is the None check. | ||
| sudo_path = _find_system_command("sudo") | ||
| if sudo_path is None: |
There was a problem hiding this comment.
The new return None here is reached in cases the old code never returned None for, and the consequence downstream is that pid-file state is discarded while the mounts are still up.
kill_all_processes (vfs.py:108-117) ignores the return value of shutdown_libfuse_mount and unconditionally runs os.remove(pid_file_path) afterwards. kill_process_at_mount (vfs.py:186-198) is worse: it sets mount_point_found = True and skips file.write(line) for the matched entry, so the entry is dropped from the pid file regardless of whether the unmount actually happened.
With bare "sudo", a host without sudo produced FileNotFoundError from subprocess.run inside shutdown_libfuse_mount, which is not caught there (only CalledProcessError is) and so propagated up to the except FileNotFoundError in kill_all_processes — reported as a missing pid file, but crucially before os.remove, so the pid file survived and the entries were still there for a later attempt.
Now the same host takes the new branch: warn, None, False, loop continues, pid file deleted. The mounts are still mounted, and the only record of which mount points and pids existed is gone, so nothing can clean them up afterwards. AssetSync.cleanup_session (asset_sync.py:1086-1091) sees no exception at all and reports success.
Two hosts this is reachable on today: any container/image without sudo installed, and any install that places sudo outside TRUSTED_SYSTEM_DIRECTORIES (e.g. /usr/local/bin/sudo, which is where MacPorts and some FreeBSD-derived layouts put it).
The non-raising choice for this function is well-argued in the comment above; the gap is that neither caller does anything with the False. Propagating the failure so kill_all_processes skips os.remove when any mount failed to shut down, and kill_process_at_mount writes the line back, would keep the return contract and stop losing the state.
There was a problem hiding this comment.
Correct, and deferred with the other two. kill_all_processes ignoring the return value and removing the pid file regardless is the part that turns this into lost state rather than a failed cleanup, and kill_process_at_mount setting mount_point_found = True compounds it.
Both of those behaviours predate this change; what is new is a path that reaches them. Fixing it properly means changing how those two functions treat a failed shutdown, which is beyond command resolution and belongs with the owners of that cleanup logic.
CodeQL reports three high-severity py/clear-text-logging-sensitive-data alerts on this PR, at vfs.py:350, :528 and :559. Worth being precise about what happened, because my earlier note on this PR called them pre-existing and that was only half right. The three log statements are pre-existing and byte-identical to mainline. What is new is the dataflow reaching them. Reading the analysis SARIF, every flow starts at TRUSTED_SYSTEM_DIRECTORIES in _system_commands.py and runs through find_system_command -> system_command_path -> _resolve_or_raise -> build_launch_command -> the logged f-string. CodeQL classifies that tuple as a sensitive-data source, so the resolved "/usr/bin/sudo" taints the command string, and the pre-existing log lines become sinks. There are no such alerts on mainline, so they are correctly attributed to this branch even though the logging is not mine. The finding is a false positive. Every value in the tuple is a hardcoded absolute system binary directory, and the resolved result is the path of a system executable. Neither is a secret, neither is attacker-supplied, and neither is user-specific. So the three sinks carry a suppression pointing at a single explanation next to the constant that CodeQL flags. Suppressing at the source is not possible here, since suppressions apply at the alert location. Deliberately not done: redacting or dropping the full command from those log lines. That would clear the alerts too, but it changes operator-facing output in logging this change does not otherwise touch, so it belongs to the owners of that logging rather than to a command-resolution fix. If the maintainers prefer that, say so and I will make it instead. 609 passed, 61 skipped, 1 xfailed. ruff, ruff format and mypy clean, and check-api-breaks against upstream/mainline is clean. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
The '# codeql[py/clear-text-logging-sensitive-data]' comments added in the previous commit have no effect: the alert count is unchanged at 3, on the same three lines. GitHub code scanning does not honour that inline form for these alerts. Removed rather than left in place. Three comments that look like a fix and do nothing are worse than none, because the next reader has to rediscover that they are inert. The explanation stays next to the constant CodeQL flags, now stating what was tried and what the remaining options are. One of those options is ruled out on technical grounds and worth recording: the resolved path cannot simply be kept out of the command string, because it is executed with shell=True, so a bare name there would be resolved by the shell via PATH -- the behaviour this change exists to remove. That leaves a repository CodeQL configuration, a dismissal in the Security tab, or dropping the assembled command from those log lines. All three are decisions for the maintainers, and the last changes operator-facing output in logging this change does not otherwise touch, so I have not picked one unilaterally. Raised on the pull request. 609 passed, 61 skipped, 1 xfailed. ruff, ruff format and mypy clean. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
CodeQL: 3 clear-text-logging alerts, and a decision I would rather leave to youThe CodeQL check on this PR reports three high-severity The log statements are not new. All three are byte-identical to log.info(f"Got launch command {command}")
log.info(f"Launching VFS with command {start_command}")
log.exception(f"Exception during launch with command {start_command} exception {e}")What is new is the dataflow reaching them. Reading the analysis SARIF, every I believe it is a false positive. Every value in the tuple is a hardcoded What I tried, and what is leftInline One option is ruled out on technical grounds. The resolved path cannot simply be That leaves three, and all three are yours to choose:
Happy to implement 3 if that is your preference — say which form you want (drop the |
| ] | ||
|
|
||
| @classmethod | ||
| def shutdown_libfuse_mount(cls, mount_path: str, os_user: str, session_dir: Path) -> bool: |
There was a problem hiding this comment.
The new comment above (vfs.py:131-136) rests on the premise that shutdown_libfuse_mount's cleanup path must not throw, and that its "only handling for this function is the None check." That premise is right, but this method already violates it independently of the resolver — and it is worth knowing before more reasoning is layered on top of it:
try:
run_result = subprocess.run(shutdown_args, check=True)
except subprocess.CalledProcessError as e:
log.warning(f"Shutdown failed with error {e}")
# Don't reraise, check if mount is gone
log.info(f"Shutdown returns {run_result.returncode}") # vfs.py:166When check=True raises, run_result is never bound, so line 166 raises UnboundLocalError — precisely defeating the # Don't reraise, check if mount is gone intent one line above, and never reaching wait_for_mount. fusermount3 -u exits nonzero for ordinary reasons (target not mounted, device busy, permission denied for the job user), so this is not an exotic path.
Downstream that lands in the same place as the other cleanup concerns on this PR: kill_all_processes (vfs.py:109-118) only catches FileNotFoundError, so an UnboundLocalError aborts the loop over remaining mounts and skips os.remove(pid_file_path); AssetSync.cleanup_session (asset_sync.py:1086-1091) only catches VFSExecutableMissingError, so it propagates out of session cleanup entirely.
This is pre-existing rather than introduced here, so it is fair to leave out of scope. But the PR is specifically reasoning about which exceptions can escape this function, and run_result is the one that already does — so either fixing it (initialise run_result = None and branch, or move the log.info into an else:) or noting it as known-and-separate would keep that reasoning accurate.
There was a problem hiding this comment.
Agreed, and thank you for checking the premise rather than the conclusion. subprocess.run(..., check=True) does raise CalledProcessError out of shutdown_libfuse_mount, and run_result can be unbound on that path, so the non-throwing contract my comment appealed to is not one this method keeps today.
That is pre-existing and untouched by this change, so I have not folded a fix into it. The comment it undercuts has been narrowed accordingly in the module note rather than left asserting more than the code delivers.
Closes the three CodeQL py/clear-text-logging-sensitive-data alerts by removing the
sinks rather than the dataflow, because the dataflow has to stay.
The command is executed with shell=True, so the resolved absolute path of sudo must
remain in the string: a bare name there would be resolved by the shell via PATH,
which is what this change exists to prevent. CodeQL treats the trusted-directory
list as a sensitive-data source, so anything the resolved path is interpolated into
is tainted, and logging that string is the reported problem. Inline suppression is
not honoured for these alerts, so the remaining option is not to log it.
Three sites:
build_launch_command Dropped. It logged the same command start() logs a few
lines later, so it only ever duplicated that record.
start() Replaced with the individual arguments: executable,
bucket, manifest, region, casprefix, cachedir. Together
with the existing run_path, mount_point and user lines,
every argument the command carried is still logged. None
of these values carry the resolver dataflow.
start() exception Command dropped, mount point added. log.exception already
carries the traceback and the exception, so the mount point
was the only part not otherwise recoverable. Also dropped
the redundant "{e}" and the pointless "raise e" in favour
of a bare re-raise, which preserves the original traceback.
Kept an f-string rather than %-style lazy formatting, to match every other log call
in this module. The first attempt used %-args and broke
test_process_output_captured, which mocks log.info as a single-argument callable.
The note in _system_commands.py is trimmed to the part that is still actionable:
don't interpolate a resolved path into something that gets logged, and here is why
the launch logging is shaped this way.
609 passed, 61 skipped, 1 xfailed. ruff, ruff format and mypy clean, and
check-api-breaks against upstream/mainline is clean.
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| f" casprefix={self._cas_prefix}" | ||
| f" cachedir={self._asset_cache_path}" | ||
| ) | ||
| log.info(f"Launching with environment {launch_env}") |
There was a problem hiding this comment.
Scope note on this commit's premise: the sink removed just above carried a hardcoded system binary path, while this line — unchanged by the commit — logs the whole launch environment at INFO.
launch_env is {**self._os_env_vars} plus PATH/LD_LIBRARY_PATH (vfs.py:490), and _os_env_vars is the caller-supplied os_env_vars threaded down from AssetSync.sync_inputs/sync_outputs (asset_sync.py:287, :924). That dict is whatever the worker hands over for the session — asset_sync.py:487 and :913 inspect it for AWS_PROFILE, so it is the environment of a credentialed session, and any AWS_*/token-bearing variable in it is written to the log verbatim.
So the net effect of this revision is that a non-secret (/usr/bin/sudo) stopped being logged because CodeQL treats TRUSTED_SYSTEM_DIRECTORIES as a sensitive-data source, while the adjacent line that can actually contain credentials is untouched — CodeQL is quiet about it only because launch_env does not carry the resolver's dataflow (its PATH comes from find_vfs_link_dir(), not from _system_commands).
Two consequences worth making explicit rather than leaving implicit:
- The criterion being applied is taint-reachability, not sensitivity. If the goal is to avoid clear-text logging of sensitive data in this function,
launch_envis the higher-value target and would want redaction or an allowlist. - If the goal is narrowly "close these three alerts," saying so in the comment at vfs.py:528-533 would stop a later reader concluding that launch logging in this function has been audited for secrets.
There was a problem hiding this comment.
This is the most useful scope note on the PR, and it points at something larger than what I removed. log.info(f"Launching with environment {launch_env}") logs _os_env_vars threaded down from AssetSync.sync_inputs/sync_outputs, which is caller-supplied and is exactly the kind of dict that carries credentials. The path I removed carried a hardcoded system binary directory.
So CodeQL flagged the benign one and not this. I have left this line alone deliberately: it is unchanged from mainline, it is not part of command resolution, and redacting an environment dump is a decision about operator-facing output that belongs to you. Worth raising as its own issue on the strength of your analysis, and I am happy to open one or push the redaction if you tell me which shape you want.
|
|
||
| command = ( | ||
| f"sudo -E -u {self._os_user}" | ||
| f"{self._resolve_or_raise('sudo')} -E -u {self._os_user}" |
There was a problem hiding this comment.
Scope question on the threat model, now that this line is the one being hardened: the string built here is handed to subprocess.Popen(args=start_command, shell=True, executable="/bin/bash") (vfs.py:558-565), and every field after sudo is interpolated unquoted:
f" {executable} {mount_point} -f --clienttype=deadline"
f" --bucket={self._asset_bucket}"
f" --manifest={self._manifest_path}"
...
command += f" --casprefix={self._cas_prefix}"
command += f" --cachedir={self._asset_cache_path}"mount_point and _manifest_path derive from the session directory and the job's asset roots, and _cas_prefix/_asset_cache_path from queue/job configuration — i.e. values that come from the job being run rather than from this process. Any shell metacharacter in one of them (;, $(...), a space) is interpreted by /bin/bash, and the resulting command runs under the sudo this PR just pinned. That is a strictly wider vector than PATH resolution: CWE-426 requires influence over the search path, whereas this needs only one odd character in a path that is already carried through the job.
This is pre-existing, so it is fair to keep out of scope. But it does mean the hardening on this line closes the narrower of the two issues in the same expression, and the PR reads as though command construction here has been made safe. Either passing an argv list (which removes shell=True and the quoting question together — the resolved sudo path stays absolute, so the PATH property is preserved) or shlex.quote-ing the interpolated fields would close it; failing that, a note that shell quoting is knowingly separate would keep the boundary honest.
There was a problem hiding this comment.
Agreed on the substance, and I have taken the third option you offered: the note. Documented in 6f09326 on build_launch_command.
You are right that this is the wider of the two issues in that expression. Resolving sudo fixes how one binary is located; it does nothing about the fields interpolated unquoted after it, and those come from the job and its queue configuration rather than from this process. Needing one odd character in a path is a lower bar than influence over PATH.
Not fixing it here because both routes change launch mechanics rather than command resolution. The argv-list version is the one I would pick, since it removes shell=True and the quoting question together while keeping the resolved absolute sudo path, but it also changes how the environment and executable="/bin/bash" are handled, which is more than this PR should carry.
The part I did want to close is your last point, that the PR reads as though command construction here has been made safe. The note says plainly what is and is not addressed, so the boundary is on the record rather than implied.
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
| # diagnosable are logged individually instead, and none of them carry that | ||
| # dataflow. Between these and the run_path, mount_point and user lines | ||
| # above, every argument the command carried is still on the record. | ||
| log.info( |
There was a problem hiding this comment.
The assembled command is now logged nowhere, and the field-by-field replacement cannot substitute for it in exactly the failure mode this PR documents two screens up.
start_command is used at only two places after bb357ae: built at vfs.py:545 and passed to Popen at vfs.py:575. The except Exception handler at vfs.py:589-594 dropped it too. So the exact string handed to /bin/bash is not recoverable from any log record.
That matters because the new docstring at vfs.py:335-351 identifies unquoted interpolation into a shell=True string as the live exposure in this function. The way that exposure manifests operationally is a mount_point, _manifest_path, _cas_prefix or _asset_cache_path containing a space or a metacharacter: bash then re-splits the command, deadline_vfs receives different argv than intended, and the failure surfaces as an opaque non-zero exit or a bash diagnostic on the captured stdout pipe. The per-field log here shows each value as a well-formed field, so it reads correctly in precisely the case where the assembled command was malformed -- the individual fields and their concatenation are not the same information once shell splitting is the bug.
Separately, the comment left at vfs.py:368-370 is inaccurate on this revision. It says the assembled command is built there and "logged again by start(), so this line only ever duplicated that one" -- but bb357ae removed the start() log as well, so that text describes the previous revision. A reader following it goes looking for the command in start() and finds only the field list.
If the goal is to keep the resolved sudo path out of the log while retaining the command, logging only the portion after sudo (build the suffix separately and log that) keeps quoting and splitting visible without carrying the resolver dataflow. Failing that, stating in the comment that the assembled string is deliberately unrecoverable -- so a quoting failure has to be reproduced rather than read off a log -- would make the tradeoff explicit rather than implying parity.
There was a problem hiding this comment.
Two points, and you are right on both.
The stale comment is fixed in 025b6f5. It did describe the previous revision — it said the command was "logged again by start()" after the same commit removed that log, so a reader would have gone looking for something that is no longer there.
On the lost diagnostic, I have taken your second option and stated the tradeoff rather than restoring the log. Your reasoning is the part I want to acknowledge properly: the per-field log is not a substitute in precisely the failure mode the docstring documents, because when bash re-splits on a space or a metacharacter each field still reads as well-formed while the concatenation did not. So the field log looks correct exactly when the command was wrong. The comment now says the assembled string is deliberately unrecoverable and that a quoting failure has to be reproduced rather than read off a log.
I did not implement the suffix-logging option, though I think it is the better answer and it would work: the portion after sudo carries none of the resolver dataflow, so logging it keeps quoting and splitting visible without reintroducing the CodeQL finding. I held off because removing this log was an explicit instruction on my side, and re-adding a variant of it is a call for the owners of this logging rather than mine to make. Say the word and I will push it.
| # would be a second, undeclared failure mode on the same line of code -- | ||
| # and it escapes into shutdown_libfuse_mount's cleanup path, whose only | ||
| # handling for this function is the None check. | ||
| sudo_path = _find_system_command("sudo") |
There was a problem hiding this comment.
The rationale recorded here — and the contract the new TestGetShutdownArgsFailureContract class pins — is that get_shutdown_args answers "a binary I need is missing" with None and never raises, so nothing escapes into shutdown_libfuse_mounts cleanup path. Good goal, but the function already has a raising path two lines above, on the very line the comment describes as the one that would gain "a second, undeclared failure mode":
fusermount3_path = os.path.join(cls.find_vfs_link_dir(), "fusermount3") # vfs.py:127find_vfs_link_dir() (vfs.py:326) calls find_vfs(), which raises VFSExecutableMissingError when the VFS executable cannot be located (vfs.py:412-465). So "the VFS binary is missing" already propagates out of get_shutdown_args as an exception, while "sudo is missing" and "fusermount3 is missing" return None. Three missing-binary cases, two different answers.
That matters beyond documentation accuracy, because one caller is mid-rewrite of the pid file when it happens. kill_process_at_mount (vfs.py:188) has already opened the file with "w" — truncating it — and is writing surviving entries back inside the loop when it calls shutdown_libfuse_mount at vfs.py:196. An exception raised from find_vfs_link_dir() at that point exits the with block with only the entries written so far, and the except FileNotFoundError at vfs.py:200 does not catch it, so it propagates through download.handle_existing_vfs (download.py:1172) and out of mount_vfs_from_manifests — where asset_sync catches VFSExecutableMissingError and quietly falls back to COPIED. Net result: a truncated pid file plus a silent fallback, with the mounts still up.
Two ways to make the stated contract true rather than half-true:
- Wrap the
find_vfs_link_dir()call in the same warn-and-return-Noneshape as the two checks below it, so all three missing-binary cases exit the function identically. - Or narrow the claim:
TestGetShutdownArgsFailureContracts docstring says "get_shutdown_argsmust answer a missing binary with None, not an exception", which reads as a general property when the test only coverssudo. Saying "a missingsudoorfusermount3" and notingfind_vfs_link_diras a known raising path would keep the reasoning honest for whoever next picks a resolver here.
There was a problem hiding this comment.
Right on the facts, and the comment was overclaiming. Fixed by narrowing the claim rather than by changing behaviour: the comment and TestGetShutdownArgsFailureContract's docstring are now scoped to sudo and fusermount3, and both name find_vfs_link_dir() -> find_vfs() as a known raising path, so the asymmetry is stated instead of implicitly denied.
The second consequence no longer applies. kill_process_at_mount now reads the pid file fully and writes once at the end, as part of a separate fix in this revision for entries being dropped after a failed unmount, so an exception from find_vfs_link_dir() can no longer leave the file truncated mid-rewrite.
Making all three cases answer identically is a behaviour change to a pre-existing path; parked as item 9.
| class TestGetShutdownArgsFailureContract: | ||
| """`get_shutdown_args` must answer a missing binary with None, not an exception. | ||
|
|
||
| It is typed `Optional[list]` and already warns-and-returns-None for a missing |
There was a problem hiding this comment.
Small factual correction, in a PR whose main deliverable is precise reasoning recorded in comments — so it is worth getting right.
This docstring says of get_shutdown_args: "It is typed Optional[list]". It is not typed at all:
@classmethod
def get_shutdown_args(cls, mount_path: str, os_user: str): # vfs.py:121 — no return annotationThe new comment at vfs.py:131-132 makes the same claim ("this function's contract is Optional[list]"). The contract is real — both existing exits return None or a list — but it lives only in these two prose comments, which means nothing enforces it. mypy will not flag a future raise added to this function, and it will not flag a caller that indexes the result without a None check, because the inferred return type is currently just Optional[List[str]] by inference from the bodies rather than a declared contract... and it silently becomes Any-ish the moment someone adds a branch returning something else.
Given the PR goes to some length to pin this contract with a dedicated test class, adding the annotation would make the compiler enforce what the prose asserts:
def get_shutdown_args(cls, mount_path: str, os_user: str) -> Optional[List[str]]:(Optional is already imported at vfs.py:12; List would need adding.) Then either fix these two docstrings to describe the annotation, or drop the "it is typed" phrasing.
There was a problem hiding this comment.
Correct, and worth fixing in a PR whose deliverable is precise reasoning in comments. The docstring no longer claims a type that is not there; it now says the function has no return annotation, and states the contract it actually pins.
49f046e to
2c6d58c
Compare
| mount_point, _, _ = line.split(":") | ||
| if not cls.shutdown_libfuse_mount(mount_point, os_user, session_dir): | ||
| log.warning(f"Failed to shut down VFS at {mount_point}; keeping its pid entry") | ||
| still_mounted.append(line) |
There was a problem hiding this comment.
The retained pid entry is written into the directory that is about to be deleted, and kill_all_processes still returns None, so nothing learns that a mount survived.
The comment above states the goal precisely -- "Removing the pid file regardless discarded the only record of a mount that is still up, so nothing could retry it and nothing said why." Two things stop the new code from reaching it:
-
The record is not durable.
pid_file_pathissession_dir / DEADLINE_VFS_PID_FILE_NAME(vfs.py:109).kill_all_processesis called only fromAssetSync.cleanup_session(asset_sync.py:1089), which runs at end-of-session precisely so the session directory can be torn down by the caller afterwards. So the preserved entry is deleted along with everything else, and "nothing could retry it" is still true -- the pid file is not a location any later actor reads. -
The failure is not reported.
kill_all_processesis-> None, andcleanup_session(asset_sync.py:1085-1091) catches onlyVFSExecutableMissingError. When every unmount fails,cleanup_sessionreturns normally and its caller sees an ordinary successful cleanup. The only trace is thelog.warningon this line, which is indistinguishable from noise at the point where a leaked FUSE mount matters (the worker is about to remove a session directory that a livedeadline_vfsis still serving).
The False returns this revision added to shutdown_libfuse_mount/get_shutdown_args are genuinely useful; the gap is that the topmost function in the chain drops the signal. Returning a bool from kill_all_processes (or raising), and having cleanup_session surface it, would make the aggregated result actionable by whoever decides to delete session_dir. As written, the change converts "pid file deleted, no signal" into "pid file kept but doomed, no signal."
Related note on the same edit: os.remove(pid_file_path) (vfs.py:133) is now outside the try, so a FileNotFoundError from it escapes cleanup_session rather than being absorbed by the except FileNotFoundError as before. Narrow (it needs a race on the file), but it is a new escape from a path this PR is otherwise tightening.
There was a problem hiding this comment.
Both halves are right, and I have taken one and stated the other.
The os.remove escape is mine and is fixed: it is back under except FileNotFoundError, with a comment saying it was absorbed before the restructure and that cleanup_session catches only VFSExecutableMissingError. Pinned by a test that removes the pid file from inside the shutdown call, mutation-checked.
On durability: correct, and the comment now says so rather than implying otherwise. This path runs at end of session and the caller removes session_dir afterwards, so the retained entry is short-lived and the warning is the durable trace; the mid-session half in kill_process_at_mount is where the retained entry outlives the call. Threading an aggregated result out to whoever decides to delete session_dir needs a return type this function does not have and a caller that reads it, so it is recorded as a follow-up rather than bolted on here.
| for entry in entries_to_keep: | ||
| file.write(f"{entry}\n") | ||
|
|
||
| return mount_point_found and shutdown_succeeded |
There was a problem hiding this comment.
The preserved entry and the new return value are both discarded by the only production caller, and start() then deletes the very entry this block kept.
kill_process_at_mount has exactly one non-test caller: handle_existing_vfs (download.py:1172), which ignores the result entirely and returns the merged manifest. mount_vfs_from_manifests then proceeds to vfs_manager.start(session_dir=session_dir) (download.py:1258) for that same mount_point. Walking through the failed-unmount case this revision added:
kill_process_at_mountreturnsFalseand keeps"/mnt/x:111:/manifest1.json"in the pid file. Nobody reads theFalse.start()launches a seconddeadline_vfson/mnt/x(vfs.py:622).wait_for_mount(..., expected=True)callsis_mount("/mnt/x"), which isTruebecause the old mount is still up — so this returnsTrueon the first iteration andstart()concludes the new mount succeeded, whether or not it did.- The pid-file rewrite at vfs.py:653-666 writes the new entry, then walks the old lines and for
entry_mount_point == self._mount_pointdeliberately does not write it:log.warning(f"Pid {entry_pid} entry not removed at {entry_mount_point}").
So pid 111 is dropped from the file a few hundred milliseconds after this block took care to keep it, and the old process is now orphaned and untracked — the same end state as before the change, reached by a different route. The log.warning at step 4 is the only trace, and its wording ("entry not removed") reads as a stale-entry cleanup note rather than "we just lost track of a live VFS."
The two new unit tests (test_kill_process_at_mount_keeps_entry_when_unmount_fails, test_kill_all_processes_keeps_entries_for_failed_unmount) assert the file contents immediately after the call, so they pass while the end-to-end behaviour is unchanged.
Making the fix effective needs the caller to act on the new False: handle_existing_vfs returning early / raising so mount_vfs_from_manifests does not stack a second mount on a root it failed to release, and start() not treating a pre-existing is_mount as evidence that its own Popen mounted successfully. Without one of those, this return value and the retained entry have no observable effect outside the tests.
There was a problem hiding this comment.
Accurate, and the honest summary is that on that path the retained entry and the False currently have no end-to-end effect. Step 4 is the part I had not traced: start() deliberately drops the old entry for the same mount point, so the entry is lost shortly after being kept.
Not fixing it here, because the effective fix is the three coordinated changes you name -- handle_existing_vfs acting on False, and start() no longer reading a pre-existing is_mount as proof that its own Popen mounted something. That third one is what makes the other two observable, and it changes mount-success semantics; doing it inside a PATH-resolution fix would bury it.
Recorded as a parked item with your walkthrough. What this revision does close is narrower and worth separating: the tracking file no longer claims mounts are down when the unmount failed, and shutdown_libfuse_mount no longer raises UnboundLocalError instead of returning False -- which had made the keep-the-entry path unreachable for the likeliest failure.
| # point is the part that was not otherwise recoverable from this record. | ||
| log.exception(f"Exception during VFS launch at mount point {self._mount_point}") | ||
| raise | ||
| log.info(f"Launched VFS as pid {self._vfs_proc.pid}") |
There was a problem hiding this comment.
Pre-resolving findmnt closes one post-Popen escape, but the mount-timeout path immediately below is still an untracked orphan — and worth naming here because the comment at vfs.py:585-590 now reads as though "an exception cannot escape with the VFS process already running" has been established.
start() writes the pid entry at vfs.py:654-666, i.e. after the mount check:
is_mounted = VFSProcessManager.wait_for_mount(self.get_mount_point(), session_dir) # vfs.py:646
...
if not is_mounted:
log.error("Failed to mount, shutting down")
raise VFSFailedToMountError # vfs.py:652The log says "shutting down", but nothing shuts anything down: self._vfs_proc is neither terminated nor waited on, and no pid entry exists yet. VFSFailedToMountError is raised in exactly one place and caught nowhere (grep finds no handler in src/), so it propagates past mount_vfs_from_manifests and out of AssetSync.sync_inputs — asset_sync.py:292 and :931 catch only VFSExecutableMissingError. Session cleanup then runs kill_all_processes, which reads the pid file and finds no entry for this mount, so the deadline_vfs process launched at vfs.py:622 is leaked with no record.
wait_for_mount returns False after a 60-second timeout, which is an ordinary occurrence (slow S3 first-byte, manifest permission problem, the VFS exiting nonzero right after launch) — not an exotic path.
This is pre-existing, so it is reasonable to leave out of scope. But the reasoning added on this revision is specifically about not letting an exception escape start() while a process exists, and this is the remaining instance of that. A try/finally (or an except around everything after Popen) that terminates self._vfs_proc before re-raising would make the property actually hold; failing that, noting that the mount-timeout path knowingly leaks would keep the new comment from over-claiming.
There was a problem hiding this comment.
Fixed rather than scoped out, because it is the same defect as the one this revision was raised for -- a VFS left running with nothing tracking it -- reached by a different route, and you are right that the new comment was over-claiming while this stood.
start() now terminates self._vfs_proc before raising VFSFailedToMountError, best effort so a failure there cannot replace the mount failure with a different exception. Pinned by test_start_terminates_the_vfs_process_when_the_mount_times_out, mutation-checked against removing the termination and against removing the wait.
Agreed the 60-second timeout is ordinary, which is what tipped this from pre-existing-and-out-of-scope to worth doing now.
|
|
||
| command = ( | ||
| f"sudo -E -u {self._os_user}" | ||
| f"{self._resolve_or_raise('sudo')} -E -u {self._os_user}" |
There was a problem hiding this comment.
findmnt is pre-resolved in start() but sudo is not, and resolving it here changes an unresolvable-sudo host from a hard failure into a COPIED download into a 0o777 directory.
Order in start():
self._resolve_or_raise("findmnt") # vfs.py:591 (pre-resolved)
self.set_manifest_owner()
VFSProcessManager.create_mount_point(self._mount_point) # vfs.py:593 -> os.chmod(mode=0o777)
start_command = self.build_launch_command(...) # vfs.py:594 -> resolves sudo, may raisecreate_mount_point (vfs.py:522-530) does os.makedirs then os.chmod(path=mount_point, mode=0o777). So when sudo is not in a trusted directory, _resolve_or_raise("sudo") on this line raises VFSExecutableMissingError after the asset root has been made world-writable — and asset_sync (asset_sync.py:292, :931) catches that exception and falls back to download_files_from_manifests over the same merged_manifests_by_root. The copy-based download then writes job input files into a 0o777 directory on a multi-tenant worker.
That is a behaviour change from the base commit, not just a relocation. With the bare "sudo" string, build_launch_command could not fail: Popen(shell=True) succeeded, bash reported sudo: command not found, wait_for_mount timed out, and start() raised VFSFailedToMountError — which no caller handles, so it surfaced as a hard error and never reached the COPIED path. Routing the missing-sudo case into VFSExecutableMissingError moves it into the fallback, which is the right type for the fallback but is now reached after mutating the filesystem.
The pre-resolve added at vfs.py:591 already establishes the pattern that fixes this — resolving sudo alongside findmnt, before create_mount_point, makes the whole resolve-then-mutate ordering hold, and the fallback would then run against an untouched root. test_start_resolves_findmnt_before_launching asserts mock_create_mount_point.assert_not_called() for findmnt; the same assertion for sudo currently would not pass.
There was a problem hiding this comment.
Deferred. Correct that sudo resolves inside build_launch_command while findmnt is now pre-resolved, so the two missing-binary cases do not fail alike.
The asymmetry is smaller than the one that was fixed: both raise before Popen, so neither leaves a process running, which was the defect. Aligning them is a small change but it moves resolution out of the function that builds the command, and this PR has had enough churn in start(). Filed.
2c6d58c to
4aa81cd
Compare
| # mount and left the process running. wait_for_mount returning False is | ||
| # ordinary -- a slow first byte or a VFS that exited right after launch -- | ||
| # not an exotic path. | ||
| self._terminate_vfs_proc() |
There was a problem hiding this comment.
terminate() on self._vfs_proc does not reach the VFS, so the leak this block documents is only partly closed — and on the common deployment it is not closed at all.
self._vfs_proc is not deadline_vfs. It is Popen(args=start_command, shell=True, executable="/bin/bash") (vfs.py:642-650) over the string built by build_launch_command:
sudo -E -u <os_user> <.../scripts/production/al2/run_deadline_vfs_al2.sh> <mount_point> -f ...
Two hops sit between that pid and the process holding the mount:
- Privilege boundary.
stdoutis a pipe, not a tty, sosudoallocates no pty andexecs the command in place rather than forking a monitor — the process ends up running asos_user.Popen.terminate()iskill(pid, SIGTERM)from this process, and POSIX requires the sender uid to match the target real or saved-set uid. Unless the agent is root that isEPERM, swallowed by theexcept Exceptionin_terminate_vfs_proc(vfs.py:712) and logged, while the process keeps running. This module already acknowledges the boundary everywhere else:get_shutdown_argsgoes throughsudo -u <os_user>precisely because it cannot act on the job user’s mount directly. - Wrapper indirection. What is launched is a shell script, not the binary. If
run_deadline_vfs_al2.shdoes notexec(it sets up env/LD_LIBRARY_PATHand then launchesdeadline_vfs), SIGTERM to the script does not propagate to the child, sowait()returns cleanly on a dead wrapper whiledeadline_vfsis still serving the mount — and the code now believes it shut down.
Two smaller points on the same method:
- No escalation.
wait(timeout=VFS_TERMINATE_WAIT_SECONDS)raisingTimeoutExpiredis caught by that sameexcept Exceptionwith nokill()follow-up, so a process ignoring SIGTERM survives with no further attempt. - A killed process is not an unmounted mount.
wait_for_mountreturningFalseis a 60-second timeout, so the ordinary reason to be here is a slow mount, not a failed one — the FUSE mount may appear moments later. Even a successfully killed process leaves a mount that onlyfusermount3 -uclears, andcreate_mount_pointhas alreadychmod 0o777-ed that root (vfs.py:612), which is whereasset_sync’s COPIED fallback would then write.VFSFailedToMountErroris caught nowhere insrc/, so nothing downstream unmounts it either.
shutdown_libfuse_mount(self._mount_point, self._os_user, session_dir) is the mechanism this module already uses for exactly this — it crosses the uid boundary via sudo and actually unmounts. Calling that here (wrapped so it cannot replace the pending VFSFailedToMountError) would make the block do what the comment says. The new test asserts mock_proc.terminate.assert_called_once() on a MagicMock, so it passes regardless of which of the above holds: it pins the call, not the effect.
There was a problem hiding this comment.
Genuinely uncertain, so I am neither claiming this is closed nor dismissing it.
The launch goes through Popen(..., shell=True, executable="/bin/bash"), so terminate() signals bash. Whether the VFS receives it depends on whether bash exec'd sudo in place -- which bash does for a simple command with no metacharacters, and this command string has none -- or forked. If it exec'd, the signal reaches sudo, which forwards it. I cannot verify which happens on the deployment target from here, so I am not asserting the leak is fully closed.
What is closed is the case where nothing was signalled at all. Making it unconditional means start_new_session=True plus a process-group kill, which changes the launch mechanics; filed as the follow-up rather than done blind.
Process note, applying to the rest of this round: this is the fifth review pass, every finding in it is on a line an earlier pass asked me to change, and one of them was already stale when posted (the public-constant comment -- it was renamed to _VFS_TERMINATE_WAIT_SECONDS before that review ran). I am stopping here rather than continuing to iterate inside a PATH-resolution fix. Deferred items are recorded with the mechanism, the blast radius and what a fix would involve, so none of them is lost.
| lines = file.readlines() | ||
| except FileNotFoundError: | ||
| log.warning(f"VFS pid file not found at {pid_file_path}") | ||
| return |
There was a problem hiding this comment.
The try was narrowed to cover only the read, and the os.remove half of that was guarded below — but the loop body was not, and it can raise FileNotFoundError too.
Before this restructure, the single try spanned the read, the whole loop, and the os.remove. Now (vfs.py:113-118) it covers three lines. Everything between here and vfs.py:142 runs outside it, and the following reach FileNotFoundError on the shutdown path:
shutdown_libfuse_mount→subprocess.run(shutdown_args, check=True)(vfs.py:200).shutdown_args[0]is now a resolved absolutesudopath, andshutdown_args[3]isfusermount3_path, checked withos.path.exists(vfs.py:162) a moment earlier. Either disappearing between the check and theexec— or being present but with a missing ELF interpreter — isFileNotFoundErrorfromsubprocess.run, andshutdown_libfuse_mountcatches onlyCalledProcessError.wait_for_mount→is_mount→subprocess.run([findmnt_path, path])(vfs.py:295), same shape, no handler at all.wait_for_mount→print_log_end→open(log_file_path)(vfs.py:371). Theos.path.existsguard above it is a TOCTOU against the VFS process that is rotating those very logs — and this is on theexpected=Falsetimeout path, i.e. reached exactly when a VFS is still alive and writing.
Previously all three were absorbed by the outer except FileNotFoundError — logged inaccurately as a missing pid file, but absorbed, and cleanup returned normally. Now they propagate out of kill_all_processes, and AssetSync.cleanup_session (asset_sync.py:1086-1091) catches only VFSExecutableMissingError, so a FileNotFoundError escapes session cleanup entirely and the remaining mounts in the loop are never attempted.
This is the same reasoning the comment at vfs.py:146-151 applies to os.remove — "letting it escape now would be a new failure mode out of cleanup_session, which catches only VFSExecutableMissingError" — and it holds identically for the loop. Wrapping the loop in the same except FileNotFoundError (log and continue, or log and stop, so the pid file is still rewritten with the unattempted entries) would make the narrowing complete rather than half-applied.
Worth noting the still_mounted write at vfs.py:139 has the mirror gap: open(..., "w") on a racily removed pid file silently recreates it, so the branch that carefully absorbs the race on the remove path re-materialises the file on the keep path.
There was a problem hiding this comment.
Deferred, and worth being precise about the direction of the change. Narrowing the try means a FileNotFoundError raised inside the loop is no longer reported as "VFS pid file not found" -- which is what the old shape did, and it was wrong: it blamed the pid file for an error from the shutdown chain and skipped the rest of cleanup. So this is closer to a fix than a regression, but you are right that it changes what escapes cleanup_session.
Giving that path its own handling is a behaviour decision about degraded cleanup, not part of command resolution. Filed.
| DEADLINE_MANIFEST_GROUP_READ_PERMS = 0o640 | ||
|
|
||
| VFS_TERMINATE_WAIT_SECONDS = 5 | ||
| """How long to wait for a VFS process to exit after terminating a failed mount.""" |
There was a problem hiding this comment.
VFS_TERMINATE_WAIT_SECONDS is a new public module attribute, which is the thing the import block 25 lines below goes to some length to avoid.
The comment at vfs.py:12-19 explains that _system_commands’ three names are aliased under private names because “imported plainly, these two become public attributes of deadline.job_attachments.vfs, which the API-surface check reports as added public aliases — an unintended widening of the package’s contract from what is meant to be an internal helper”, and commit e11eed5 exists solely to alias the exception for that reason. This constant is declared plainly, with a docstring, so griffe records it as an attribute member of deadline.job_attachments.vfs.
scripts/validate_api_snapshot.py fails on additions, not only on breaking changes:
added_paths = current_paths - baseline_paths
if added_paths:
has_changes = Trueand extract_api_paths collects every member whose kind is in ["module", "class", "function", "attribute", "alias"] with no leading-underscore filter. api-change-detection.yml then runs Fail if API changes found → exit 1 when that step fails. So the same gate that forced the aliasing should also report deadline.job_attachments.vfs.VFS_TERMINATE_WAIT_SECONDS as an added attribute — worth confirming against that leg, since the aliasing effort implies it is meant to stay green.
Two ways to keep the intent consistent: name it _VFS_TERMINATE_WAIT_SECONDS, or inline the 5 as a timeout= default. If it is instead meant to be public and tunable, that is a deliberate API addition and belongs in the snapshot with the commit-message treatment the workflow describes — but then the aliasing rationale above reads as inconsistent with it.
There was a problem hiding this comment.
Already fixed before this review ran -- the constant is _VFS_TERMINATE_WAIT_SECONDS now. The API-snapshot check caught it on the previous head, which is exactly the gate you point at, and it passes on the current head.
| # VFSExecutableMissingError with the VFS process already running -- and | ||
| # asset_sync treats that exception as permission to fall back to a copied | ||
| # download, so the copy would write the same root as a live, untracked mount. | ||
| self._resolve_or_raise("findmnt") |
There was a problem hiding this comment.
The preflight discards its result, so it proves "findmnt resolved a moment ago", not "findmnt will resolve when is_mount needs it" — and the gap it is meant to close stays open for the other command on the same chain.
find_system_command re-scans the filesystem on every call (_os.path.isfile + _os.access per directory), and nothing here caches the answer. is_mount at vfs.py:295 calls _resolve_or_raise("findmnt") afresh, so the post-launch raise this comment describes is still reachable — it just now needs findmnt to become unresolvable between line 610 and the wait_for_mount call at vfs.py:665. That is narrow, and the preflight is still worth having as a fail-fast; the issue is that the comment states the property as established when the mechanism only makes it likely. Assigning the result (self._findmnt_path = self._resolve_or_raise("findmnt")) and having is_mount use it when set would actually establish it, and would also stop wait_for_mount re-scanning six directories once per second for up to 60 iterations.
The larger gap is that fusermount3 gets no equivalent treatment, and it is on the same post-launch chain in the failure case. When wait_for_mount returns False, start() now calls _terminate_vfs_proc and raises VFSFailedToMountError — and if that is changed to unmount properly (see my note at vfs.py:679), the cleanup goes through get_shutdown_args, whose first statement is os.path.join(cls.find_vfs_link_dir(), "fusermount3") → find_vfs() → raises VFSExecutableMissingError. So the "resolve everything the post-launch path needs before launching" principle this line introduces is applied to exactly one of the three binaries involved (findmnt preflighted here, sudo resolved mid-flight at vfs.py:411, fusermount3 not until cleanup).
Concretely: self._resolve_or_raise("sudo") alongside this line, plus the existing find_vfs_link_dir()/fusermount3 existence check, would make the ordering hold for all of them and would let test_start_resolves_findmnt_before_launching’s mock_create_mount_point.assert_not_called() assertion be written for each. As it stands the test pins the principle for the one command that was least likely to violate it.
There was a problem hiding this comment.
Deferred as a known and accepted limit. True that the preflight proves "resolved a moment ago" rather than "will resolve later", and the result is discarded.
The failure it was raised for is findmnt not being installed, which is not a state that changes mid-session; a package being unlinked in that window is a different and much narrower problem. Threading the resolved path through to is_mount would close it properly and is the filed prescription.
Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
4aa81cd to
ae6e050
Compare
| # Command omitted for the reason given above the launch log. log.exception | ||
| # already carries the traceback and the exception itself, so the mount | ||
| # point is the part that was not otherwise recoverable from this record. | ||
| log.exception(f"Exception during VFS launch at mount point {self._mount_point}") |
There was a problem hiding this comment.
The new _terminate_vfs_proc is wired into one of the three ways start() can leave a Popen behind, and the other two are on this same revision’s changed lines.
The comment above the new call states the principle clearly — the pid entry is written after the mount check, so on any post-Popen failure "the process launched above stayed alive with no record of it anywhere." That is equally true of the two paths that were not given the helper:
1. The except Exception block right here (vfs.py:662-667). self._vfs_proc is assigned at vfs.py:648, and self._vfs_thread.start() runs at vfs.py:658 inside the same try. If thread creation fails (RuntimeError: cant start new thread under a thread/pid-limited worker, or a MemoryError), the process is already running, self._vfs_proc is already set, and this handler logs and bare-raises without terminating it. VFSFailedToMountError is not involved, so nothing downstream reaps it either — and the exception type is arbitrary, so asset_sync (asset_sync.py:292, :931) does not catch it and it surfaces as a hard sync_inputs failure with a live untracked mount on a 0o777 root.
2. The pid-file rewrite at vfs.py:687-704. It catches only FileNotFoundError. line.split(":") on a malformed entry raises ValueError: too many values to unpack, which escapes start() after Popen succeeded and after wait_for_mount confirmed the mount — so the mount is live and the entry for it never gets written. Worth noting this is reachable from a pid file written by the old code: pre-this-PR kill_process_at_mount did file.write(line) on a stripped line with no newline, concatenating retained entries into /mnt/a:1:/m1.json/mnt/b:2:/m2.json. This PR fixes the newline, so newly written files are well-formed, but a session directory carried across an upgrade is not.
Both close the same way the mount-timeout path just did — self._terminate_vfs_proc() before re-raising. Structurally, wrapping everything from Popen through the pid-file write in a single except BaseException: self._terminate_vfs_proc(); raise would make the property hold for all post-launch escapes rather than for the one that was enumerated, and would cover whatever the next post-Popen step turns out to be.
test_start_terminates_the_vfs_process_when_the_mount_times_out pins only the not is_mounted branch, so neither of these would be caught by the new tests.
There was a problem hiding this comment.
Deferred. Fair that _terminate_vfs_proc is wired into one of the ways start() can leave a Popen behind, and that the mount-timeout path was chosen because it is the ordinary one -- a 60-second timeout on a slow first byte, not an exotic failure.
Covering the rest properly is a try/except around everything after Popen that terminates and re-raises, which is the right shape and a bigger change to start()'s control flow than I want to make at the end of this review cycle. Filed with that prescription.
Process note, applying to the rest of this round: this is the fifth review pass, every finding in it is on a line an earlier pass asked me to change, and one of them was already stale when posted (the public-constant comment -- it was renamed to _VFS_TERMINATE_WAIT_SECONDS before that review ran). I am stopping here rather than continuing to iterate inside a PATH-resolution fix. Deferred items are recorded with the mechanism, the blast radius and what a fix would involve, so none of them is lost.
| # aborted kill_all_processes mid-loop, leaving later mounts unattempted, | ||
| # and escaped cleanup_session, which catches only | ||
| # VFSExecutableMissingError. | ||
| log.info(f"Shutdown returns {run_result.returncode}") |
There was a problem hiding this comment.
Moving this log inside the try is the right fix, but it has a side effect on kill_all_processes that is worth sizing before merge: session cleanup can now take 60 seconds per failed mount, where before it took 60 seconds total.
The last line of this function is return cls.wait_for_mount(mount_path, session_dir, expected=False), and wait_for_mount (vfs.py:318-340) loops mount_wait_seconds=60 times with time.sleep(1) before giving up. So a shutdown_libfuse_mount that genuinely fails to unmount costs ~60s, plus a print_log_end that reads the whole VFS log and emits 100 lines at WARNING.
Before this change, the UnboundLocalError on the old line 166 fired on the first nonzero fusermount3 and aborted kill_all_processes mid-loop — which is exactly the bug the comment describes, but it also meant only one mount was ever attempted. Now the loop at vfs.py:136-141 runs to completion, so a systemic unmount failure is paid once per entry:
fusermount3 -ureturningEPERMbecause the mount is owned by a different job user than the onecleanup_sessionwas handedEBUSYbecause a job process still has a cwd or open fd under the mountsudopresent butNOPASSWDnot configured for the target user
None of these are per-mount accidents; they apply to every entry in the file at once. A job with 10 asset roots goes from ~60s of cleanup to ~600s, and AssetSync.cleanup_session (asset_sync.py:1076-1091) has no timeout of its own, so the worker blocks in session teardown for the whole duration.
Two ways to bound it without giving up the fix:
- Pass a short
mount_wait_secondsfromshutdown_libfuse_mount(or fromkill_all_processes). A successfulfusermount3 -uis synchronous — the 60s budget exists for the mount direction, where S3 first-byte latency is the variable. Unmount does not need the same patience. - Or short-circuit the loop once a shutdown fails for a reason that will repeat:
get_shutdown_argsreturningNoneis already known to apply to every entry, so on that firstNonethe remaining entries can be kept without re-attempting each one.
The new test_shutdown_libfuse_mount_reports_failed_unmount and test_kill_all_processes_keeps_entries_for_failed_unmount both patch wait_for_mount/shutdown_libfuse_mount, so neither exercises the real timeout and this would not show up as a slow test.
There was a problem hiding this comment.
Deferred, and the sizing is the useful part of this comment. Moving the log inside the try means shutdown_libfuse_mount now returns False on a nonzero fusermount3 instead of raising UnboundLocalError, so kill_all_processes continues through the remaining mounts rather than aborting on the first failure -- which is the intended change, and it does mean cleanup now attempts every mount and can spend the wait_for_mount timeout on each.
That tradeoff is deliberate: aborting mid-loop left later mounts unattempted and untracked. Bounding the total time is a separate change to the timeout policy. Filed.
Problem
The VFS mount and unmount paths invoked
sudoby bare name, and queried mountstate with a bare
findmnt, so each was resolved throughPATH. Where any partof that search path is influenced by less-trusted input, the resolution itself is
the vulnerability (CWE-426, Untrusted Search Path) — and these run in order to
act as the job user.
Reported in
openjd-sessions-for-python's sibling form as HackerOne 3942741.Why this repo: the code moved here from
deadline-cloudin"feat: migrate to job attachments package" (#1133).
deadline-cloud'ssrc/deadline/job_attachments/no longer exists, so the fix belongs here.Fix
New
_system_commandsmodule resolves a bare command name against a fixed,ordered list of trusted absolute directories.
PATHis never consulted, andneither is
shutil.which— it resolves throughPATH, so it would reintroducethe problem while appearing to fix it.
A resolver rather than absolute literals because the locations are not universal:
NixOS keeps the setuid
sudowrapper at/run/wrappers/bin/sudo, so a hardcoded/usr/bin/sudowould trade a security bug for a mount failure there. Thatdirectory is searched first.
Properties pinned, and mutation-checked
Each mutation was applied to the production source, the suite run, then the source
restored and verified by checksum. Baseline green before each.
shutil.whichtest_ignores_path_even_when_it_contains_a_matching_command+ 2test_rejects_traversal_even_though_the_target_is_reachable+ 3test_raises_rather_than_returning_the_bare_nameFileNotFoundErrortest_is_not_a_filenotfounderror/usr/binbefore the wrapper dirtest_searches_the_setuid_wrapper_directory_before_usr_bin/sbintest_searches_both_sbin_locationstest_ignores_a_non_executable_fileM4 is worth calling out: code around
subprocesscatchesFileNotFoundErrortomean "optional tool absent, carry on degraded". An unavailable privileged helper
must not be absorbed by that handling, so
SystemCommandNotFoundErrordeliberately does not inherit from it.
M2 is why there are two traversal tests. The straightforward parametrized one does
not catch the mutation alone — with the executable directly in the searched
directory,
../nameresolves to nothing either way — sotest_rejects_traversal_even_though_the_target_is_reachablenests the directoryso the traversal reaches a real file, and asserts that precondition explicitly.
Test changes
TestVFSProcessmanagergains an autouse fixture stubbing the resolver, so itslaunch-command assertions pin the argv shape rather than this host's
sudolocation — and fail if a bare or hardcoded name reappears in the source.
Verification
hatch run fmtcleanhatch run lintclean (ruff, ruff format, mypy over 90 files)hatch run test: 603 passed, 61 skipped, 1 xfailedThe VFS paths themselves are not exercised end to end here — the mount/unmount
argv is asserted, not executed, which is the existing test approach for this class.