fix: normalize the paths callers supply to validate - #126
Conversation
`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>
fa90055 to
cf04538
Compare
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>
cf04538 to
1749a4f
Compare
There was a problem hiding this comment.
Pull request overview
This PR fixes path-handling bugs when callers supply explicit file paths to validate / generate-and-validate, by normalizing those inputs into the same project-relative form used elsewhere in the pipeline (including a symlink-aware retry that avoids following symlinked files). It also skips deleted paths (common in diffs) and adds targeted tests to prevent silent false-passes and misreported “unowned” results caused by mismatched path spellings.
Changes:
- Normalize caller-supplied paths in
Runner::validate_filesand in the public CODEOWNERS bulk-lookup API before filtering/querying. - Skip definitely-nonexistent paths during validate runs to avoid failing commits that delete files.
- Update and add tests to cover normalization edge cases (dot paths,
.., absolute paths with root/path symlink disagreements, deleted paths, and symlink leaf behavior).
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/runner.rs |
Normalize supplied paths up-front, skip deleted files, and query CODEOWNERS using normalized project-relative paths. |
src/path_utils.rs |
Add lexical project-relative normalization plus a filesystem-assisted retry that avoids canonicalizing symlinked file leaves. |
src/ownership/codeowners_query.rs |
Apply the same normalization to the bulk CODEOWNERS lookup used by a public API. |
src/cli.rs |
Update CLI help text to document path resolution and skipping deleted paths. |
tests/validate_files_test.rs |
Fix fixtures/assertions to validate genuinely unowned files and avoid relying on phrasing. |
tests/supplied_path_normalization_test.rs |
Add comprehensive regression coverage for supplied-path normalization and deletion behavior. |
Suppressed comments (1)
src/cli.rs:54
- Same as above: the
\continuation keeps the indentation as literal spaces, so the generated help text will contain a large gap before “resolved relative…”.
#[arg(help = "Optional list of files to validate ownership for (fast mode for git hooks). Paths are \
resolved relative to the project root; ones that no longer exist are skipped, so a \
changeset that deletes files is not reported as unowned.")]
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| #[arg(help = "Optional list of files to validate ownership for (fast mode for git hooks). Paths are \ | ||
| resolved relative to the project root; ones that no longer exist are skipped, so a \ | ||
| changeset that deletes files is not reported as unowned.")] |
There was a problem hiding this comment.
Not a defect — Rust's string continuation escape strips the newline and the leading whitespace on the next line, so the indentation never reaches the string.
Verified against the rendered output on this branch:
$ codeowners validate --help
[FILES]... Optional list of files to validate ownership for (fast mode for git hooks). Paths are resolved relative to the project root; ones that no longer exist are skipped, so a changeset that deletes files is not reported as unowned.
Counting interior multi-space runs in that description gives 0. The only gap is clap's own column alignment between [FILES]... and the description text, which is unrelated to the continuation.
(This would be a real problem with concat! or adjacent string literals, which don't strip anything — just not with \.) Leaving the help text as-is, and likewise for the suppressed duplicate on line 52.
| assert_owned_file_passes("ruby/app/payments/../models/payroll.rb") | ||
| } | ||
|
|
||
| #[test] |
There was a problem hiding this comment.
Good catch — fixed in 9e204f1 by gating the test behind #[cfg(unix)] rather than making it portable.
Windows isn't a supported target (CI is ubuntu-latest + macos-latest, and the release artifacts are macOS x86_64/aarch64 and Linux x86_64/aarch64 only — see .github/workflows/ci.yml and dotslash-config.json), so there's nothing to port it to. But your point that cargo test should compile on all targets stands regardless, and the gate costs nothing.
| pub fn resolve_project_relative(root: &Path, canonical_root: Option<&Path>, path: &Path) -> Option<PathBuf> { | ||
| if let Some(relative) = project_relative(root, path) { | ||
| return Some(relative); | ||
| } | ||
|
|
||
| let resolved = path.parent()?.canonicalize().ok()?.join(path.file_name()?); | ||
|
|
||
| project_relative(canonical_root.unwrap_or(root), &resolved) | ||
| } |
There was a problem hiding this comment.
Correct, and it was worse than surprising — fixed in 9e204f1, which now returns early unless path.is_absolute().
The framing that convinced me: a relative path is interpreted against the project root by contract (that's what --help promises), and the lexical pass is the whole of that interpretation. So a lexical failure means the path escapes the root, and retrying resolved it against the process CWD instead — quietly switching interpretation frames, so identical arguments would mean different files depending on where the command ran from. Plus the wasted syscall you noted.
Chasing this also surfaced a real bug in lexically_normalize that the retry was masking. A retained .. was indistinguishable from a real component to pop(), so a later .. popped it and ../../a cancelled its own escape:
$ codeowners validate ../ruby/app/unowned.rb # single `..`
exit=0 # correctly out of scope
$ codeowners validate ../../ruby/app/unowned.rb # double `..`
Unowned files detected:
ruby/app/unowned.rb # ← out-of-project path, reported as in-project
exit=1Same class as the symlink bug the previous commit fixed — answering about a different file than the caller named — and the single-.. case behaving correctly is exactly what made it look handled. Both are now covered by unit tests.
Addresses review of the previous commit, plus a bug found while checking it.
lexically_normalize retained a leading `..` so the caller could detect an escape,
but pop() does not distinguish that retained `..` from a real component, so a
later one popped it. `../../a` therefore cancelled its own escape and came out as
`a`: a path plainly outside the project was reported as though it named a file
inside it.
validate ../ruby/app/unowned.rb -> exit 0 (correctly out of scope)
validate ../../ruby/app/unowned.rb -> exit 1 "Unowned files detected:
ruby/app/unowned.rb"
Same class as the symlink bug the previous commit fixed -- answering about a
different file than the caller named -- and the single-`..` case passing made it
look handled. A `..` is now only allowed to pop a real component.
Restrict the filesystem retry to absolute paths. A relative path is interpreted
against the project root by contract, which is what --help promises, and the
lexical pass is the whole of that interpretation -- so failure means it escapes
the root. Retrying resolved it against the process CWD instead, quietly switching
interpretation frames: identical arguments would mean different files depending on
where the command ran from. It also spent a syscall per path to reach that wrong
answer. Reported by review.
Gate the symlink test behind #[cfg(unix)]. std::os::unix::fs::symlink has no
portable equivalent and would fail to compile on Windows. This crate ships only
macOS and Linux artifacts, so the test is gated rather than made portable, which
keeps `cargo test` compiling everywhere. Reported by review.
Not changed: review also flagged the `\` line continuations in cli.rs help text as
leaving runs of literal spaces. Rust's string-continuation escape strips the
newline *and* the following indentation, so it does not. Verified against the
rendered output -- the description contains zero interior multi-space runs; the
only gap is clap's own column alignment between the argument name and its text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 ofowned_globs, and both outcomes are wrong:owned_globsshape{gems,ruby,…}/**/*.rb)**-leading (**/*.rb)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
maintoday, independent of that PR's premise.The path forms that hit this
./ruby/app/x.rbandruby/a/../x.rbwere never reduced at all.Absolute paths were reduced with
strip_prefixagainst a root that need not agree with them about symlinks.cli.rscanonicalizes--project-root, so on macOS — whereTMPDIRlives under/var, a symlink to/private/var— a caller passing theTMPDIRspelling fails to strip. And a library caller building its ownRunConfig, which is how thecode_ownershipgem 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.
Examples 1–4 below are the four resulting shapes; 5–8 are the rest of what this branch fixes.
Examples
Every transcript below is real output from
mainvs. this branch, same fixture, same command.ruby/app/unowned.rbis a file with no owner;ruby/app/models/payroll.rbis owned by@PayrollTeam.1.
./-prefixed path, directory-anchoredowned_globs→ silent passThe dangerous shape.
valid_project's globs are{gems,config,javascript,ruby,components}/**/*.{rb,tsx,erb}, so an unreduced./ruby/...matches nothing and is dropped before any ownership query.A pre-commit hook or CI job invoked this way reports success on a file it never looked at.
2. Absolute path, root resolved but path not → silent pass
cli.rscanonicalizes--project-root, so on macOS it becomes/private/var/...while a caller passing theTMPDIRspelling supplies/var/....strip_prefixfails, the path stays absolute, and the glob filter drops it.3. Absolute path, path resolved but root not → silent pass
The mirror image, reachable only through the library API, since the CLI always canonicalizes the root. This is how the
code_ownershipgem calls in — it builds its ownRunConfig. From the probe that found it:Resolving only the supplied path fixes example 2 and leaves this one failing identically, which is why the retry resolves both sides.
4.
**-leadingowned_globs→ the opposite symptomSame cause, and here the unreduced path survives the filter and gets queried in the caller's spelling. It matches no CODEOWNERS entry, so a perfectly well-owned file is reported unowned — note the report echoing
./back:This is why the fix cannot be evaluated against one fixture alone, and why the tests cover both glob shapes.
5. A deleted file in the changeset → failed the commit
git diff --name-onlylists deletions, so a deleted path reachesvalidatein normal use. A deleted file cannot have an owner.6. Absolute path naming a symlink → checked the wrong file
Fixed by the second commit, and a bug the first commit introduced: it canonicalized the whole path, which follows a symlinked file. Here
ruby/app/link_to_unowned.rb → unowned.rb:Both exit 1 here because both paths are unowned, which is exactly why the regression test asserts on which path is named rather than on pass/fail. Point the symlink at an owned file instead and the before-case exits 0 — a false pass on a file the caller never mentioned.
The retry now resolves only the parent and re-attaches the file name, so example 2 stays fixed without following the leaf.
7. Same defect in a neighbouring public API
codeowners_query::teams_for_files_from_codeowners, reached from the publicrunner::teams_for_files_from_codeowners, relativized with the samerelative_to_bufthat passes unstrippable paths through. A/var/...path against a/private/var/...root was looked up in the CODEOWNERS file as an absolute path, matched no entry, and came backNone— reported as unowned.Fixing
validatewhile leaving the bulk-lookup entry point beside it would have made this branch's claim narrower than it reads. Its keys were already the relativized form rather than the caller's spelling, so they were inconsistent depending on whetherstrip_prefixhappened to succeed; they are now consistently relative.8. Repeated
..cancelling its own escape → wrong file reportedFound while acting on review feedback, and introduced by this branch's first commit. A leading
..is retained so the caller can detect an escape, butpop()cannot tell it apart from a real component, so a later..popped it — and../../acame out asa.Same class as example 6 — answering about a different file than the caller named — and the single-
..case behaving correctly is exactly what made it look handled.Relatedly, the filesystem retry is now gated to absolute paths. A relative path is interpreted against the project root by contract, so the lexical pass is the whole interpretation; retrying resolved it against the process CWD instead, which meant identical arguments could mean different files depending on where the command ran from.
The fix
path_utils::project_relativeresolves.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
Example 5 above. 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 exist —valid_projecthas 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.rswas 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 unmodifiedmain.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_fileshere is the existing read-CODEOWNERS-back implementation. #125 replaces that method wholesale, so the ~30 lines of normalization wiring inside it are transitional — butpath_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.The third commit addresses review feedback on the second, and fixes a
..-handling bug (example 8) found while doing so.Unrelated, found while doing this: the
pre-commithook fails in any git worktree. Git exportsGIT_DIRto hooks, the tests spawngitsubprocesses that inherit it and read the wrong repository, and 11 git-dependent unit tests fail. Reproducible against unmodifiedmainwithGIT_DIR=<path> cargo test --lib. Worth a separate fix in.rusty-hook.toml.🤖 Generated with Claude Code