Skip to content

fix: normalize the paths callers supply to validate - #126

Draft
perryqh wants to merge 2 commits into
mainfrom
fix/normalize-supplied-paths
Draft

fix: normalize the paths callers supply to validate#126
perryqh wants to merge 2 commits into
mainfrom
fix/normalize-supplied-paths

Conversation

@perryqh

@perryqh perryqh commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

validate <paths> compared caller-supplied paths against project-relative ones without reducing them to the same form first. What that costs depends on the shape of owned_globs, and both outcomes are wrong:

owned_globs shape what happens to a mishandled path symptom
directory-anchored ({gems,ruby,…}/**/*.rb) matches nothing, dropped before any ownership query exit 0, having checked nothing
**-leading (**/*.rb) survives the filter, queried in the caller's spelling well-owned file reported unowned

The first is the dangerous one: silent, and in the unsafe direction. A pre-commit hook or CI job reports success on a file it never looked at.

Split out of #125 so it can be reviewed on its own — it fixes bugs that exist on main today, independent of that PR's premise.

The path forms that hit this

./ruby/app/x.rb and ruby/a/../x.rb were never reduced at all.

Absolute paths were reduced with strip_prefix against a root that need not agree with them about symlinks. cli.rs canonicalizes --project-root, so on macOS — where TMPDIR lives under /var, a symlink to /private/var — a caller passing the TMPDIR spelling fails to strip:

codeowners --project-root /var/folders/.../tmp --no-cache validate \
  /var/folders/.../tmp/ruby/app/unowned.rb
code=0
stdout=""

And a library caller building its own RunConfig — which is how the code_ownership gem calls in — can pass an unresolved root against a resolved path, the mirror image:

root      = /var/folders/.../.tmpXld81u
file      = /private/var/folders/.../.tmpXld81u/ruby/app/unowned.rb
errors    = []          ← exit 0, genuinely unowned file never checked

Fixing only one side leaves the other failing exactly as silently, so the retry resolves both. The first attempt uses the root as given, so relative paths — the common case — cost no syscalls, and the root is resolved once per run rather than once per path.

Absolute paths were also echoed back in the caller's spelling rather than project-relative, since the raw string was what got reported.

The fix

path_utils::project_relative resolves . and .. lexically and reports failure rather than passing an unstrippable path through — which is how a /var/... path came to be compared against project-relative ones in the first place.

Lexically, not by canonicalizing: the project walk records symlink paths rather than their targets, so resolving symlinks here would produce paths that match no walked file.

Deleted paths are now skipped

A changeset that deletes a file lists it, so a deleted path reaches validate in normal use — and a deleted file cannot have an owner. Reporting it as unowned fails a commit for removing code, which gv <deleted file> did.

The gem already filters its list by File.exist? before calling in, so this matches what its callers see today and extends it to direct library callers. Only a definite "not there" skips: try_exists().unwrap_or(true) keeps a path whose status is unknown, because a visible error is investigable and a silent pass is not.

A fixture that did not contain what three tests claimed

Three tests asserted on valid_project/ruby/app/unowned.rb, which does not existvalid_project has to validate cleanly, so it ships no unowned file. They passed only because a nonexistent path was reported as unowned, which means they were covering typo handling while claiming to cover unowned files. Skipping nonexistent paths removes that accident.

Repointed at invalid_project, which has a real one, and narrowed to assert the path and exit status rather than the category wording, so they don't depend on how the report is phrased.

The tests assert the mechanism, not the outcome

Worth calling out, because the first draft of tests/supplied_path_normalization_test.rs was nearly worthless and passed against unfixed code.

It pointed at invalid_project, whose **-leading globs mean a mishandled path is spuriously reported rather than dropped — and the assertion only checked that "unowned.rb" appeared somewhere in the output. So it passed for entirely the wrong reason: only 2 of 9 tests failed against unmodified main.

Rewritten against valid_project (anchored globs, where mishandling drops the path) with an injected unowned file, and now asserting that the report names the normalized path and does not echo the caller's spelling. Without that, a test cannot distinguish "checked correctly" from "mishandled and spuriously reported."

7 of the 9 fail without this change. The 2 that pass are the plain-relative control and the outside-the-project skip, both of which already worked. One test covers the **-glob direction, since the symptom there is the opposite.

Note for reviewers

Runner::validate_files here is the existing read-CODEOWNERS-back implementation. #125 replaces that method wholesale, so the ~30 lines of normalization wiring inside it are transitional — but path_utils::project_relative, project_relative_path, the fixture correction and the whole test file survive that change unaltered. #125 will be rebased onto this branch.

Unrelated, found while doing this: the pre-commit hook fails in any git worktree. Git exports GIT_DIR to hooks, the tests spawn git subprocesses that inherit it and read the wrong repository, and 11 git-dependent unit tests fail. Reproducible against unmodified main with GIT_DIR=<path> cargo test --lib. Worth a separate fix in .rusty-hook.toml.

🤖 Generated with Claude Code

`validate <paths>` compared caller-supplied paths against project-relative ones
without reducing them to the same form first. What that costs depends on the
shape of owned_globs, and both outcomes are wrong.

Under directory-anchored globs (`{gems,ruby,...}/**/*.rb`), the mishandled path
matches nothing, is dropped before any ownership query runs, and the command
exits 0 having checked nothing. Silent, and in the unsafe direction: a
pre-commit hook or CI job reports success on a file it never looked at.

Under `**`-leading globs (`**/*.rb`), it survives the filter instead and is
queried in the caller's spelling, which matches no CODEOWNERS entry, so a
well-owned file is reported unowned.

Three spellings hit this. `./ruby/app/x.rb` and `ruby/a/../x.rb` were never
reduced at all. Absolute paths were reduced with strip_prefix against a root
that need not agree with them about symlinks: cli.rs canonicalizes
--project-root, so on macOS, where TMPDIR lives under /var, a symlink to
/private/var, a caller passing the TMPDIR spelling fails to strip -- and a
library caller building its own RunConfig (which is how the code_ownership gem
calls in) can pass an unresolved root against a resolved path, the mirror image.
Fixing only one side leaves the other failing exactly as silently, so the retry
resolves both. Absolute paths were also echoed back in the caller's spelling
rather than project-relative, since the raw string was what got reported.

path_utils::project_relative resolves `.` and `..` lexically and reports failure
rather than passing an unstrippable path through, which is how a /var/... path
came to be compared against project-relative ones in the first place. Lexically,
not by canonicalizing: the project walk records symlink paths rather than their
targets, so resolving symlinks would produce paths matching no walked file. The
first attempt uses the root as given, so relative paths -- the common case --
cost no syscalls, and the root is resolved once per run rather than per path.

Paths that no longer exist are now skipped. A changeset that deletes a file
lists it, so a deleted path reaches validate in normal use, and a deleted file
cannot have an owner -- reporting it as unowned fails a commit for removing
code. `gv <deleted file>` did exactly that. The gem already filters its list by
File.exist? before calling in, so this matches what its callers see and extends
it to direct library callers. Only a definite "not there" skips:
try_exists().unwrap_or(true) keeps a path whose status is unknown, because a
visible error is investigable and a silent pass is not.

Three tests asserted on valid_project/ruby/app/unowned.rb, which does not
exist -- valid_project has to validate cleanly, so it ships no unowned file.
They passed only because a nonexistent path was reported as unowned, meaning
they covered typo handling while claiming to cover unowned files, and skipping
nonexistent paths removes that accident. Repointed at invalid_project, which has
a real one, and narrowed to assert the path and exit status rather than the
category wording, so they do not depend on how the report is phrased.

The new tests assert the mechanism rather than the outcome: that the report
names the *normalized* path and does not echo the caller's spelling. Without
that they cannot distinguish "checked correctly" from "mishandled and
spuriously reported" -- an earlier draft of this file, written against
invalid_project's `**` globs, passed against unfixed code for precisely that
reason. Seven of the nine fail without this change; the two that pass are the
plain-relative control and the outside-the-project skip, both of which already
worked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two findings from reviewing the previous commit, one of them a false pass it
introduced.

The retry canonicalized the whole supplied path, which follows a symlinked
*file*. The project walk records the symlink path rather than its target -- the
reason lexically_normalize is lexical in the first place, stated in its own doc
comment two lines above the code that violated it. So an absolute path naming an
unowned symlink was silently checked as its owned target and exited 0:

    validate ruby/app/models/link_unowned.rb              -> exit 1  (correct)
    validate /tmp/proj/ruby/app/models/link_unowned.rb    -> exit 0  (false pass)
    realpath(link_unowned.rb) = /private/tmp/proj/ruby/app/models/payroll.rb

A false pass on a different file than the caller named, which is the exact
failure class this branch exists to rule out. The retry now resolves the parent
and re-attaches the file name, so the ancestor /var -> /private/var discrepancy
is still fixed without following the leaf. A symlinked *ancestor* is still
resolved, unavoidably -- that is the point in the /var case -- and the walk does
not follow symlinked directories anyway, so such a path names no walked file
under either spelling.

Writing the invariant down was not enough to enforce it. There was no symlink
test, so nothing caught the contradiction; there is one now, and it fails if the
whole-path canonicalize is reintroduced.

The same defect survived in codeowners_query::teams_for_files_from_codeowners,
reached from public API as runner::teams_for_files_from_codeowners. It
relativized with relative_to_buf, which passes an unstrippable path through
unchanged, so a /var/... path against a /private/var/... root was looked up in
the CODEOWNERS file as an absolute path, matched no entry, and came back
unowned. Fixing validate while leaving the bulk-lookup entry point beside it
would have made the branch's claim narrower than it reads.

That one falls back to the path as given rather than dropping it, because the
returned map is contracted to hold one entry per input and
team_for_file_from_codeowners asserts on that. Note the keys were already the
relativized form, not the caller's spelling, so they were inconsistent depending
on whether strip_prefix happened to succeed; they are now consistently relative.

The retry logic moves to path_utils::resolve_project_relative so both callers
share it rather than growing a second copy.

Adds two positive guards. Every other assertion in the file is that an unowned
file gets reported, which would also hold if normalization mangled a path into
some other unowned path; these pin that a well-owned file still resolves to
itself and passes under `./` and interior `..` spellings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@perryqh
perryqh force-pushed the fix/normalize-supplied-paths branch from cf04538 to 1749a4f Compare August 25, 2026 00:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Triage

Development

Successfully merging this pull request may close these issues.

1 participant