Skip to content

fix: recognize host-level UNC roots in path containment checks - #1327

Open
crowecawcaw wants to merge 31 commits into
aws-deadline:mainlinefrom
crowecawcaw:fix/unc-host-path-containment
Open

fix: recognize host-level UNC roots in path containment checks#1327
crowecawcaw wants to merge 31 commits into
aws-deadline:mainlinefrom
crowecawcaw:fix/unc-host-path-containment

Conversation

@crowecawcaw

@crowecawcaw crowecawcaw commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Fixes #1321

Problem

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. Three distinct pairs fail:

Pair commonpath result
\\host vs \\host\share\f ValueError: Paths don't have the same drive (the reported case)
\\host\s1\a vs \\host\s2\b ValueError: Paths don't have the same drive
\\host\share vs \\host\share\f ValueError: 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 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 ValueError rather than a verdict — two of them latent crashes rather than merely wrong answers:

Site Before
_submit_job_bundle.py:_is_known_path caught ValueError → valid paths silently reported unknown
_submit_job_bundle.py:_filter_redundant_known_paths Path.parts collapses \\server\share into one atom, so a host root never subsumed its shares
job_bundle/loader.py symlink containment uncaught → crash for a bundle at a share root, or a symlink escaping onto a share
job_bundle/parameters.py PATH default uncaught → same
cli/_groups/job_group.py download summary stray trailing separator in user-visible text

Solution

New private deadline/client/_path_utils.py compares 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 file
namespace, 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

Spelling Means Fully qualified?
C:\foo drive-absolute yes
C:foo foo under the working directory on drive C: no
\foo foo at the root of the current drive no
foo relative to the working directory no
\\server\share\foo UNC (network) yes
\\?\..., \\.\... extended-length / device yes
  • Windows keeps a working directory per drive (DOS legacy, carried in hidden =C:
    environment variables). That is why C:foo and \foo are ambiguous — they resolve against
    process state, which is exactly what a trusted root must not depend on.
  • The POSIX habit that breaks here: string prefix implies directory containment. Across path
    spaces that is false in both directions.

Win32 normalization — what \\?\ turns off

Before a path reaches the filesystem, Win32 rewrites it: / to \; collapses . and ..;
strips trailing dots and spaces (foo. becomes foo); resolves drive-relative and rooted
forms against the working directory; intercepts reserved device names (CON, NUL, COM1);
and enforces MAX_PATH = 260.

\\?\ — the extended-length prefix

  • Passes the rest through nearly verbatim to the NT namespace, raising the limit to ~32,767
    characters.
  • Because normalization is off, the path must already be perfect: backslashes only (/
    fails), no . or .., fully qualified.
  • Trailing dots and spaces are preserved, so it can name files Win32 cannot reach. This is why
    folding prefixed and plain spellings together is not automatically safe — it is safe here
    because component text is preserved, so C:\trusted \evil still fails closed.
  • \\.\ is the device namespace (\\.\C: is the volume, not the filesystem root). It has
    no 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 on
it:

\\server\share\f   ->   \\?\UNC\server\share\f      correct
\\server\share\f   ->   \\?\\\server\share\f        invalid -- what job-attachments#67 produced

Why \\server breaks everything

  • On Windows, \\server is not a directory. server and share together form the mount
    point, the drive-equivalent. Explorer lists shares through a different API; there is no
    directory handle for \\server.
  • So ntpath.splitdrive(r"\\server\share") reports the whole thing as the drive, and no stdlib
    helper models "\\server contains its shares" — that is not a filesystem fact, it is a policy
    choice. It is the one piece of hand-written path logic in this PR, and deliberately so.
  • commonpath raises 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 ⓜ

3.9 / 3.10 3.11 / 3.12 3.13 / 3.14
splitdrive(r"\\host") ("", r"\\host") (r"\\host", "") same
normpath(r"\\host") \host — pair collapsed \\host same
isabs(r"\\host\share") False True True
isabs(r"\x") True True False
splitroot absent 3.12+ present
  • Before 3.11 the model demanded exactly \\server\share, and mishandled both neighbours in
    opposite directions:
    • less than that (\\host) read as "no drive", so normpath collapsed \\ to \ and
      silently moved a host-level root out of the UNC space;
    • exactly that (\\host\share) read as "all drive", leaving isabs an empty remainder to
      test, so it answered not absolute.
  • 3.12 added splitroot, giving (drive, root, tail). Needed because splitdrive folds the
    root into rest and so cannot tell \foo from \\foo.
  • 3.13 tightened isabs(r"\x") to False, correctly, since it needs the current drive. That is
    the answer this PR now matches on every version.
  • Net effect: "just use the stdlib" returns different answers per interpreter. That is why
    these helpers derive the path space themselves instead of asking ntpath.

Long paths — three gates, not one

  • MAX_PATH is enforced by normalization, so \\?\ bypasses it structurally.
  • Windows 10 1607+ added the registry LongPathsEnabled, but it is not sufficient: the
    process also needs longPathAware in 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.exe reports True and a
    manifest-cleared copy reports False.
  • python.exe has declared it since CPython 3.6. DCC executables and pythonservice.exe
    generally do not, which is why the prefix is still required.

Crossing between spellings — where the bugs live

  • Apply the prefix at the filesystem call; strip it exactly once where a path is
    surfaced (terminal, JSON, manifests other tools parse).
  • Retain it for internal filesystem consumers. That is job-attachments#67's invariant, and
    its items 1 to 5 came from stripping too early.
  • Some APIs go through normalization and therefore reject the prefix —
    win32security.Get/SetFileSecurity is the live example.
  • Path.resolve() preserves an existing \\?\ on 3.10+ but drops it on 3.8 and 3.9: a
    transition that silently varies by version.
  • Prefixing one call in a chain moves the failure to the next unprefixed one. Both repositories
    hit this repeatedly — job-attachments#67 items 1 to 5 and 7 to 8, and four times here.
  • Lexical versus symlink-aware is a real boundary. Collapsing .. lexically after a
    realpath-based check has already passed can write somewhere else, which is why .. is
    refused rather than collapsed in a prefixed path.

Why this class of bug recurs

  • Six path spaces, and code written assuming one.
  • The path space is encoded in a string prefix, so it is invisible unless you parse for it.
  • The stdlib's own answer moved three times inside the supported range, so version-independence
    has to be built rather than inherited.
  • Every failure mode here is a false negative — a valid path reported unknown, or a valid
    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.

@github-actions github-actions Bot added the waiting-on-maintainers Waiting on the maintainers to review. label Aug 7, 2026
Comment thread test/integ/windows_smb/test_unc_path_containment.py Fixed
Comment thread src/deadline/client/_path_utils.py
@crowecawcaw
crowecawcaw force-pushed the fix/unc-host-path-containment branch from 666fe0c to a1c2c95 Compare August 11, 2026 21:46
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>
@crowecawcaw
crowecawcaw force-pushed the fix/unc-host-path-containment branch from a1c2c95 to e56265f Compare August 11, 2026 22:01
Comment thread src/deadline/client/api/_submit_job_bundle.py Outdated
Comment thread .github/workflows/windows_smb_test.yml
…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>
Comment thread src/deadline/client/api/_submit_job_bundle.py
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>
@crowecawcaw
crowecawcaw marked this pull request as ready for review August 14, 2026 23:40
@crowecawcaw
crowecawcaw requested a review from a team as a code owner August 14, 2026 23:40
Comment thread .github/workflows/windows_smb_test.yml Outdated
run: |
hatch run pytest test/integ/windows_smb -v --no-cov -p no:randomly

