Skip to content

[v5] plumbing: revlist, Skip path validation in the object walk - #2305

Merged
pjbgf merged 2 commits into
go-git:releases/v5.xfrom
r0h1tb:fix/revlist-skip-path-validation
Aug 8, 2026
Merged

pjbgf merged 2 commits into
go-git:releases/v5.xfrom
r0h1tb:fix/revlist-skip-path-validation

Conversation

@r0h1tb

@r0h1tb r0h1tb commented Aug 8, 2026

Copy link
Copy Markdown

[v5] plumbing: revlist, Skip path validation in the object walk

Fixes #2251

Targets releases/v5.x. main is unaffected — the v6 object walk was rewritten
to read Tree.Entries directly and no longer goes through TreeWalker.

Problem

Push computes the set of objects to send with revlist.Objects. If any
reachable tree in history contains an entry whose name holds a control
character, the whole push aborts before any network transfer:

invalid path "\x1b\x1b\x1b": contains control character

The offending entry does not have to be part of the commit being pushed. In the
reported case it was deleted years earlier and its objects were already on the
remote, so they would never have been sent. Upstream Git pushes such a
repository without complaint: verify_path forbids only / and NUL inside a
path component.

Root cause

iterateCommitTrees enumerates entries through TreeWalker.Next, which since
#2105 validates every name:

// plumbing/object/tree.go
if err := pathutil.ValidTreePath(entry.Name); err != nil {
    return name, entry, err
}

ValidTreePath exists so that callers materialising entries into a worktree
or archive stay safe. The revlist walk materialises nothing — it only collects
hashes to decide what to send — but a validator error propagates out of
iterateCommitTrees and fails the entire walk.

This is the same defect #2222 fixed for the tree diff walk, at a call site that
fix did not cover.

Fix

TreeWalker gains an opt-out, and revlist takes it:

treeWalker := object.NewTreeWalker(tree, true, seen)
treeWalker.SkipPathValidation()

Validation stays on by default; only this inspection-only walk opts out.
Path safety for callers that do materialise names is untouched and still
enforced at FindEntry, TreeEntryFile, archive and FileIter.

Why an exported method rather than the unexported field #2222 used. On
main the only opt-out caller is treeNoder, which lives in package object
and can set the field directly. revlist is a separate package, so v5 needs an
exported entry point. SkipPathValidation() is the smallest such surface I
could find — happy to reshape it into an exported field, a NewTreeWalker
variant, or an internal/ accessor if you'd prefer not to grow the v5 API.

A subtlety worth flagging

Next checks its seen set before validating, so an entry whose blob is
still reachable from a newer tree is skipped before the validator sees it. My
first attempt at a regression test reused one blob across both trees and
passed against the unfixed branch. Content that was genuinely deleted has a
blob nothing else references, which is exactly why the reported case involves
history. Both tests now use distinct blobs.

What I deliberately left out

  • No change to ValidTreePath itself, and no relaxation of any
    materialisation boundary. Only the walk that decides which objects to send.
  • No v6 change — the bug does not exist there.
  • No backport of the archive/FileIter call sites. Those do materialise
    names, so validation belongs there.

The second commit

plumbing: object, Skip path validation in tree diff walk backports #2222,
which landed on main but never on releases/v5.x. I found it by checking the
sibling call site. On this branch DiffTree still fails:

from: invalid path "\x1b\x1b\x1b": contains control character

It is a separate commit precisely so you can drop it if you'd rather track the
v5 backport on its own — the first commit stands alone.

Tests

Two new files, both failing on the unfixed branch with the reported error:

$ git stash                       # revert source, keep tests
$ go test ./plumbing/revlist/ -check.f ControlCharacter

FAIL: revlist_pathvalidation_test.go:80: RevListSuite.TestRevListObjects_ControlCharacterInHistory
... ("invalid path \"\\x1b\\x1b\\x1b\": contains control character")

FAIL: revlist_pathvalidation_test.go:121: RevListSuite.TestRevListObjects_ControlCharacterInPushedCommit
... ("invalid path \"\\x1b\\x1b\\x1b\": contains control character")

OOPS: 0 passed, 2 FAILED
$ git stash -- plumbing/object/treenoder.go
$ go test ./plumbing/object/ -check.f DiffTreePathValidation

FAIL: difftree_pathvalidation_test.go:54: DiffTreePathValidationSuite.TestDiffTree_ControlCharacterEntry
... ("from: invalid path \"\\x1b\\x1b\\x1b\": contains control character")

OOPS: 0 passed, 1 FAILED

With the fixes applied, all three pass.

The trees are written through Tree.Encode, which rejects only NUL — the same
constraint upstream Git applies — so the fixtures hold names Git itself would
accept, rather than anything synthetic.

Verification

go test -count=1 ./... on macOS 26.1 arm64, Go 1.26.0:

ok FAIL no test files
baseline (releases/v5.x @ 42852fd) 53 0 7
with both commits 53 0 7

