Skip to content

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

Draft
perryqh wants to merge 1 commit into
mainfrom
fix/normalize-supplied-paths
Draft

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

Conversation

@perryqh

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 shapewhat happens to a mishandled pathsymptom
directory-anchored ({gems,ruby,…}/**/*.rb)matches nothing, dropped before any ownership queryexit 0, having checked nothing
**-leading (**/*.rb)survives the filter, queried in the caller's spellingwell-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>
Sign up for freeto 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

@perryqh