- name: Report skips

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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:

  1. --numprocesses=auto is in addopts (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.
  2. The platform gate was the same hole. skipif(sys.platform != "win32") is a skip like any other, so a runs-on change 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>
@crowecawcaw

Copy link
Copy Markdown
Contributor Author

The review comment is correct, and the reason the Windows checks were green is that this test never ran in PR CI: windows_smb_test.yml triggers on workflow_dispatch / push: [mainline] / workflow_call only, and testpaths in pyproject.toml excludes test/integ, so nothing on a pull request reaches the file. Its first real run would have been the post-merge push to mainline.

Windows clamps .. at a share root — the root of \\server\share\... is \\server\share\, and ntpath.realpath starts with normpath, whose splitroot treats \\host\share as the drive with root \, so the leading .. is dropped rather than climbing to \\host:

os.path.realpath(os.path.join(r"\\host\share", "../escaped.txt"))  ->  \\host\share\escaped.txt

That is inside the destination, so _safe_zip_extract correctly does not raise and the pytest.raises failed. The lexical unit tests already say this (test/unit/deadline_client/test_path_utils.py:74-75); only this integ test contradicted them.

Verified on a real Windows runner against a real SMB share, both directions:

  • pre-fix commit: test_archive_extracts_at_a_share_root_and_still_rejects_an_escape FAILED ... Failed: DID NOT RAISE ValueError (1 failed, 9 passed)
  • post-fix: 12 passed

Fixed in baa5b9d, splitting the one test into three:

  • test_archive_extracts_at_a_share_root — the actual [Bug] _is_known_path fails path validation for host-level UNC roots (e.g. \\<host> ) #1321 regression, unchanged.
  • test_pardir_at_a_share_root_stays_on_the_share — pins the clamp against the real redirector, which is why a share-root destination needs no escape case.
  • test_archive_escaping_a_directory_on_a_share_is_rejected — the escape case, now with a subdirectory destination as suggested, so ../escaped.txt genuinely leaves it.

Worth noting no escape was ever possible here regardless: zipfile.extractall strips .. from member names itself, so the guard is the second line of defense. This was a wrong assertion, not a hole.

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>
@crowecawcaw

Copy link
Copy Markdown
Contributor Author

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.

  • _safe_zip_extract was the worse one, and it explains the original finding. is_path_contained's path_module default binds os.path at import time, so the one call site that relied on the default could not be pointed at ntpath the way every other call site can — each of those carries "read at call time, so tests can patch it". Reverting commit 0bcc8bf entirely failed zero tests in test/unit. Fixed by passing path_module explicitly, plus four cases: share-root destination, the .. clamp, an escape from a subdirectory, and D:evil (a drive-relative entry that survives the isabs check and discards the destination — ntpath.join(r"\\host\share", "D:evil") is "D:evil").
  • The pre-submission hook PATH check had no seam and no test on any platform: restoring pre-3.11 isabs semantics there passed all 3580 unit tests, while the same mutation at its sibling call site kills 14. Extracted _reject_relative_hook_path_values with an injectable path_module; it now pins both directions (\\host\share accepted — pre-3.11 isabs called that relative; \scene.ma and C:scene.ma rejected — isabs accepted the first through 3.12).
  • Related: the absolute-PATH-default check ran only on relative defaults, so it never evaluated under the injected ntpath. A share-root default is the spelling that diverges; match= matters because the containment check below raises the same type.

The tests-do-not-run problem was bigger than the one file. test/integ is locked out twice — pyproject.toml testpaths and hatch.toml:12, which hard-codes test/unit test/cli_e2e for the reusable build — so editing testpaths alone would change nothing. Meanwhile integ:test collected test/integ/windows_smb on both mainline CodeBuild legs and skipped all 12 silently, reading as coverage that did not exist. The SMB workflow now runs on pull_request for the paths it covers (verified: this push triggered it, event: pull_request, 12 passed), and the mainline integ script excludes the directory so one job owns it.

Two SMB tests were passing for the wrong reason. 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. It now pins the UNC rewrite and asserts both spellings, including that a mapped-drive root does not cover files resolved to UNC form. The symlink escape targeted the local C: spelling of a file inside the share, so rejection came from comparing path spaces, not from leaving the bundle; it now targets a sibling through the share and pins that realpath resolves the link at all.

Smaller fixes: the anti-climb backstop had one covering case (added the prefixed/device spellings whose .. survives normalization before 3.11 — verified on real 3.9, where components are [..., "t", "..", "evil", "f"]); the known-root filter is now asserted through a pre-3.11 normpath; is_absolute_path is pinned against a deliberately-wrong isabs since 3.13+ stdlib agrees with it and cannot fail the delegating form; five pytest.raises gained match=; the common_ancestor property test gained an assertion floor (it skips empty answers, so returning nothing for everything passed it vacuously); dropped a patch of abspath the filter never calls and three dead ruff exemptions.

Also corrected two comments: the \\?\ normalization boundary is 3.11, not 3.10 (measured on 3.9/3.10/3.11), and the splitroot shim's triples are not comparable across that boundary — the shim reads the running splitdrive, so freezing 3.12 literals for the older legs would assert the wrong thing.

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 workflow_call trigger on the SMB workflow is dead — nothing in the repo calls it, so released tags get no SMB validation, and wiring it into release_publish.yml is a release-pipeline change; and AGENTS.md claims an 80% coverage gate while pyproject.toml sets fail_under = 69 (pre-existing).

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>
@crowecawcaw

Copy link
Copy Markdown
Contributor Author

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. path_module=os.path in a signature binds at import, so any call site omitting it is unpatchable — that trap is what made the guard untestable. All seven helpers now take path_module=None and resolve in the body, so the omission is harmless and every call site is patchable whether or not it passes one.

Same #1321 bug found still live elsewhere. job_group._assert_valid_path validated a download root arriving over the JSON protocol with Path.is_absolutePureWindowsPath(r"\\host").is_absolute() is False before 3.13 and True from 3.13 (measured on 3.9/3.10/3.11/3.14), so a host-level UNC download root was rejected outright on four of six supported versions. It had no tests: both references to it in the suite patch it out. Now uses the version-independent helper; the old implementation fails 5 of the new cases.

Tests that asserted nothing. test_agrees_with_pathlib_except_for_unc_hosts — the PR's independent oracle — reached its assertions only for disagreeing pairs and continued otherwise, so it passed with zero assertions executed, including against an is_path_contained that just delegates to pathlib (i.e. this whole PR reverted). It now counts both sanctioned classes and asserts a floor; verified the delegating regression fails it. Separately, four TestSanitizeBundleName cases were written as if sys.platform == "win32": assert ... with no else, so half of that traversal guard's behavior went unverified on linux and macos; they now patch the sys.platform it reads at call time. Two pre-existing bare pytest.raises on the symlink guard gained match=.

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 test_splitroot_backport_matches_stdlib (no stdlib oracle before 3.12) and test_agrees_with_pathlib (skipif < 3.12), each with a version-independent counterpart that runs everywhere — no test added here skips on every leg. _path_summary.py is at 100% statement+branch coverage on all 18 legs; _path_utils.py 100% on 3.12+ and 98% below (the missing line is the 3.12-only splitroot delegation, correctly dead there). The SMB job: event = pull_request, DEADLINE_SMB_TESTS_REQUIRED=1, collected 12 items, 12 passed, 0 skipped, -rs produced no skip summary, SymlinkEvaluation reported ENABLED, 2m32s wall (~4s of it tests).

One correction to what I said earlier: running on pull_request makes this job visible, not blocking. Branch protection on mainline requires only the 18 Python legs plus DCO and Semantic PR, so a red SMB job cannot stop a merge today. That is inherent to the paths: filter — a filtered check sits permanently pending if made required — so making it required means dropping the filter and paying the Windows env install on every PR. Your call; also worth knowing Blender Submitter UI (Windows) carries continue-on-error: true and can never fail its workflow.

Unrelated flake, fixed here because it blocked this PR. test_deadline_login_dialog.py::test_monitor_login_keeps_its_own_message failed on macos 3.11 and reproduced locally under the full suite. The file is untouched by this branch (it came from #1323), but the race is in the test's own helper: it waited on an Event the background thread sets right after calling on_pending_authorization, then read dialog.text() — while the message crosses to the GUI thread on a queued signal, so that Event says nothing about whether the dialog applied it. It now waits for the text, bounded. Verified by injecting the delay a loaded runner produces: the old predicate fails with exactly the CI error, the new one passes.

Two things left alone, deliberately — both real, both outside this PR: ui/widgets/path_widgets.py:67,241 collapse $HOME with directory.startswith(home_dir) and then slice [len(home_dir) + 1:], so with a home of /Users/bob, picking /Users/bobby/projects/scene.ma displays ~/ar/projects/scene.ma and picking /Users/bob2/projects/x yields /projects/x — and deadline_config_dialog.py writes that text straight into job_history_dir / job_bundle_default_directory. That is the same string-prefix mistake, in the GUI. And cli/_groups/_batch_get.py:46 has an import-time-bound sleep=time.sleep default that both production call sites omit, so the retry/backoff path costs real wall-clock and is unpatchable. Happy to take either as a follow-up.

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>
@crowecawcaw

Copy link
Copy Markdown
Contributor Author

Correction to my last comment: the ~-collapse bug in ui/widgets/path_widgets.py is fixed here, not left for a follow-up. Both widgets carried the same copy-pasted block; it is now one helper using is_path_contained + relpath. With a home of /Users/bob: choosing /Users/bobby/projects/scene.ma previously displayed ~/ar/projects/scene.ma (the + 1 ate a character), and /Users/bob2/projects/x became /projects/x (join() drops the ~ when what follows is rooted) — both now come back untouched, and /Users/bob collapses to ~ rather than ~/. Covered in both path spaces including the sibling prefixes and Windows case-insensitivity; the previous implementation fails 8 of the new cases.

The _batch_get.py import-time-bound sleep=time.sleep default is the one still left for a follow-up.

Current state: 34 checks green, UNC Containment (real SMB) passing on pull_request at this head, and macos-latest 3.11 — the leg that failed earlier — green with the queued-signal race fixed. The branch is BEHIND mainline and will need an update before merge.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

waiting-on-maintainers Waiting on the maintainers to review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] _is_known_path fails path validation for host-level UNC roots (e.g. \\<host> )

3 participants