fix: recognize host-level UNC roots in path containment checks - #1327
fix: recognize host-level UNC roots in path containment checks#1327crowecawcaw wants to merge 31 commits into
Conversation
666fe0c to
a1c2c95
Compare
os.path.commonpath cannot compare a Windows host-level UNC path (\\server) with
a path under one of its shares: ntpath.splitdrive reports no drive for the former
and \\server\share for the latter, so it raises "Paths don't have the same
drive". It raises the same way for two shares of one host, and for a share-root
directory compared with its own files ("Can't mix absolute and relative paths").
_is_known_path caught that ValueError as "not contained", so every asset path
under a \\server known root was reported as unknown and submissions from a
Windows network share could not proceed without confirmation. Three other
containment checks did not catch it at all and surfaced a raw ValueError instead
of a verdict: job bundle symlink containment (for a bundle at a share root, or a
symlink escaping a drive-letter bundle onto a share), PATH parameter default
containment, and the download summary.
Add deadline.client._path_utils, which compares paths component by component so a
UNC host is an ordinary ancestor of its shares. Path spaces are discriminated with
splitroot (backported for Python < 3.12) rather than inferred from the string, so
a rooted driveless path, a drive root, a drive-relative path, the UNC namespace,
and the device namespace can never be confused for one another. Route all four
containment checks and the summary through it, and ban commonpath/commonprefix via
ruff TID251 so the bug class cannot return.
Containment fails closed on everything it cannot resolve, since every caller uses
it to decide whether a path is trusted. Extended-length and device paths
(\\?\..., \\.\...) keep their prefix and occupy a path space of their own rather
than being folded into the plain form they denote, so they never alias it; those
prefixes disable path normalization, so that space has no share-relative form and
a root there still contains its own files. The bare \\ anchor is not a root
directory the way POSIX / is -- splitroot reads it as a drive with an empty root,
an incomplete UNC spelling naming no server -- so it is an ancestor of nothing.
Treating it as one would trust every reachable share from a single root, and
ntpath.isabs reports it absolute, so it survives a caller's isabs filter.
Also harden the known-asset-path handling this exposed. Roots are expanded for
'~' and dropped unless absolute, rather than kept and compared or resolved against
the working directory. A non-absolute root cannot match the absolute candidates it
is checked against, so it is a hazard only once a caller normalizes it: resolving
one would mark an unrelated tree as trusted (os.path.abspath("") is the whole
working directory), suppressing the unknown-path warning and letting a
non-interactive submit upload files the user never designated. Dropping it at the
boundary keeps that from depending on which normalization a future caller reaches
for, and costs only a warning. An empty root arrives from --known-asset-path "",
the MCP tool's unvalidated JSON array, and a PATH parameter whose allowedValues
suppressed absolutization. Redundancy filtering now compares components, so a UNC
host subsumes its shares (Path.parts collapses \\server\share into one atom), case
variants of one location dedupe on Windows, and the caller's first,
highest-precedence spelling is the one retained.
Windows semantics are tested through an explicit path_module so they run on every
platform; UNC paths cannot be built with os.path.join(os.sep, ...), so tests
written against the native module silently skipped them on POSIX. Containment is
additionally checked against pathlib.PurePath.is_relative_to as an independent
oracle on Python 3.12+, where the only permitted disagreement is the UNC-ancestor
case this change adds. Reflexivity, ancestor soundness, and transitivity are
asserted over a corpus spanning every path space.
test/integ/windows_smb validates the fix against a real SMB share on a Windows
runner, since every other test models Windows lexically and cannot confirm the
redirector agrees. It is excluded from the default test paths and dispatched
manually, because creating a share requires administrator rights.
Fixes aws-deadline#1321
Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
a1c2c95 to
e56265f
Compare
…fixes
Three defects of one shape: a Windows path spelling that names an ordinary
location was read as a different path space, so a root stopped containing
its own contents.
Before Python 3.11 both ntpath.normpath and ntpath.splitdrive strip a UNC
path that names no share down to a rooted, driveless one ('\\host' ->
'\host', splitdrive reporting no drive at all). A host-level known-asset
root therefore landed in a different path space than the candidates under
it, leaving issue aws-deadline#1321 unfixed on 3.9 and 3.10 -- two of the six
interpreters the CI matrix covers. UNC-ness is now read off the path text
rather than off splitdrive's drive, and a collapsed anchor is restored.
_filter_redundant_known_paths deduped roots with a raw os.path.normpath,
which truncated a host-level root the same way. That list is what
_is_known_path compares against, so the damage reached the trust decision
rather than staying cosmetic. It now uses normalized_path, which keeps the
anchor.
An extended-length prefix ('\\?\') only turns off Win32 normalization; it
denotes the same location as the plain spelling. It now folds to that
spelling instead of occupying a path space of its own, so a prefixed path
is contained by exactly the roots its plain form is -- job-attachments
carries that form through its internals, so it can reach these checks.
Folding ahead of normpath also makes '..' resolution independent of the
running Python. Forms denoting no plain path (Volume{GUID}, GLOBALROOT and
the '\\.\' device namespace) keep their own space and still alias nothing.
common_ancestor moves to _path_summary, leaving _path_utils to the trust
decision alone. It is display-only with a single caller, and it carried the
unresolved-'..' and preserved-spelling handling that only a printed string
needs.
Verified by differential across 3.9, 3.10, 3.11 and 3.14: 42 component
cases and 1722 containment permutations, zero disagreements. The pre-3.11
branch is exercised on every interpreter through an injected path module
that reproduces the old normpath and splitdrive, with assertions that the
proxy is not passing vacuously.
Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
Fourth site of the same version-dependent defect, and the remaining cause
of the red CI. Before Python 3.11 ntpath.isabs tests what splitdrive leaves
behind, and for a UNC path that names a share splitdrive consumes the whole
string:
3.9 / 3.10 : ntpath.isabs(r"\\host\share") -> False
3.11+ : -> True
Three call sites gate trust on that answer:
- _filter_redundant_known_paths dropped every UNC root naming a share,
since it drops roots that are not absolute. That is why 4 of its cases
still failed after the previous commit.
- The pre-submission hook check requires PATH values to be absolute, so a
hook emitting a valid UNC path was rejected as relative -- on exactly
the setup issue aws-deadline#1321 reports.
- The PATH-default check requires the opposite, so an absolute UNC default
slipped past it. That one still failed closed on the containment check
immediately after, but reported the wrong reason.
All three now use is_absolute_path, which derives the answer from the anchor
the rest of the module already computes. It is deliberately as strict as the
newest stdlib rather than as loose as the oldest: a drive-relative path
('C:x') needs the working directory on that drive and a rooted, driveless
one ('\x') needs the current drive, so neither may be trusted as a root.
ntpath.isabs accepted the latter until 3.13.
Verified against the stdlib on 3.9, 3.10, 3.11 and 3.14: identical to 3.14
on every case, and never accepts something the running interpreter's isabs
rejects except a UNC share, which is the defect being fixed. A test pins
that property so the helper cannot quietly widen.
Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
ntpath.isabs disagrees with itself twice across the supported range, not
once. The previous commit handled the first disagreement and adopted the
newest stdlib's answer for the second, which was wrong:
ntpath.isabs(r"\x") 3.9-3.12: True 3.13+: False
ntpath.isabs(r"\\h\s") 3.9/3.10: False 3.11+: True
Being as strict as 3.13 dropped rooted, driveless roots, which broke
test_filter_redundant_known_paths on Windows 3.11. A rooted, driveless path
names the current drive's root rather than the working directory, so it does
not carry the risk the known-root hardening exists to prevent -- only a
drive-relative path ('C:x') does. It is now absolute on every version, and
the verdict no longer changes with the interpreter.
That test was already latently failing on Windows 3.13 and 3.14 before this
commit, for the same reason: this PR introduced the isabs filter, and on
3.13+ the stdlib answer discards a '/a' root. Fail-fast cancelled those jobs
before they reported.
Also corrects a second Windows-only failure this PR introduced in the same
test. mainline returned each root's original spelling; this PR normalizes
them, so a '/a' root now comes back as '\a' on Windows. Normalizing is the
intent -- it is what dedupes equivalent spellings without consulting the
working directory -- so the test's expectation is now platform-aware rather
than the normalization being reverted. Verified by replaying all three of
its Windows assertions under ntpath on 3.9, 3.10, 3.11 and 3.14.
Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
Reverts the leniency of the previous commit. Two tests in this PR disagreed
about a rooted, driveless path, and on Windows they cannot both hold because
'/a' and '\projects' are the same shape there:
- test_filter_redundant_known_paths_drops_unanchored_paths lists
'\projects' among the roots that must be dropped.
- test_filter_redundant_known_paths, which predates this PR's isabs filter,
passes '/a' roots and expects them kept.
Dropping is the correct answer. '\projects' resolves at the root of whichever
drive the process happens to be on, so like 'C:x' it names no fixed location,
and letting it through is exactly what the known-root hardening exists to
prevent. Dropping a root costs a warning; trusting an ambiguous one does not
fail closed. CPython reached the same conclusion in 3.13, when ntpath.isabs
stopped accepting the form.
So the pre-existing test is the one that was wrong on Windows, and only
because this PR added the filter: its POSIX-style roots are root-relative
there, not absolute. It already carried a drive-qualified variant for the
real case, which is now what pins the redundancy behaviour; the two
unanchored spellings assert they are dropped.
is_absolute_path therefore answers from the anchor and rejects both Windows
working-directory-dependent forms on every version, diverging from
ntpath.isabs where the stdlib disagrees with itself -- accepting a UNC share
it rejects before 3.11, rejecting a rooted driveless path it accepts through
3.12. It matches 3.14 exactly. Replaying both tests' Windows assertions
under ntpath on 3.9, 3.10, 3.11 and 3.14 gives 26 for 26 on each.
Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
CodeQL flagged 'drive' as possibly used before assignment in the mapped-drive SMB test. pytest.skip() raises, so the for/else could not actually fall through with it unbound, but the plain form says so without relying on the reader knowing that. The alert has been open since this PR's first push and is the only remaining red check. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
Raised by automated review, reproduced before changing anything.
A bare '\\' is fully qualified, so it passed the is_absolute_path filter. Its
components are the single ['\\'], so it sorted first and was inserted into the
trie as a marker -- and being one component it is a prefix of *every* real UNC
root, so each one after it was skipped as redundant:
filter(['\\', '\\host']) -> ['\\']
filter(['\\', '\\server\share', '\\other\s2']) -> ['\\']
is_path_contained deliberately treats that anchor as containing nothing, so the
surviving root matched nothing while the roots that would have matched were
gone. Every path under them became unknown -- the spurious "outside of known
asset paths" warning, and a blocked non-interactive submit. Same user-visible
failure as aws-deadline#1321, reintroduced through the filter this PR added.
It reaches the filter from the inputs the docstring already lists for the empty
root, plus two spellings that are not obvious: '//' and '\\?\UNC\', the latter
because _fold_extended_length_prefix collapses it to the anchor. That fold is
new in this PR, so it widened the reachable surface.
Dropped alongside the unanchored roots, via a named is_bare_unc_anchor: it is
the one absolute path that names no location, which is why is_absolute_path is
the wrong place to express it.
Tests: the filter cases including both orderings and both alternate spellings,
the anchor alone yielding no roots, is_bare_unc_anchor across path spaces, and
an end-to-end _generate_message_for_asset_paths case that runs the filter
before the containment check. The halves each looked correct in isolation --
_is_known_path handles the anchor properly, and the filter deleted the real
root before it ever got there -- so only the combined test sees this.
Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
Raised by automated review. windows_smb_test.yml declared only workflow_dispatch and workflow_call, and nothing in the repo calls it, so the regression coverage it provides for aws-deadline#1321 never actually ran. test/integ is outside testpaths and outside 'hatch run test', so no other job reaches test/integ/windows_smb either -- a future regression in UNC handling would have shipped guarded by nothing but the unit tests, which are lexical by their own docstring and say nothing about what the SMB redirector does. Adds 'push: branches: [mainline]', matching dcm_integration_tests.yml. Kept off per-PR CI deliberately: it needs administrator rights to create the share and is slower and more environment-dependent than a unit test. workflow_call stays so a release-time caller can still invoke it with a tag. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
| run: | | ||
| hatch run pytest test/integ/windows_smb -v --no-cov -p no:randomly | ||
|
|
||
| - name: Report skips |
There was a problem hiding this comment.
Running this through some AI checks this is the main thing flagged and it seems somewhat unlikely, but here for consideration:
The SMB fixture calls pytest.skip() when the share cannot be created or reached (test_unc_path_containment.py:59,70).
Because pytest exits successfully when every test is skipped, the new regression workflow can go green while executing zero SMB tests. The “Report skips” step only prints the skips; it does not enforce coverage and unnecessarily reruns the entire suite.
I’d recommend running the suite once with -rs and making module-wide fixture skips fail in CI—for example, fail when the share cannot be created/reached or validate the JUnit report to ensure the required tests executed.
This matters because the workflow comments state that no other job runs these integration tests, so a green result could falsely indicate that issue #1321 remains covered.
There was a problem hiding this comment.
Confirmed and fixed in cc26271. I checked the mechanism rather than reasoning about it:
module-scoped fixture pytest.skip -> 2 skipped, exit 0
module-scoped fixture pytest.fail -> 2 errors, exit 1
So the green-with-zero-coverage path was real. The prerequisites step only closed the admin case, and on windows-latest the runner is already elevated — so that throw never fires, and the two skips that can actually happen were exactly the ones it did not cover.
Fixed the way you suggested — one run with -rs, environment skips failing in CI — via a _unavailable() helper gated on DEADLINE_SMB_TESTS_REQUIRED, which the workflow sets and a developer without administrator rights does not. Applied at all four skip sites rather than only the module fixture: share creation, reachability, the symlink privilege, and the drive letter. Each one means the environment cannot exercise the fix, and the workflow provisions for all four (it enables Developer Mode for the symlink), so a skip there is a broken runner rather than a benign one.
Two things surfaced while confirming it, both of which made it worse than described:
--numprocesses=autois inaddopts(pyproject.toml:172) and the run command did not override it, so every xdist worker built its own share and they raced for the one free drive letter — a spurious skip the workflow generated itself. Pinned to--numprocesses=0.- The platform gate was the same hole.
skipif(sys.platform != "win32")is a skip like any other, so aruns-onchange would have produced the identical false green. Under the flag it is now a collection error (exit 2, verified).
I skipped the JUnit-report validation you offered as the alternative: the flag covers "collected but skipped", and pytest already exits 5 on "collected nothing", so there is no gap left for XML parsing to close.
Report skips is gone — you are right that it re-ran the whole suite, a second net share create and delete cycle, to print counts it could not act on. -rs on the single run reports the same thing.
Also added test_unavailable_fails_where_the_share_is_required, which pins both branches of the helper and needs no share, so it is the one test here a broken runner cannot silence.
Worth stating because it sharpens your point: the workflow itself has never executed — workflow_dispatch requires the file on the default branch, so it could not have been dispatched from this branch. The post-merge push will be its first run, which is precisely when a false green would have gone unnoticed.
pytest exits 0 when every test skips, so a module-scoped fixture skip made a runner that could not build the share report a green job while asserting nothing about SMB -- and this workflow is the only place these tests run. Environment skips now fail when DEADLINE_SMB_TESTS_REQUIRED is set, which the workflow sets and a developer without administrator rights does not. That covers the share, the redirector, the symlink privilege, and the drive letter; a non-Windows interpreter raises at collection for the same reason. Drops the Report skips step, which re-ran the whole suite -- a second share create and delete cycle -- to print counts it could not act on. -rs on the one run reports the same thing. Pins numprocesses=0 over the auto in addopts: each xdist worker built its own share and they raced for the one free drive letter, which is a spurious skip in its own right. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
The zip-slip guard aws-deadline#1181 added compares os.path.commonpath against the destination, which the TID251 ban this PR introduces rejects -- and for the reason the ban exists. commonpath raises on a share-root destination: ntpath.commonpath([r"\\host\share", r"\\host\share\template.yaml"]) ValueError: Can't mix absolute and relative paths The guard read that as an escape, so with the destination on a share root it rejected every entry of every archive. A redirected profile puts the bundle cache there, so this was reachable rather than theoretical. is_path_contained answers the same question across path spaces: a different drive or UNC host is not contained, so the branch that caught ValueError to reject them is no longer needed. It also compares components rather than strings, so a sibling sharing a prefix stays outside. Tests: the share-root extraction and its escape on a real share, and a sibling-prefix rejection in the unit suite. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
Windows clamps '..' at a share root, so an archive entry of '../escaped.txt' extracted into \\server\share resolves back to \\server\share -- contained, and the guard correctly does not raise. Assert the clamp where the destination is a share root, and move the escape case onto a subdirectory, the only destination on a share that '..' can leave. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
|
The review comment is correct, and the reason the Windows checks were green is that this test never ran in PR CI: Windows clamps That is inside the destination, so Verified on a real Windows runner against a real SMB share, both directions:
Fixed in
Worth noting no escape was ever possible here regardless: |
is_path_contained's path_module default binds os.path at import time, so the one call site that relied on it could not be pointed at ntpath the way every other call site can. The guard's UNC behavior was therefore reachable only from the SMB integ suite, which no pull request runs -- and reverting the fix it belongs to failed nothing in test/unit. Pass path_module explicitly, as the other call sites do, and cover the share-root destination, the '..' clamp at a share root, an escape from a directory on a share, and a drive-relative entry that discards the destination entirely. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
The guard sat inline in create_job_from_job_bundle with no seam, so nothing exercised it under Windows semantics on any platform or interpreter: restoring pre-3.11 isabs semantics there passed all 3580 unit tests. Its sibling call site, which takes path_module, kills the same mutation with 14 failures. Moving it to a module-level function with an injectable path_module pins both directions: a hook emitting '\\host\share' is accepted (pre-3.11 isabs read that as relative), and one emitting '\scene.ma' or 'C:scene.ma' is rejected (isabs accepted the first through 3.12). Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
The existing cases all used a relative default, so the absolute check above the containment check never ran under the injected ntpath -- pre-3.11 isabs semantics passed the whole suite. A share-root default is the spelling that diverges, and match= is load-bearing because the containment check raises the same type. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
Coverage: - the anti-climb backstop had one covering case; add the prefixed and device spellings whose '..' survives normalization before 3.11 (verified on 3.9). - the known-root filter is now asserted through a pre-3.11 normpath, the versions where a collapsed host-level root matches none of its own shares. - is_absolute_path is pinned against a path module whose isabs is deliberately wrong, since 3.13+ stdlib agrees with it and cannot fail the delegating form. - '..' in a root, degenerate empty paths, and the documented order tie-break. - the pathlib oracle now carries the extended-length spellings, and names folding as the second sanctioned disagreement rather than claiming one. Correctness: - five pytest.raises calls gain match=; the functions raise that type from unrelated preconditions, so a fixture drift would pass them silently. - the common_ancestor property test asserts a floor; it skips empty answers, so returning nothing for everything passed it vacuously. - drop a patch of abspath that the filter never calls, and three per-file ruff exemptions for a rule none of those files trips. - normpath leaves '..' inside '\\?\' alone before 3.11, not 3.10 (measured), and the splitroot shim's triples are not comparable across that boundary. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
Nothing on a pull request could reach test/integ/windows_smb: the reusable build runs `hatch run test`, whose script hard-codes test/unit and test/cli_e2e. So the only tests that check these verdicts against a real redirector ran after merge, and a wrong assertion in them stayed green through review. Run it on pull requests touching the paths it covers. Meanwhile the mainline integ jobs collected this directory and skipped all of it -- no share, no DEADLINE_SMB_TESTS_REQUIRED -- reading as coverage they did not have; exclude it so the dedicated workflow owns it. Replace the Developer Mode step, which cannot grant CPython the symlink privilege, with the SymlinkEvaluation setting the escape test actually depends on. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
The mapped-drive test branched on realpath's output, so a containment regression in the drive-letter space would steer it into the branch that does not check that space, and the surviving assertion duplicated an earlier test. Pin the rewrite instead, then assert both spellings, including the limitation that a mapped-drive root does not cover files that resolve to UNC form. The symlink escape targeted the local C: spelling of a file inside the share, so the rejection came from comparing path spaces rather than from leaving the bundle. Target a sibling through the share, pin that realpath resolves the link at all, and match the message. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
|
Audited this PR's tests three ways — mutation testing (revert each fix, see if anything fails), CI wiring (does each test actually execute, and where), and assertion correctness (re-derive every Windows expectation against real interpreters). Six commits follow. Highlights: Two guards had no effective coverage at all.
The tests-do-not-run problem was bigger than the one file. Two SMB tests were passing for the wrong reason. The mapped-drive test branched on Smaller fixes: the anti-climb backstop had one covering case (added the prefixed/device spellings whose Also corrected two comments: the Verified: 3766 unit tests pass locally; SMB suite 12/12 on a real Windows runner with a real share; every mutation described above now fails at least one test (checked one at a time in an isolated worktree). Two things I did not change, for you to decide: the |
A default argument binds os.path when the module is imported, so a caller that
omitted path_module could not be redirected by a test patching os.path -- which
is why the archive guard's UNC behavior was unreachable from test/unit until it
started passing one explicitly. Resolving None in the body makes the omission
harmless: every call site is patchable whether or not it passes one.
Also corrects three claims these modules made:
- the docstring said every function takes an explicit path_module; it takes an
optional one.
- the commonpath explanation was written as universal but describes 3.9/3.10.
From 3.11 splitdrive does report a drive for '\\server' and the exception
changes message; it raises on every supported version, which is the part that
matters.
- the component trie's first key is the path-space anchor ('/'), not ''. The
security argument in that same docstring depends on it.
Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
…cific `if sys.platform == "win32": assert ...` with no else passes while executing nothing on the other two CI platforms -- half of sanitize_bundle_name's behavior, including its traversal guard, went unverified on linux and macos. It reads sys.platform at call time, so patching it pins both verdicts anywhere. The pre-existing symlink containment test used a bare pytest.raises for the guard this branch rewired; that function raises the same type for "path is not a directory", so fixture drift would pass it without reaching the check. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
Every agreeing pair takes a `continue` and all assertions sit after it, so the test passed having executed zero assertions -- including when is_path_contained is replaced by a pathlib-delegating implementation, which is the exact regression it exists to catch. Count both sanctioned classes and assert the corpus produces them, mirroring the floor its sibling in test_path_summary.py already has. Also fixes an inline comment still claiming one sanctioned disagreement where the docstring above it now says two. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
_assert_valid_path validated a root arriving over the JSON protocol with Path.is_absolute, which reads '\\host' as relative before Python 3.13 and absolute from 3.13 -- so the same host-level UNC path aws-deadline#1321 reports for containment was rejected outright as a download root on four of the six supported versions. It had no tests: both references to it in the suite patch it out. Use the version-independent helper, with the path module injected as the sibling call sites do, and cover both directions including the rooted-driveless and drive-relative spellings that must stay rejected. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
…around The ban's own comment says ntpath and posixpath are included because this codebase passes explicit path modules, then applied that to commonpath only -- so ntpath.commonprefix, the string-prefix match that reports '\\host\share2' as inside '\\host\share', was allowed. Verified both spellings now flag. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
test_monitor_login_keeps_its_own_message failed on the macos 3.11 leg of this branch, and reproduced locally under the full suite. It is not this branch's code -- the file is untouched here -- but it blocks the required check, and the race is in the test's own helper. The helper waited on an Event the background thread sets straight after calling on_pending_authorization, then read dialog.text(). The message crosses to the GUI thread on a queued signal, so that Event says nothing about whether the dialog has applied it; under load the cancel click lands first and the assertion sees the default 'Logging you in...'. Wait for the text itself, bounded so a message that never arrives still fails. Verified by injecting the delay the loaded runner produces (Event set before the message is sent): the old predicate fails with exactly the CI error, the new one passes. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
|
Second audit round (code review for the same mistake classes + a read of the actual Actions logs). Six more commits. Root cause of the archive-guard gap, fixed at the source. Same #1321 bug found still live elsewhere. Tests that asserted nothing. Log audit — what actually ran. Every one of the 18 matrix legs collected exactly +304 items over the merge-base run; on 3.12/3.13/3.14 (all three OSes) +304 passed / +0 skipped, on 3.9/3.10/3.11 +300 passed / +4 skipped. Those 4 are One correction to what I said earlier: running on Unrelated flake, fixed here because it blocked this PR. Two things left alone, deliberately — both real, both outside this PR: |
The picker widgets rewrote a chosen path to the '~' spelling when it startswith() the home directory, then sliced it by the home directory's length. With a home of /Users/bob, choosing /Users/bobby/projects/scene.ma displayed '~/ar/projects/scene.ma', and /Users/bob2/projects/x came back as '/projects/x', because join() drops the '~' when what follows is rooted. The config dialog writes that text straight into job_history_dir and job_bundle_default_directory, so the wrong directory is persisted; the widget then expands the '~' again and the two no longer agree. Use is_path_contained and relpath, in one helper rather than the two copies the two widgets carried. Covered in both path spaces, including the sibling prefixes that must come back untouched and the case-insensitivity Windows needs; the previous implementation fails 8 of the new cases. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
|
Correction to my last comment: the The Current state: 34 checks green, |
Two changes so this can actually block a merge: The paths filter is gone. GitHub reports a required check that was filtered out as pending rather than passed, so keeping the filter would block every merge it skipped. The cost is the Windows env install on each run, about two minutes. The release workflow now calls it, between the unit tests and PreRelease, using the tag input the workflow_call trigger already declared for exactly that and which nothing used -- so the tag being published was never validated against a real redirector. No secrets: the job creates a loopback share and needs none. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
An audit reverted each change one at a time and found six mutants no test noticed. Five are closed here, each verified to fail the new test: - The call-time path_module resolution -- the most heavily commented decision in this branch -- was untested from every angle: every production call site passes the argument explicitly, and every test that omits it does so without patching os.path, so binding it at import again broke nothing. One test, patching os.path and omitting the argument at all six helpers, kills that. - parameters.py had no sibling-string-prefix case, so 'C:\bundle-secret' passed a naive startswith. Its two sibling guards, the symlink check and the archive guard, each have this case. - The picker widgets' calls to the collapse helper were deletable with the suite still green: the helper was tested, the wiring was not, and the wiring is what persists a wrong directory into settings. - The download summary's UNC case pinned only a cosmetic separator. Two shares of one host is the pair commonpath answers with ValueError, which nothing caught, so summarizing such a download aborted the command. - The JSON-protocol branch of the OS-mismatch remap validated nothing under test; it is where a root arrives from a machine rather than a person. The sixth is not a defect: passing path_module=os.path explicitly at a call site is unobservable now that the default resolves at call time, so it stays a convention that documents intent. Every line this branch adds to src is now covered on this interpreter. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
The JSON-protocol remap validates the root it is handed against os.path, the module of the machine that will do the downloading. The new test supplied '/mnt/share/renders' regardless of platform, which is rooted but driveless on Windows -- it resolves against whichever drive the process happens to be on, so it is not absolute there and the validator rejected it. The suite was green on Linux and macOS and failed on all six Windows jobs. Pick the spelling from os.name so the case asserts what it means -- an absolute root is accepted and set -- on every host. TestAssertValidPath already covers both spellings everywhere by injecting the path module, so the platform-specific verdicts stay pinned off Windows too. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
click 8.5.0 propagates a ParamType's declared result type through click.prompt, so the two prompts typed with click.Path now hand back 'str | bytes | os.PathLike[str]' -- the union click.Path declares to cover its path_type option. os.path.expanduser widens that to 'str | bytes' and Path() rejects bytes outright, so mypy fails on both call sites. With path_type unset, click.Path.coerce_path_result returns the prompt string unchanged, so str is the only type either prompt can produce; cast says so. No runtime behavior changes. This is not specific to this branch -- mainline fails the same two lines under click 8.5.0, which is unpinned and resolves fresh whenever the Hatch environment cache misses. That is why every job in the matrix went red at once, after the same commit linted clean on click 8.4.2. Signed-off-by: Stephen Crowe <6042774+crowecawcaw@users.noreply.github.com>
Fixes #1321
Problem
os.path.commonpathcannot compare a Windows host-level UNC path (\\server) with a path under one of its shares.ntpath.splitdrivereports no drive for the former and\\server\sharefor the latter, so it raises. Three distinct pairs fail:commonpathresult\\hostvs\\host\share\fValueError: Paths don't have the same drive(the reported case)\\host\s1\avs\\host\s2\bValueError: Paths don't have the same drive\\host\sharevs\\host\share\fValueError: Can't mix absolute and relative paths_is_known_pathcaught thatValueErroras "not contained", so every asset path under a\\serverknown root was reported as unknown and submissions from a network share could not proceed without confirmation.The audit found the same root cause at three more sites, where the exception was not caught at all and surfaced as a raw
ValueErrorrather than a verdict — two of them latent crashes rather than merely wrong answers:_submit_job_bundle.py:_is_known_pathValueError→ valid paths silently reported unknown_submit_job_bundle.py:_filter_redundant_known_pathsPath.partscollapses\\server\shareinto one atom, so a host root never subsumed its sharesjob_bundle/loader.pysymlink containmentjob_bundle/parameters.pyPATH defaultcli/_groups/job_group.pydownload summarySolution
New private
deadline/client/_path_utils.pycompares paths component by component, so a UNC host is an ordinary ancestor of its shares. Because the helper needs to work cross-OS and because Python stdlib handles paths differently for different versions, we need to implement some lower level path logic ourselves.Testing
Lots of unit tests. Also testing on Windows runner with real SMB shares.
Appendix: Windows path spaces and the moving stdlib model
Background for reviewers who do not work on Windows day to day. "Path space" is the term the
code uses; Microsoft's docs call the
\\?\and\\.\ones namespaces (Win32 filenamespace, Win32 device namespace) and call the broader categories path formats. Rows
marked ⓜ were measured on real interpreters rather than taken from documentation.
Windows has ~6 path spaces; POSIX has ~1
C:\fooC:foofoounder the working directory on drive C:\foofooat the root of the current drivefoo\\server\share\foo\\?\...,\\.\...=C:environment variables). That is why
C:fooand\fooare ambiguous — they resolve againstprocess state, which is exactly what a trusted root must not depend on.
spaces that is false in both directions.
Win32 normalization — what
\\?\turns offBefore a path reaches the filesystem, Win32 rewrites it:
/to\; collapses.and..;strips trailing dots and spaces (
foo.becomesfoo); resolves drive-relative and rootedforms against the working directory; intercepts reserved device names (
CON,NUL,COM1);and enforces
MAX_PATH= 260.\\?\— the extended-length prefixcharacters.
/fails), no
.or.., fully qualified.folding prefixed and plain spellings together is not automatically safe — it is safe here
because component text is preserved, so
C:\trusted \evilstill fails closed.\\.\is the device namespace (\\.\C:is the volume, not the filesystem root). It hasno plain spelling, and neither does
\\?\Volume{GUID}\. Both keep a path space of their own.The
\\?\UNC\form is the trap. The prefix replaces the leading pair rather than stacking onit:
Why
\\serverbreaks everything\\serveris not a directory.serverandsharetogether form the mountpoint, the drive-equivalent. Explorer lists shares through a different API; there is no
directory handle for
\\server.ntpath.splitdrive(r"\\server\share")reports the whole thing as the drive, and no stdlibhelper models "
\\servercontains its shares" — that is not a filesystem fact, it is a policychoice. It is the one piece of hand-written path logic in this PR, and deliberately so.
commonpathraises rather than answering, because one side has a drive and the other does not.The stdlib model shifted three times inside 3.9-3.14 ⓜ
splitdrive(r"\\host")("", r"\\host")(r"\\host", "")normpath(r"\\host")\host— pair collapsed\\hostisabs(r"\\host\share")FalseTrueTrueisabs(r"\x")TrueTrueFalsesplitroot\\server\share, and mishandled both neighbours inopposite directions:
\\host) read as "no drive", sonormpathcollapsed\\to\andsilently moved a host-level root out of the UNC space;
\\host\share) read as "all drive", leavingisabsan empty remainder totest, so it answered not absolute.
splitroot, giving(drive, root, tail). Needed becausesplitdrivefolds theroot into
restand so cannot tell\foofrom\\foo.isabs(r"\x")toFalse, correctly, since it needs the current drive. That isthe answer this PR now matches on every version.
these helpers derive the path space themselves instead of asking
ntpath.Long paths — three gates, not one
MAX_PATHis enforced by normalization, so\\?\bypasses it structurally.LongPathsEnabled, but it is not sufficient: theprocess also needs
longPathAwarein its executable manifest.RtlAreLongPathsEnabled()reports the process state, not the registry, despite the name.Measured in job-attachments#67: one machine, key set, stock
python.exereportsTrueand amanifest-cleared copy reports
False.python.exehas declared it since CPython 3.6. DCC executables andpythonservice.exegenerally do not, which is why the prefix is still required.
Crossing between spellings — where the bugs live
surfaced (terminal, JSON, manifests other tools parse).
its items 1 to 5 came from stripping too early.
win32security.Get/SetFileSecurityis the live example.Path.resolve()preserves an existing\\?\on 3.10+ but drops it on 3.8 and 3.9: atransition that silently varies by version.
hit this repeatedly — job-attachments#67 items 1 to 5 and 7 to 8, and four times here.
..lexically after arealpath-based check has already passed can write somewhere else, which is why..isrefused rather than collapsed in a prefixed path.
Why this class of bug recurs
has to be built rather than inherited.
absolute path reported relative. Those surface as a warning or a dropped root rather than a
crash, so they ship easily and are visible only to users on network shares.