Where things stand
- 2015
pytest-of-$USERsubdirectory introduced (#105) so the numbered-suffix scan doesn't walk all of/tmp; renamed for version collisions (#894). Predictable base becomes a deliberate UX feature. - Aug 2025 orlitzky reports symlink + TOCTOU attacks on the predictable name (#13669); later assigned CVE-2025-71176 / GHSA-6w46-j5rx-g56g (CVSS 3.1: 6.8 Medium; CWE-379, CWE-367).
- Mar 2026
PR #14279 (comprehensive:
mkdtemprandom rootdir + fd-based hardening, ~580 additions) reviewed over several rounds. - Apr 2026 PR #14343 (minimal: ~30 lines, no behavior change) merged instead and shipped in 9.0.3; #14279 closed. Advisory metadata says "fixed in 9.0.3".
- Jun 2026 Dispute over "fixed" status; RonnyPfannschmidt's comparison table concedes the anti-pattern remains and the comprehensive approach "may be worth revisiting in a future major release". Bridge offer accepted as a discussion topic — "needs bikeshedding first, maybe at the pytest sprint".
Residual gaps after 9.0.3
#14343 stats/chmods the rootdir with follow_symlinks=False and rejects a symlinked
rootdir outright. What it deliberately leaves open:
✗ Open · Squat DoS
Any local user can pre-create /tmp/pytest-of-<victim>; the victim's runs
then fail the ownership check with an OSError they cannot fix themselves.
Acknowledged verbatim in the shipped code comment ("give the prankster a good scolding").
◐ Partial · TOCTOU window
Checks are path-based (stat → S_ISLNK → use), not fd-based. A race
between check and use is narrowed, not eliminated — vs. #14279's
O_NOFOLLOW|O_DIRECTORY + fstat/fchmod on a held fd.
✗ Open · The anti-pattern itself
A predictable name in world-writable /tmp remains the architecture. Every check
added is cataloguing "bad things someone might do" instead of removing the shared-parent
trust problem (orlitzky's core objection).
◐ Quiet edges · Coverage asymmetries
Where os.supports_follow_symlinks lacks support, the code silently falls back to
following symlinks. The --basetemp branch bypasses all hardening. Checks
are skipped entirely on Windows/Emscripten (mostly moot — see platform matrix below).
Constraints any bridge proposal must satisfy
Collected from maintainer feedback across #13669, #14279, and #14343 — these are the objections that decided the outcome last time:
bluetech's four review objections
- No warnings users can't act on (
avoids_symlink_attackscase). - Don't duplicate
shutil.rmtree's own symlink refusal without justification. - Don't re-verify what
mkdtempalready guarantees without justification. - Never scan all of
/tmpper cleanup — the subdirectory existed precisely to bound that scan (2015, #105).
Retention & discoverability (webknjaz, Ronny)
- "Last N runs" retention (
tmp_path_retention_count, default 3) needs a way to find prior runs — pure randomness breaks GC. - Humans navigate to
/tmp/pytest-of-me/pytest-current; that UX is an intentional feature. - Ronny's stated unlock: "if we provide a command to list/gc tmpdirs we are in a position to use non-obvious dirnames and avoid subdirs."
Compatibility budget
- The template
{temproot}/pytest-of-{user}/pytest-{num}/{testname}/is documented public contract (tmp_path.rst); changing it needs a major release + deprecation story. pytest-currentsymlinks,.lockfiles, andgarbage-*rename-then-delete are incidental (undocumented) — changeable, but in-tree tests assert on some of them.- Breaking changes were the stated reason #14279 lost; "minimal until proven otherwise."
Threat-model honesty
- Defend the user from other users, not from themselves (bluetech's framing).
- Multi-user is real: shared dev clusters, web hosts, CI multi-tenancy, low-privilege service accounts (orlitzky, pgunn, The-Compiler).
- But pytest is usually a dev tool on single-user boxes (Ronny) — proportionality matters, and perfect is not on the menu.
The approaches, compared
Marks pair a symbol with a word: ✓ closed · ◐ partial · ✗ open.
| Aspect | A · Harden in place fd-based checks + random fallback on squat |
B · Flat random #14279: pytest-of-user-XXXX/ per session |
C · Nested random hardened parent / random session dirs |
D · Relocate base XDG / user-private dir chain |
|---|---|---|---|---|
| Symlink attack | ✓ closed (fd-based, no follow) | ✓ closed (mkdtemp, by construction) | ✓ closed (parent fd-verified + random children) | ✓ closed (no shared parent at all) |
| TOCTOU race | ✓ closed at rootdir (held fd) | ✓ closed | ✓ closed (if parent ops use dir_fd) |
✓ closed (parent trusted by OS construction) |
| Squat DoS | ◐ degraded — squat detected → warn + fall back to mkdtemp rootdir; run proceeds |
✓ closed | ◐ parent squattable — needs A's fallback to close | ✓ closed where a private base exists; fallback path inherits its own column |
| Predictable-name anti-pattern | ✗ remains (verified, but still shared-/tmp) | ✓ removed | ◐ parent remains | ✓ removed — predictable name becomes safe inside a user-owned base |
| Breaking change | ✓ none (fallback path only under attack) | ✗ yes — documented template, pytest-current, per-session layout |
◐ moderate — session dir names change; parent + nesting kept | ◐ moderate — location changes, structure can stay identical |
| Findability / UX | ✓ unchanged | ✗ needs help — prefix + mtime sort, or E | ◐ mostly kept — grouping dir survives; numbered names don't | ✓ kept — stable, documentable per-platform path; pytest-current can stay |
| Retention / GC cost | ✓ unchanged (scoped scan) | ✗ temproot scan — bluetech's #4, unless E's registry | ✓ scoped scan inside parent | ✓ scoped scan; XDG_CACHE variant means pytest owns lifetime (it already does retention) |
| Complexity | ◐ moderate — fd plumbing + fallback branch | ◐ large — ~580 additions in #14279 | ◐ large — everything in A plus layout migration | ◐ moderate-large — base-selection chain + per-platform docs |
| Champion / origin | bluetech's floor, extended; fallback idea bridges orlitzky's DoS point | orlitzky, #14279 (laurac8r); The-Compiler opt. 2 | nicoddemus, The-Compiler opt. 3 (with his own caveat) | nirs (XDG_RUNTIME_DIR), The-Compiler opt. 4 |
A · Harden in place, degrade gracefully
/tmp/pytest-of-laura/ ← verified via O_NOFOLLOW fd pytest-0/ pytest-1/ pytest-current@ # under squat attack only: /tmp/pytest-of-laura-x7Qz3a/ ← mkdtemp fallback + warning
- Open rootdir with
O_NOFOLLOW|O_DIRECTORY, verify withfstat, tighten withfchmod, resolve children viadir_fd— the TOCTOU window closes because the fd pins the object. - The new move: on ownership failure, don't hard-fail — fall back to a
mkdtemp-random rootdir and warn. Converts the DoS into a degraded-but-working run. Directly answers orlitzky's "I can't fix it!" scenario. - Ships in a 9.x minor; zero behavior change for unattacked users. Unix-only plumbing
(
dir_fdunsupported on Windows) mirrors the existingsupports_follow_symlinksgating — and Windows was never exposed.
B · Flat random per session (#14279's shape)
/tmp/pytest-of-laura-BorMuC/ ← session 1 (mkdtemp) /tmp/pytest-of-laura-6YmIOO/ ← session 2 /tmp/pytest-of-laura-guESQy/ ← session 3
- Strongest single-mechanism fix: nothing predictable to squat or symlink; battle-tested
tempfile.mkdtempdoes the security work. - Costs are exactly the ones that killed #14279: documented-template break,
pytest-currentgone, retention GC must scan the shared temproot (bluetech #4), more/tmpnoise (nicoddemus). - Viable only at a major release, and only with the discovery/GC gap answered — i.e., B really means B+E.
C · Hardened parent, random children
/tmp/pytest-of-laura/ ← still predictable (squattable) pytest-1-N93sIT/ pytest-2-iKTFoY/ pytest-3-EUIRYH/
- Keeps the grouping dir (GC scan stays scoped,
/tmpstays tidy) and randomizes session dirs beneath it. - Honest assessment: inside a verified 0700 parent, an attacker can't write — so randomizing children adds little security beyond A. The parent squat (DoS) is untouched; The-Compiler's own caveat ("same issue pushed a level down") applies whenever parent verification is skipped or wrong.
- Buys concurrency robustness (no numbered-suffix races under xdist/parallel sessions) more than attacker resistance. Pay the layout break only if that's the goal.
D · Move out of shared /tmp entirely
Linux: $XDG_RUNTIME_DIR/pytest/pytest-0/ (0700, spec-guaranteed) or: $XDG_CACHE_HOME/pytest/tmp/pytest-0/ (durable; pytest GCs) macOS: $TMPDIR → /var/folders/…/T/pytest-of-laura/ (already private) Win: %LOCALAPPDATA%\Temp\pytest-of-laura\ (already private) else: fall back to A (hardened /tmp scheme)
- Dissolves the problem instead of defending it: predictable names are safe inside a
parent only the user can write. macOS/Windows already work this way — the vulnerability is
Unix-shared-
/tmp-specific. XDG_RUNTIME_DIRcaveats: tmpfs (RAM-backed, ~10% RAM cap) — risky for large artifacts; 6-hour reaping rules; absent under cron, plainsu, minimal containers.XDG_CACHE_HOMEflips the tradeoff: durable disk, always present, but pytest owns cleanup (it already has retention) and NFS homes can hurt (locking, perf, quotas).- Needs a precedence story (
--basetemp›PYTEST_DEBUG_TEMPROOT› ini opt-in › platform default) and a deprecation cycle for the documented location — the structure beneath can stay byte-identical.
E · Discovery & GC command — the enabling layer, not a competitor
pytest --tmpdirs list / --tmpdirs gc / --tmpdirs latest
(spelling negotiable), backed by a small per-user registry (e.g. under
$XDG_STATE_HOME/pytest/) recording recent rootdirs.
- Answers Ronny's condition verbatim: a list/gc command is what makes "non-obvious dirnames" acceptable.
- Answers webknjaz: retention reads the registry, not directory-name arithmetic — GC without any shared-temproot scan (bluetech #4 resolved for B too).
- Replaces
pytest-currentwhere symlinks are unsafe/absent (--tmpdirs latestprints the path); keeps it where safe (inside D's private base). - Prior art for the pattern:
go clean -cache+go env GOCACHE— findable via a documented command rather than a guessable path.
Recommended bridge: stage it
Phase 1 9.x minor — A + E groundwork
- fd-based rootdir verification (close the TOCTOU residue) — no visible change.
- Squat → warn +
mkdtempfallback (close the DoS as a felt problem) — visible only under attack. - Introduce the registry +
--tmpdirscommand; document the threat model and the--basetempbypass asymmetry. - Each item answers a named objection without touching the documented path template — "minimal until proven otherwise" is respected.
Phase 2 next major — D by default, B semantics as fallback
- Per-platform private base (macOS/Windows: unchanged; Linux: XDG chain with an ini opt-out)
— keeps numbered dirs, retention, and
pytest-currentbecause the parent is now trustworthy. - Where no private base exists (bare containers, exotic Unix), use mkdtemp-random rootdirs
in
/tmpwith registry-driven discovery/GC (B+E). - Deprecation cycle for the documented template; changelog + migration note for scripts that
hardcode
pytest-of-*paths.
Why this split: Phase 1 is shippable without re-litigating UX (it changes nothing visible), while Phase 2 preserves the UX everyone defended — predictable, navigable paths — by relocating them somewhere predictability is harmless. The sprint discussion can accept, reorder, or strike each line independently.
Best practices & prior art worth citing
Use the battle-tested primitive
tempfile.mkdtemp is atomic and race-free by construction; hand-rolled check
sequences must catalogue every attack (hardlinks-to-dirs are POSIX
implementation-defined; bind mounts defeat link counting). ssh-agent's
/tmp/ssh-XXXXXXXXXX/ is the canonical shape. npm shipped the same predictable-name
bug as CVE-2013-4116.
Warnings must be actionable
Both reviewers converged on this: don't warn about avoids_symlink_attacks when the
user can do nothing. Either bail out with a clear error, degrade with a stated consequence, or
stay silent and document.
Bound your scans
The 2015 subdirectory (#105) exists because suffix-scanning all of /tmp hurt once.
Any design that reintroduces a shared-temproot scan per session will meet the same objection —
scope scans to an owned directory or replace them with a registry.
Defense-in-depth must earn its lines
Re-verifying mkdtemp output or duplicating shutil.rmtree's symlink
refusal reads as noise to reviewers unless each check names the concrete scenario it stops
(e.g. exotic umask/ACL → one unconditional chmod 0700 is enough).
Platform matrix, stated up front
The bug class exists only where a world-writable parent is shared: Linux/BSD
/tmp. macOS (/var/folders, 0700 per user) and Windows
(per-user %TEMP% ACLs) are structurally immune — so Unix-only hardening code
(dir_fd, O_NOFOLLOW) costs nothing where it's unavailable.
Advisory hygiene
The GHSA says "fixed in 9.0.3" while the issue thread disputes it. Whatever the sprint decides, feed the outcome back into the advisory (annotate residual DoS as accepted risk, or re-scope the CVE) — the June friction came from that gap, not from the code.
Open questions for the sprint
- Is the squat→fallback behavior (A) acceptable semantics, or should a squat still hard-fail
under a strict opt-in (
tmp_path_strict_ownership=true)? - XDG_RUNTIME_DIR vs XDG_CACHE_HOME as the Linux Phase-2 default — RAM-backed ephemerality vs durable disk that pytest must GC. (Large-artifact suites argue for CACHE.)
- Where do XDG vars slot into precedence relative to
TMPDIRandPYTEST_DEBUG_TEMPROOT? Opt-in ini first, default flip later? - Does
--basetempget any hardening, or is "you asked for exactly this path" documented as the contract? - Registry format/location for E — and does
pytest-currentsurvive as a symlink inside private bases, or is--tmpdirs latestthe sole replacement? - Backport stance: is Phase 1 (invisible hardening) backportable to 9.0.x, or does even that wait?