From 7a02040ebb0dabeb0bce9cb65a3660e2e75ee8f8 Mon Sep 17 00:00:00 2001 From: Krishika <203898617+krishika08@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:26:20 +0530 Subject: [PATCH] Accept backslash anchor paths on POSIX in the CLI resolve_and_validate_anchors() looked up 'src\payment.py' literally on Linux and rejected it, while retrieve(anchor_files=...) already normalizes backslashes. Try the exact spelling first, then (POSIX only) the forward-slash spelling. All containment checks still apply to the resolved file. Add a test that '..\outside.py' is still rejected. Fixes test_resolve_and_validate_anchors_normalization on Linux CI. Co-Authored-By: Claude Opus 5.5 --- reponeedle/cli.py | 25 +++++++++++++++++-------- tests/test_cli_anchors.py | 7 +++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/reponeedle/cli.py b/reponeedle/cli.py index 5c14c8a..029880c 100644 --- a/reponeedle/cli.py +++ b/reponeedle/cli.py @@ -42,16 +42,25 @@ def resolve_and_validate_anchors(repo_dir: str, anchors: Optional[List[str]]) -> continue raw_anchor = a.strip() - # Check path interpretation 1: Relative to repo_dir (e.g. "auth/session.py" with --repo "dir") - candidate_1 = os.path.normpath(os.path.join(abs_repo, raw_anchor)) - # Check path interpretation 2: Relative to current working directory or absolute - candidate_2 = os.path.normpath(os.path.abspath(raw_anchor)) + # The exact spelling is tried first. On POSIX, a Windows-style spelling ("src\\a.py") is then + # retried with forward slashes, consistent with retrieve(anchor_files=...). A real file whose + # name contains a backslash still takes precedence. Containment checks below apply either way. + spellings = [raw_anchor] + if os.sep == "/" and "\\" in raw_anchor: + spellings.append(raw_anchor.replace("\\", "/")) resolved_target = None - if os.path.exists(candidate_1) and os.path.isfile(candidate_1): - resolved_target = candidate_1 - elif os.path.exists(candidate_2) and os.path.isfile(candidate_2): - resolved_target = candidate_2 + for spelling in spellings: + # Interpretation 1: relative to repo_dir (e.g. "auth/session.py" with --repo "dir"). + candidate_1 = os.path.normpath(os.path.join(abs_repo, spelling)) + # Interpretation 2: relative to the current working directory, or absolute. + candidate_2 = os.path.normpath(os.path.abspath(spelling)) + if os.path.isfile(candidate_1): + resolved_target = candidate_1 + elif os.path.isfile(candidate_2): + resolved_target = candidate_2 + if resolved_target is not None: + break if resolved_target is None: raise ValueError( diff --git a/tests/test_cli_anchors.py b/tests/test_cli_anchors.py index 534c23a..2c94527 100644 --- a/tests/test_cli_anchors.py +++ b/tests/test_cli_anchors.py @@ -110,6 +110,13 @@ def test_resolve_and_validate_anchors_outside_repo(self, change_impact_repo: Pat with pytest.raises(ValueError, match="resolves outside repository root"): resolve_and_validate_anchors(str(change_impact_repo), [str(outside_file)]) + def test_resolve_and_validate_anchors_backslash_traversal_rejected(self, change_impact_repo: Path): + # The backslash-to-slash retry (POSIX) must not open a traversal route. + outside_file = change_impact_repo.parent / "outside_bs.py" + outside_file.write_text("print('outside')", encoding="utf-8") + with pytest.raises(ValueError, match="resolves outside repository root"): + resolve_and_validate_anchors(str(change_impact_repo), ["..\\outside_bs.py"]) + def test_cli_change_impact_single_anchor(self, change_impact_repo: Path, capsys: pytest.CaptureFixture): exit_code = main([ "Fix payment gateway timeout",