Skip to content

Fix vendored Hatch refs in %/#/;/? checkouts (#547) - #1428

Merged
Mikola Lysenko (mikolalysenko) merged 4 commits into
mainfrom
agent/fix-hatch-root-uri-unsafe-path
Oct 12, 2026
Merged

Mikola Lysenko (mikolalysenko) merged 4 commits into
mainfrom
agent/fix-hatch-root-uri-unsafe-path

Conversation

@mikolalysenko

@mikolalysenko Mikola Lysenko (mikolalysenko) commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

LLM Description written by Claude Code:claude-opus-5-5

Fixes #547

Summary

vendor / scan --mode vendored on a Hatch project now refuses
(pypi_hatch_unsupported, nothing written) when the project's real path
contains a character Hatch's {root:uri} expands into a broken file URL.
Before, the scan reported success and every Hatch env then failed to
install in that checkout. That includes a checkout that was vendored
somewhere else and then moved to such a path: the next vendor run
fails instead of reporting it as in sync.

Root cause

Vendored Hatch wiring writes name @ {root:uri}/.socket/vendor/pypi/<uuid>/<wheel>#sha256=….
Hatch expands {root:uri} with hatchling/utils/fs.py:path_to_uri,
which escapes only the space. With a %XX escape, #, ? or ;
in the path (e.g. a Jenkins multibranch workspace app_feature%2Fx),
pip decodes the escape, cuts the path at a fragment or query, or the
PEP 508 parser rejects the requirement. Nothing in
vendor/pypi_hatch.rs checked the root before writing.

Change

  • utils::hatch::root_uri_unsafe_char checks the real path (symlinks
    resolved, which is what Hatch expands) for a %XX escape, #, ?,
    ;, and whitespace or control characters other than the space. It looks
    only at named path components, so a Windows \\?\ prefix doesn't count.
    A % that starts no escape (100%) is allowed, because pip leaves it as is.
  • pypi_hatch::load and pypi_hatch::preflight refuse such a root.
    preflight runs before a superseded patch is unwound, so a refusal
    never touches the project's files.
  • docs/testing/hatch.md now says which paths a vendored checkout can't
    move to.

Hosted and agent mode are unaffected (hosted references are http URLs).
The npm, PyPI and gem wrappers only dispatch to the binary, so they need
no change.

Tests (red → green)

Issue Test Without the fix With the fix
#547 (scan in an unsafe path: %2F, %20, #, ;, ?) mode_migration_pypi::hatch_vendored_refuses_a_root_hatch_cannot_expand FAILED (exit 0, wiring written) ok
#547 (checkout relocated to app_feature%2Fsix-patch) mode_migration_pypi::hatch_vendored_rerun_refuses_after_relocation_to_unsafe_root FAILED (exit 0, in sync) ok
#547 (load/preflight, symlinked root, 100%/@/'/space controls, tab) socket-patch-core vendor::pypi_hatch::tests::root_hatch_cannot_expand_is_refused FAILED (load must refuse) ok

Local runs:

  • cargo fmt --all -- --check: clean.
  • cargo clippy --workspace --all-features -- -D warnings: clean.
  • cargo test --workspace --all-features --no-fail-fast: every Hatch and
    PyPI test passes. A few tests fail only because of this sandbox, and none
    of them touches the Hatch code. The sandbox runs as root, so chmod can't
    make a write fail, which breaks the covgap_commands_vendor state-write
    tests, the repair and in_process_redirect cleanup-failure tests, and
    4 core lib write-failure tests. e2e_bun_lockb fails because there is no
    network (missing_blob). CI runs as a normal user.
  • The real-Hatch e2e (e2e_vex_build hatch::) uses temp paths with none
    of these characters, so this change doesn't affect it. Left to CI.

Review

/code-review high raised 7 findings. These were fixed in 8d4e9ea:
check the real path, not the path as written; allow a % that starts no
escape; refuse whitespace and control characters; ignore the Windows
\\?\ prefix; stop the error message from repeating the character
list by hand.

Not fixed: when the root is refused in the supersede path, load and
then preflight both run the check. This only happens on a refusal and
costs one extra read of two small files, so it isn't worth a new
refusal code.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Gw7nKi8mXGzQ2JucUXxYRF


Generated by Claude Code


Note

Medium Risk
Changes vendored Hatch preflight/load behavior for checkouts under unusual paths; failures are explicit refusals rather than silent broken installs, with no impact on hosted/agent flows.

Overview
Fixes #547 by refusing vendored Hatch vendor / scan work when the project’s canonical checkout path contains characters Hatch’s {root:uri} does not escape (%XX, #, ?, ;, control/whitespace other than space). load and preflight now call require_expandable_root (via root_uri_unsafe_char) and return pypi_hatch_unsupported without writing files or reporting in-sync after a move to an unsafe path. Hosted/agent modes are unchanged.