Package-level results are byte-identical to the baseline. No pre-existing
failures on this branch in my environment, with one caveat worth naming: on the
very first cold run plumbing/transport/git failed 12 tests with
dial tcp [::1]:PORT: connect: connection refused. It passed on every
subsequent run including the recorded cold baseline, so I treated it as a
local port-binding flake in that suite's git daemon setup, not a real result.

gofmt -l is clean on all five changed files, and go vet ./... is clean.
(gofmt -l . reports 30 pre-existing files, e.g. plumbing/format/commitgraph/*,
none of them touched here.)

Reshaping

Happy to split the two commits into separate PRs, drop the second, rename or
restructure SkipPathValidation, or move the regression tests into the existing
revlist_test.go / difftree_test.go if you'd rather not have new files.

r0h1tb added 2 commits August 8, 2026 10:34
…git#2251

iterateCommitTrees enumerates every reachable tree entry through
TreeWalker.Next, which validates each name with pathutil.ValidTreePath.
That validator exists so callers materialising entries into a worktree or
archive stay safe. The revlist walk does neither: it only collects hashes
to decide which objects to send.

The effect was that a single tree entry whose name upstream Git accepts —
one containing a control character, which verify_path permits since only
'/' and NUL are forbidden in a component — aborted the whole walk, so
Push failed with

    invalid path "\x1b\x1b\x1b": contains control character

even when the offending entry existed only in history, was already present
on the remote, and would never be sent.

TreeWalker gains SkipPathValidation, which revlist calls. This is the same
reasoning applied to the tree diff walk in go-git#2222; that fix set the flag
in-package, whereas revlist is a separate package and needs an exported
entry point.

Note the seen-set check in Next runs before validation, so an entry whose
blob is still reachable from a newer tree never reaches the validator.
Deleted content has a blob nothing else references, which is why the
reported case involved history rather than the pushed commit.
Backport of go-git#2222, which landed on main but not on this branch.

DiffTree walks each tree through treeNoder.Children -> transformChildren
-> TreeWalker.Next, which validated every entry name with
pathutil.ValidTreePath. That validator protects callers materialising
entries into a worktree or archive; DiffTree does neither, computing a
diff in memory without ever writing a name to disk.

So a diff over a tree containing an entry name upstream Git accepts —
one with a control character, which verify_path permits since only '/'
and NUL are forbidden in a component — failed with

    from: invalid path "\x1b\x1b\x1b": contains control character

instead of reporting the change.

This is the same defect as the revlist walk fixed in the previous commit,
at the sibling call site. It is split out so it can be dropped or landed
separately.
@r0h1tb

r0h1tb commented Aug 8, 2026

Copy link
Copy Markdown
Author

Behavioural delta: Push no longer aborts when a reachable tree in history holds an entry name upstream Git accepts. Nothing else changes — validation stays on by default everywhere it already was.

Reproducing the failure without the fix, from a checkout of this branch:

$ git stash                                              # revert source, keep tests
$ go test -count=1 ./plumbing/revlist/ -check.f ControlCharacter
FAIL: RevListSuite.TestRevListObjects_ControlCharacterInHistory
... ("invalid path \"\\x1b\\x1b\\x1b\": contains control character")
FAIL: RevListSuite.TestRevListObjects_ControlCharacterInPushedCommit
... ("invalid path \"\\x1b\\x1b\\x1b\": contains control character")
OOPS: 0 passed, 2 FAILED

$ git stash pop && git stash -- plumbing/object/treenoder.go
$ go test -count=1 ./plumbing/object/ -check.f DiffTreePathValidation
FAIL: DiffTreePathValidationSuite.TestDiffTree_ControlCharacterEntry
... ("from: invalid path \"\\x1b\\x1b\\x1b\": contains control character")
OOPS: 0 passed, 1 FAILED

Three things I would rather flag myself than have you find:

  1. This adds exported API to a stable line. TreeWalker.SkipPathValidation() exists only because revlist is outside package object and cannot set the field the way plumbing: object, skip path validation in tree diff walk #2222 did. If growing the v5 surface is unwelcome, an exported field or an internal/ accessor works just as well — say which and I will redo it.

  2. My first regression test passed against the unfixed branch. Next consults its seen set before validating, so reusing one blob across both trees meant the bad entry was skipped before the validator saw it. Both tests now use distinct blobs, which is what makes the historical case reach the validator at all.

  3. The second commit is scope beyond the issue. It backports plumbing: object, skip path validation in tree diff walk #2222, which never reached this branch, after checking the sibling call site. Drop it if you would rather track that separately — the first commit stands alone.

I also confirmed this does not affect main: the v6 object walk reads Tree.Entries directly and never goes through TreeWalker, so only releases/v5.x needs it.

@pjbgf pjbgf left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@r0h1tb thanks for working on this. 🙇

@pjbgf
pjbgf merged commit d031897 into go-git:releases/v5.x Aug 8, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants