Skip to content

chore(compile): tighten link_resolver._resolve_path input guards#991

Merged
danielmeppiel merged 2 commits intomicrosoft:mainfrom
mvanhorn:fix/841-link-resolver-input-guards
Apr 30, 2026
Merged

chore(compile): tighten link_resolver._resolve_path input guards#991
danielmeppiel merged 2 commits intomicrosoft:mainfrom
mvanhorn:fix/841-link-resolver-input-guards

Conversation

@mvanhorn
Copy link
Copy Markdown
Contributor

Closes #841.

Summary

Adds an early-return guard for empty / whitespace-only input in `_resolve_path` (Minor 1 + Minor 2 from the issue). Previously `_resolve_path("")` and `_resolve_path(" ")` returned the base directory via `Path(base_dir) / ""`, which is semantically wrong even though the existing `.exists()` checks in the call sites masked the bug.

```python
if not path or not path.strip():
return None
```

Tests (Minor 3)

Adds `TestResolvePathInputGuards` to `tests/unit/compilation/test_link_resolver.py` covering each case the issue called out:

  • empty string -> `None` (new guard)
  • whitespace-only (spaces, tab, newline) -> `None` (new guard)
  • embedded NUL byte -> must not crash; the containment check is the caller's job, so the test only locks in "no exception"
  • POSIX backslash traversal (`foo\\..\\..\\etc\\passwd`) -> stays a literal segment under `base_dir`
  • `file://` URI on POSIX -> treated relative, joined under `base_dir`
  • non-existent relative target -> resolves normally (happy path)

Verification

`pytest tests/unit/compilation/test_link_resolver.py` -> 23 / 23 pass (17 existing + 6 new).

@danielmeppiel danielmeppiel force-pushed the fix/841-link-resolver-input-guards branch from c881fc8 to 8025653 Compare April 30, 2026 09:34
Copilot AI review requested due to automatic review settings April 30, 2026 09:34
@danielmeppiel
Copy link
Copy Markdown
Collaborator

Maintainer rebase + panel verdict (per #1064 FIX-NOW orchestration)

Rebased on origin/main (clean, no functional changes) and ran the apm-review-panel critique locally.

Verdict: READY

Panel findings (7 personas)

  • python-architect: surgical early-return guard; idiomatic and minimal
  • cli-logging-expert: no CLI/logging surface touched
  • devx-ux-expert: internal helper; no user-facing surface
  • supply-chain-security-expert: LOW -- removes a latent "empty path resolves to base_dir" bug; backslash/file:// behavior intentionally documented as POSIX-relative per docstring contract
  • oss-growth-hacker: internal defensive change; no growth surface
  • doc-writer: no docs/CLI/manifest impact; whitespace cleanup OK
  • apm-ceo: closes chore(compile): tighten link_resolver._resolve_path input guards #841 cleanly; tests align with Minor 1/2/3 asks; no escalation

Test evidence

uv run --extra dev pytest tests/unit/compilation/test_link_resolver.py tests/test_console.py -x --tb=short  # green
uv run --extra dev pytest tests/unit -x --tb=short -q
=> 6775 passed, 1 warning, 27 subtests passed in 46.31s

Per fidelity matrix: pure path-resolution, tmp_path-backed unit tests are the contracted integration surface.

Merging via squash next.

Copy link
Copy Markdown
Collaborator

@danielmeppiel danielmeppiel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved per maintainer panel verdict (see prior comment); CI green.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Tightens link_resolver._resolve_path input handling so empty / whitespace-only link targets resolve to None instead of incorrectly resolving to base_path, and adds unit tests to lock in the intended edge-case behavior discussed in #841.

Changes:

  • Add an early-return guard in _resolve_path for empty / whitespace-only input.
  • Add a new TestResolvePathInputGuards test class covering empty/whitespace, NUL bytes, traversal-like strings, file://-like inputs, and a normal relative path case.
  • Update test imports to exercise _resolve_path directly.
Show a summary per file
File Description
src/apm_cli/compilation/link_resolver.py Adds an early-return guard to prevent empty/whitespace paths from resolving to the base directory.
tests/unit/compilation/test_link_resolver.py Adds focused unit tests to validate _resolve_path behavior for several problematic inputs.

Copilot's findings

Comments suppressed due to low confidence (3)

tests/unit/compilation/test_link_resolver.py:498

  • This is also platform-dependent: on Windows, pathlib may treat file: as a drive component, making the path "absolute" and causing _resolve_path to return it unchanged (so it would not start with base_dir). Please skip/parametrize this test for POSIX-only behavior, or assert the Windows behavior explicitly.
    def test_file_uri_on_posix_is_treated_as_relative(self, base_dir):
        """`file://...` is not absolute on POSIX, so it joins under base_dir rather than escaping it."""
        result = _resolve_path("file:///etc/passwd", base_dir)
        assert result is not None
        assert str(result).startswith(str(base_dir))

src/apm_cli/compilation/link_resolver.py:450

  • _resolve_path can currently return a Path containing an embedded NUL (e.g., from markdown content). Call sites in this module then call .exists() on that Path without catching ValueError, which will raise "embedded null byte" and crash compilation/validation. Consider rejecting inputs containing \x00 (return None) so downstream .exists() checks stay safe.
    if not path or not path.strip():
        return None
    try:
        if Path(path).is_absolute():
            return Path(path)
        else:
            return base_path / path
    except (OSError, ValueError):

tests/unit/compilation/test_link_resolver.py:492

  • These assertions are POSIX-specific (on Windows, backslash is a path separator, so this input will be split into segments and the expected literal-segment behavior will not hold). Please skip/parametrize this test by platform (e.g., only run on non-nt) or adjust expectations for Windows to avoid post-merge failures in the Windows unit-test job.
    def test_posix_backslash_traversal_stays_relative(self, base_dir):
        """Backslashes are literal characters on POSIX, so the path stays under base_dir."""
        result = _resolve_path("foo\\..\\..\\etc\\passwd", base_dir)
        assert result is not None
        # The literal backslash filename is interpreted as a single segment under base_dir.
        assert result == base_dir / "foo\\..\\..\\etc\\passwd"
  • Files reviewed: 2/2 changed files
  • Comments generated: 1

Comment on lines +476 to +485
def test_embedded_nul_byte_does_not_crash(self, base_dir):
"""An embedded NUL byte must not crash _resolve_path itself.

The current containment relies on the caller's `.exists()` check to
reject the resulting path -- this lock-in is documented in the issue
("Current containment code handles these correctly").
"""
# Either return value is acceptable; what matters is no exception.
result = _resolve_path("foo\x00bar", base_dir)
assert result is None or isinstance(result, Path)
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test/docstring claims the caller's .exists() check will "reject" a path with an embedded NUL, but in practice Path.exists() raises ValueError for embedded null bytes unless explicitly caught. If the intent is "must not crash overall", it would be better to have _resolve_path return None for \x00 inputs and assert that here, rather than allowing a Path return value.

This issue also appears in the following locations of the same file:

  • line 487
  • line 494
Suggested change
def test_embedded_nul_byte_does_not_crash(self, base_dir):
"""An embedded NUL byte must not crash _resolve_path itself.
The current containment relies on the caller's `.exists()` check to
reject the resulting path -- this lock-in is documented in the issue
("Current containment code handles these correctly").
"""
# Either return value is acceptable; what matters is no exception.
result = _resolve_path("foo\x00bar", base_dir)
assert result is None or isinstance(result, Path)
def test_embedded_nul_byte_returns_none(self, base_dir):
"""An embedded NUL byte should be rejected by _resolve_path."""
result = _resolve_path("foo\x00bar", base_dir)
assert result is None

Copilot uses AI. Check for mistakes.
Adds an early-return guard for empty / whitespace-only input. Previously
`_resolve_path("")` and `_resolve_path("   ")` returned the base directory
via `Path(base_dir) / ""`, which is semantically wrong (an empty link
should resolve to nothing) even though existing callers' `.exists()` check
masked the bug.

Adds a TestResolvePathInputGuards class that locks in the behaviour for:

- empty string
- whitespace-only (spaces, tab, newline)
- embedded NUL byte (must not crash; containment is the caller's job)
- POSIX backslash traversal (literal segment, stays under base_dir)
- file:// URI on POSIX (treated relative, joined under base_dir)
- non-existent relative target (happy path)

Closes microsoft#841
@danielmeppiel danielmeppiel force-pushed the fix/841-link-resolver-input-guards branch from 8025653 to 1ba83d7 Compare April 30, 2026 09:40
Copy link
Copy Markdown
Collaborator

@danielmeppiel danielmeppiel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving after rebase onto post-#999 main (Ruff guardrails) — trivial import-style conflict in test_link_resolver.py resolved cleanly. All 6775 unit tests + targeted link_resolver tests pass on the new HEAD (1ba83d7).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@danielmeppiel danielmeppiel merged commit 0c6b221 into microsoft:main Apr 30, 2026
10 checks passed
danielmeppiel added a commit that referenced this pull request Apr 30, 2026
…#1065)

* fix: address Copilot review findings on #1055 and #991 (consolidated)

Folds the still-actionable findings from the post-merge Copilot reviews of
PRs #1055 and #991 into a single PR (no follow-up issue / PR sprawl).

#1055 (CLAUDE_CONFIG_DIR support):
- Document why the absolute-path fallback in TargetProfile.for_scope()
  is safe for downstream consumers (pathlib's 'rhs absolute wins' rule
  on '/' joins). Comment-only; no behavior change.
- Findings already addressed by the original PR (verified):
  * resolve(strict=False) collapses '..' before relative_to(home)
  * docs/guides/dependencies.md + integrations/ide-tool-integration.md
    already cover CLAUDE_CONFIG_DIR in the merged version
  * test_scope_install_uninstall.py:382 + test_scope_integration.py
    already exercise install/uninstall + outside-home + traversal cases

#991 (link_resolver guards):
- Correct the embedded-NUL-byte test docstring: Path.exists() raises
  ValueError on NUL on most platforms (it does not silently return
  False). Cosmetic; test behavior unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: address Copilot review on #1065

Two valid findings from copilot-pull-request-reviewer:

1. link_resolver._resolve_path: NUL byte in input previously returned
   a Path that crashed downstream .exists() / .read_text() with
   ValueError, aborting markdown link resolution. Reject NUL at the
   resolver boundary so callers (resolve_markdown_links,
   validate_link_targets) get None as documented.
   Tightened test to assert None instead of 'either is fine'.

2. integration/targets.for_scope: prior comment claimed absolute
   root_dir was 'safe' for all downstream consumers. install/services.
   _deployed_path_entry would actually raise RuntimeError for an
   out-of-tree absolute path. Today this is unreachable because
   user-scope CLAUDE installs do not flow through that translator,
   but the comment now records the constraint so a future refactor
   that lockfiles user-scope deploys treats it as a dynamic-root case.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore(compile): tighten link_resolver._resolve_path input guards

3 participants