Integration and unit tests cover unsafe Jenkins-style paths (app_feature%2Fsix-patch), symlink resolution, and safe paths (@, ', space, bare %). docs/testing/hatch.md documents the restriction.

Separately, test cache_env ambient scrubs snapshot the parent environment once through scrub_parent_vars so self-tests match the scrub and avoid races when in-process tests mutate env between reads.

Reviewed by Cursor Bugbot for commit 272c022. Configure here.

Assisted-by: Claude Code:claude-opus-5-5
Vendored Hatch wiring points at {root:uri}/.socket/vendor/..., but
Hatch expands {root:uri} escaping only spaces. In a checkout whose path
holds %, #, ? or ; (a Jenkins multibranch workspace like
app_feature%2Fx), pip decodes, truncates or rejects the URL, so every
Hatch env failed to install while the scan reported success.

vendor and scan --mode vendored now refuse such a root with
pypi_hatch_unsupported before writing anything, including a re-run of a
checkout that was vendored elsewhere and then moved. The hatch testing
doc notes the relocation limit.

Fixes #547

Assisted-by: Claude Code:claude-opus-5-5
Hatch builds {root:uri} from the real project path, so a checkout
reached through a symlink is now checked at its target. Only a % that
starts a %XX escape is refused (pip leaves 100% alone), and a tab or
other non-space whitespace or control character, which the PEP 508
parser stops at, is refused too. A Windows \\?\ prefix no longer counts
as a ?.

Refs #547

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

@mikolalysenko Mikola Lysenko (mikolalysenko) added the Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review label Oct 12, 2026
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Labeled Ready for review at head 8d4e9eabcf82a0400d58b5393ad8eca40d008b53.

  • CI: 35 green + 1 neutral, 50 path-skipped, no failures (no main-wide failures).
  • Bugbot: reviewed 8d4e9ea, no findings.
  • Mergeable, no conflicts; does not touch CHANGELOG.md.

Slack announcement not sent this run (Slack send unavailable); the next run will retry.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final review brief

What it does. Vendored Hatch wiring writes {root:uri}/.socket/vendor/... references, and Hatch's path_to_uri escapes only spaces. In a checkout whose real path has a %XX escape, #, ?, ; or non-space whitespace, every Hatch env then fails to install. vendor / scan --mode vendored now refuses that path with pypi_hatch_unsupported before writing anything, and so does a re-run after the checkout has been moved to such a path.

Risk: low. It only adds a refusal on the vendored Hatch path, which runs in load and preflight (preflight runs before any superseded wiring is unwound). Hosted and agent modes are untouched. revert doesn't call either function, so a moved checkout can still be unvendored.

Look here

Verified

  • Read the full diff (4 files, +193/−3). CHANGELOG.md is untouched and the change stays in scope.
  • Checked that the real path is the right one to test: Hatch takes the project root from os.getcwd(), which is the physical path. The ? and tab cases only run on Unix, since Windows file names can't contain them.
  • Checked every caller of load and preflight in vendor/pypi.rs (fresh, in-sync and supersede paths). Uninstall/revert doesn't go through the guard.
  • Ran cargo test -p socket-patch-core --lib root_hatch_cannot_expand locally: it passes.
  • CI is green on 8d4e9ea: ci-ok and clippy both pass. Bugbot is clean on 8d4e9ea.

Changes I made: none.

Open questions: none. Nit, not blocking: on a supersede refusal the check runs twice, once in load and once in preflight. The PR description already explains this and accepts the cost.

Auto-merge is armed, so approving this sends it straight to the merge queue.


Generated by Claude Code

@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 12, 2026
The coverage job failed this PR's merge-queue run (2ea36d6). That job
flakes in in_process_edge_cases (#1416): the cache_env scrub
self-tests read the parent env twice while in-process tests set
SOCKET_OFFLINE. Port #1418's fix (one snapshot of the parent env) so
the queue run is not at the mercy of that race. It no-ops once #1418
lands.

Refs #1416

Assisted-by: Claude Code:claude-opus-5-5
@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

[agent] This PR was dequeued because coverage failed in its merge-queue run (run 38273391986, merge commit 2ea36d6). The same job passed on this PR's head, 8d4e9ea.

I can't read the job log from this sandbox: the log host returns 403, which is the same block noted on #1416. The annotation only says exit code 101. The most likely cause is the known in_process_edge_cases flake (#1416), where the cache_env scrub self-tests race an in-process test that sets SOCKET_OFFLINE. Nothing in this diff touches that code.

#1418 fixes that flake and is still open, so I ported its one-file test change in 272c022. It becomes a no-op once #1418 merges. in_process_edge_cases passes locally, and fmt is clean.

Auto-merge was disarmed when the PR was dequeued. It needs to be re-queued once CI is green on 272c022.


Generated by Claude Code

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

BugBot review


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 272c022. Configure here.

@mikolalysenko

Copy link
Copy Markdown
Collaborator Author

Final reviewer: re-enqueued once after the 16:02 merge-queue eviction. Head 272c022 (carries the ported #1418 coverage-flake fix) is approved on head, ci-ok and clippy green, no open threads, mergeable.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Ready for review Agent-verified: mergeable, CI green, Bugbot clean — awaiting human review

Projects

None yet

3 participants