Skip to content

Commit cf04538

Browse files
perryqhclaude
andcommitted
fix: resolve only the parent when retrying an absolute path
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>
1 parent 805f6c9 commit cf04538

4 files changed

Lines changed: 128 additions & 30 deletions

File tree

src/ownership/codeowners_query.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,23 @@ pub(crate) fn teams_for_files_from_codeowners(
1010
team_file_globs: &[String],
1111
file_paths: &[String],
1212
) -> Result<HashMap<String, Option<Team>>, String> {
13+
// Normalize the same way `Runner::validate_files` does. This is reached from public API
14+
// (`runner::teams_for_files_from_codeowners`) and had the same defect:
15+
// `relative_to_buf` passes an unstrippable path through unchanged, so an absolute path
16+
// that disagreed with `project_root` about symlinks -- a `/var/...` path against a
17+
// `/private/var/...` root -- was looked up in the CODEOWNERS file *as an absolute path*,
18+
// matched no entry, and came back unowned.
19+
//
20+
// Falls back to the path as given rather than dropping it, because the returned map is
21+
// contracted to hold one entry per input and `team_for_file_from_codeowners` asserts on
22+
// that. A path that cannot be placed inside the project has no owner, which is the
23+
// honest answer for a lookup.
24+
let canonical_root = project_root.canonicalize().ok();
1325
let relative_file_paths: Vec<PathBuf> = file_paths
1426
.iter()
1527
.map(Path::new)
1628
.map(|path| {
17-
if path.is_absolute() {
18-
crate::path_utils::relative_to_buf(project_root, path)
19-
} else {
20-
path.to_path_buf()
21-
}
29+
crate::path_utils::resolve_project_relative(project_root, canonical_root.as_deref(), path).unwrap_or_else(|| path.to_path_buf())
2230
})
2331
.collect();
2432

src/path_utils.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,35 @@ pub fn project_relative(root: &Path, path: &Path) -> Option<PathBuf> {
3838
Some(normalized)
3939
}
4040

41+
/// Like [`project_relative`], but consults the filesystem when the lexical attempt fails.
42+
///
43+
/// An absolute path only strips if it and `root` agree about symlinks, and there is no
44+
/// guarantee they do. `cli.rs` canonicalizes `--project-root`, but a library caller building
45+
/// its own `RunConfig` (which is how the `code_ownership` gem calls in) does not. So on
46+
/// macOS, where `TMPDIR` lives under `/var`, a symlink to `/private/var`, *either* side can
47+
/// be the unresolved one, and in a symlinked checkout the same is true generally. Resolving
48+
/// only one side leaves the other failing exactly as silently, so the retry resolves both.
49+
///
50+
/// It resolves the **parent** and re-attaches the file name, rather than canonicalizing the
51+
/// whole path. Canonicalizing the leaf would follow a symlinked *file*, and the project walk
52+
/// records the symlink path rather than its target — so an absolute path naming a symlink
53+
/// would be checked as a different file than the caller asked about, and pass or fail on
54+
/// that file's ownership instead. A symlinked *ancestor* is still resolved, unavoidably:
55+
/// that is the whole point in the `/var` case, and the walk does not follow symlinked
56+
/// directories anyway, so such a path names no walked file under either spelling.
57+
///
58+
/// `canonical_root` is the resolved `root`, passed in rather than computed so a caller
59+
/// normalizing a whole changeset pays for it once instead of once per path.
60+
pub fn resolve_project_relative(root: &Path, canonical_root: Option<&Path>, path: &Path) -> Option<PathBuf> {
61+
if let Some(relative) = project_relative(root, path) {
62+
return Some(relative);
63+
}
64+
65+
let resolved = path.parent()?.canonicalize().ok()?.join(path.file_name()?);
66+
67+
project_relative(canonical_root.unwrap_or(root), &resolved)
68+
}
69+
4170
/// Resolve `.` and `..` without touching the filesystem.
4271
///
4372
/// Deliberately lexical: canonicalizing would also resolve symlinks, and the project walk

src/runner.rs

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -153,14 +153,14 @@ impl Runner {
153153
// exited 0 having checked nothing -- a false pass in the unsafe direction.
154154
//
155155
// The canonical root is resolved once rather than per path, since only the retry
156-
// inside `project_relative_path` needs it and that retry can fire for every path
156+
// inside `resolve_project_relative` needs it and that retry can fire for every path
157157
// when a caller passes an absolute list.
158158
let canonical_root = self.run_config.project_root.canonicalize().ok();
159159

160160
let relative_paths: Vec<PathBuf> = file_paths
161161
.iter()
162162
.filter_map(|file_path| {
163-
Self::project_relative_path(&self.run_config.project_root, canonical_root.as_deref(), Path::new(file_path))
163+
crate::path_utils::resolve_project_relative(&self.run_config.project_root, canonical_root.as_deref(), Path::new(file_path))
164164
})
165165
// A path that no longer exists is dropped rather than reported. Changesets
166166
// delete files routinely and `git diff --name-only` lists them, so reporting a
@@ -215,28 +215,6 @@ impl Runner {
215215
RunResult::default()
216216
}
217217

218-
/// Reduce a caller-supplied path to project-relative form.
219-
///
220-
/// An absolute path only strips if it and the root agree about symlinks, and there is
221-
/// no guarantee they do — `cli.rs` canonicalizes `--project-root`, but a library caller
222-
/// building its own `RunConfig` (which is how the `code_ownership` gem calls in) does
223-
/// not. So on macOS, where `TMPDIR` lives under `/var`, a symlink to `/private/var`,
224-
/// *either* side can be the unresolved one, and in a symlinked checkout the same is
225-
/// true generally.
226-
///
227-
/// Hence the retry resolves both sides rather than just the path: fixing only the path
228-
/// leaves the mirror-image case — a canonical path against an unresolved root — failing
229-
/// exactly as silently. The first attempt uses the root as given, so the common case of
230-
/// relative paths costs no syscalls at all.
231-
fn project_relative_path(root: &Path, canonical_root: Option<&Path>, path: &Path) -> Option<PathBuf> {
232-
if let Some(relative) = crate::path_utils::project_relative(root, path) {
233-
return Some(relative);
234-
}
235-
236-
let canonical_path = path.canonicalize().ok()?;
237-
crate::path_utils::project_relative(canonical_root.unwrap_or(root), &canonical_path)
238-
}
239-
240218
pub fn generate(&self, git_stage: bool) -> RunResult {
241219
let content = self.ownership.generate_file();
242220
if let Some(parent) = &self.codeowners_file_path.parent() {

tests/supplied_path_normalization_test.rs

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@
1818
//! that, a test cannot tell "checked correctly" from "mishandled and spuriously reported",
1919
//! which is exactly how an earlier draft of this file passed against unfixed code.
2020
//!
21-
//! One test covers the `**`-glob direction, since the symptom there is the opposite.
21+
//! One test covers the `**`-glob direction, since the symptom there is the opposite. Two
22+
//! more assert an *owned* file still passes under each odd spelling — without those, every
23+
//! assertion here would also hold if normalization mangled one path into some other unowned
24+
//! path.
2225
//!
2326
//! Path forms covered, against both states the project root can be in (resolved or not,
2427
//! since `cli.rs` canonicalizes it but a library caller need not):
@@ -27,6 +30,7 @@
2730
//! - absolute, root and path agreeing about symlinks
2831
//! - absolute, root resolved and path not
2932
//! - absolute, path resolved and root not (library callers only)
33+
//! - absolute, naming a symlinked *file* -- must check the symlink, not its target
3034
//! - a deleted path, and a path outside the project -- both skipped, deliberately
3135
3236
use assert_cmd::prelude::*;
@@ -55,6 +59,27 @@ fn fixture_with_an_unowned_file() -> TempDir {
5559
temp_dir
5660
}
5761

62+
/// Assert `validate <spelling>` succeeds, i.e. the path reached the check *and* resolved to
63+
/// a file that really is owned.
64+
///
65+
/// The counterpart to `assert_normalizes`: those tests would still pass if normalization
66+
/// mangled a path into some *other* unowned path, and these would not.
67+
fn assert_owned_file_passes(spelling: &str) -> Result<(), Box<dyn Error>> {
68+
let temp_dir = fixture_with_an_unowned_file();
69+
70+
Command::cargo_bin("codeowners")?
71+
.arg("--project-root")
72+
.arg(temp_dir.path())
73+
.arg("--no-cache")
74+
.arg("validate")
75+
.arg(spelling)
76+
.assert()
77+
.success()
78+
.stdout(predicate::eq(""));
79+
80+
Ok(())
81+
}
82+
5883
/// Assert `validate <spelling>` reached the ownership check and reported the file under its
5984
/// normalized name.
6085
fn assert_normalizes(spelling_from: impl Fn(&std::path::Path) -> String) -> Result<(), Box<dyn Error>> {
@@ -159,6 +184,64 @@ fn test_absolute_path_when_only_the_path_is_resolved() {
159184
);
160185
}
161186

187+
#[test]
188+
fn test_owned_file_passes_with_a_dot_slash_prefix() -> Result<(), Box<dyn Error>> {
189+
// A positive guard. Every assertion above is that an *unowned* file gets reported, which
190+
// would also hold if normalization mangled the path into some other unowned path. This
191+
// pins that a well-owned file still resolves to itself and passes.
192+
assert_owned_file_passes("./ruby/app/models/payroll.rb")
193+
}
194+
195+
#[test]
196+
fn test_owned_file_passes_with_an_interior_parent_dir() -> Result<(), Box<dyn Error>> {
197+
assert_owned_file_passes("ruby/app/payments/../models/payroll.rb")
198+
}
199+
200+
#[test]
201+
fn test_absolute_path_to_a_symlink_names_the_symlink_not_its_target() {
202+
// Regression guard. The retry used to canonicalize the whole supplied path, which
203+
// follows a symlinked *file*, so an absolute path naming a symlink was checked as its
204+
// target -- a different file than the caller asked about. The retry now resolves only
205+
// the parent and re-attaches the file name, fixing the ancestor `/var` ->
206+
// `/private/var` discrepancy without following the leaf.
207+
//
208+
// Both the symlink and its target are unowned here, and the assertion is on *which path
209+
// the report names* rather than on pass/fail. An earlier version pointed the symlink at
210+
// an owned file and asserted failure, which was fixture-coupled and wrong: reading
211+
// through a symlink sees the target's contents, so once ownership is resolved through
212+
// the mappers rather than by reading CODEOWNERS back, the symlink genuinely inherits the
213+
// target's `@team` annotation and is owned. Naming the path sidesteps that entirely.
214+
//
215+
// The symlink is created after `setup_fixture_repo` because that helper copies with
216+
// `fs::copy`, which would follow it and write a regular file instead.
217+
let temp_dir = fixture_with_an_unowned_file();
218+
let project_root = temp_dir.path();
219+
220+
let link = project_root.join("ruby/app/link_to_unowned.rb");
221+
std::os::unix::fs::symlink("unowned.rb", &link).expect("failed to create symlink");
222+
git_add_all_files(project_root);
223+
224+
// Deliberately NOT canonicalized, so the retry fires.
225+
let absolute = link.to_string_lossy().to_string();
226+
227+
let output = Command::cargo_bin("codeowners")
228+
.expect("binary")
229+
.arg("--project-root")
230+
.arg(project_root)
231+
.arg("--no-cache")
232+
.arg("validate")
233+
.arg(&absolute)
234+
.output()
235+
.expect("run");
236+
let stdout = String::from_utf8_lossy(&output.stdout);
237+
238+
assert!(
239+
stdout.contains("link_to_unowned.rb"),
240+
"the report names the symlink's target instead of the symlink the caller asked \
241+
about, so the retry followed the leaf.\nstdout={stdout}"
242+
);
243+
}
244+
162245
#[test]
163246
fn test_owned_file_is_not_spuriously_reported_under_star_star_globs() -> Result<(), Box<dyn Error>> {
164247
// The other failure mode. `invalid_project`'s `owned_globs` are `**`-leading, so a

0 commit comments

Comments
 (0)