Skip to content

fix: detect port-like segment in SCP shorthand and suggest ssh:// form#787

Merged
danielmeppiel merged 4 commits intomicrosoft:mainfrom
edenfunf:fix/scp-shorthand-port-detection
Apr 20, 2026
Merged

fix: detect port-like segment in SCP shorthand and suggest ssh:// form#787
danielmeppiel merged 4 commits intomicrosoft:mainfrom
edenfunf:fix/scp-shorthand-port-detection

Conversation

@edenfunf
Copy link
Copy Markdown
Contributor

Fixes #784.

Root Cause

_parse_ssh_url uses ^git@([^:]+):(.+)$ to parse SCP shorthand. Everything after : becomes the repo path. When a user writes:

git@bitbucket.example.com:7999/project/repo.git

The parser produces repo_url = "7999/project/repo" with port = None. Phase 3 validation accepts "7999" as a valid segment ([a-zA-Z0-9._-]+). The subsequent git clone fails with a 404 from the remote — no actionable error from APM.

The user intended port 7999 with repo project/repo, but SCP shorthand (git@host:path) uses : as the path separator and cannot carry a port. The correct form is ssh://git@host:7999/project/repo.git, which is handled by _parse_ssh_protocol_url via urlparse.

Fix

After alias/ref extraction and .git stripping in _parse_ssh_url, check if the first path segment is a valid TCP port number (1–65535). If so, raise a ValueError with an actionable suggestion:

It looks like '7999' in 'git@bitbucket.example.com:7999/project/repo' is a port
number, but SCP-style URLs (git@host:path) cannot carry a port. Use the ssh://
URL form instead:
  ssh://git@bitbucket.example.com:7999/project/repo.git

Before

git@bitbucket.example.com:7999/project/repo.git
→ repo_url="7999/project/repo", port=None → git clone → 404 ❌

After

git@bitbucket.example.com:7999/project/repo.git
→ ValueError with actionable suggestion to use ssh:// form ✅

Design decisions

  • ASCII-only digit check (re.fullmatch(r"[0-9]+", ...) instead of str.isdigit()): Python's isdigit() returns True for Unicode superscript digits (²³) where int() raises ValueError. Using an ASCII-only regex avoids this edge case.
  • Placement before validate_path_segments: a port-like first segment is a more specific and actionable error than a generic traversal/segment error. It takes priority.
  • Suggestion preserves .git suffix: if the user wrote .git, the suggested URL includes it for copy-paste-ability. If omitted, the suggestion omits it too.
  • Port-only input (git@host:7999 with no remaining path): detected separately with a distinct message ("no repository path follows").
  • Out-of-range numbers (0, 99999) fall through to existing validation — they are not valid ports and are unlikely to be port intentions.

False positive analysis

Purely numeric org/owner names in the range 1–65535 would be a false positive. GitHub explicitly disallows numeric-only usernames; GitLab and Bitbucket require at least one letter. Self-hosted instances could theoretically allow it, but this is vanishingly rare. The error message clearly explains the issue and suggests the ssh:// form, which unambiguously separates port from path.

Changes

File Change
src/apm_cli/models/dependency/reference.py Add port-like segment detection in _parse_ssh_url (~28 lines)
tests/unit/test_generic_git_urls.py Add TestSCPPortDetection class with 15 test cases

Tests

New TestSCPPortDetection class covering:

  • Port 7999, 22, 1, 65535 (boundary values) — all raise with ssh:// suggestion
  • Leading zeros (007999) — detected and normalized
  • Port with #ref and @alias — still detected after extraction
  • Port-only / no remaining path — distinct error message
  • Suggestion preserves or omits .git suffix based on user input
  • Port 0 and 99999 (out of range) — NOT detected, fall through
  • Normal org names (acme, v2) — parse normally, no change
  • ssh:// URL with port — continues to work (regression guard)

Validation

uv run pytest tests/unit/test_generic_git_urls.py -x     # 128 passed
uv run pytest tests/unit tests/test_console.py -x         # 4120 passed

Refs: #661, #665

microsoft#784)

When a user writes `git@host:7999/project/repo.git` (SCP shorthand
intending port 7999), the parser silently treats `7999/project/repo`
as the repo path. The subsequent git clone fails with a 404 — no
actionable error from APM.

Detect when the first path segment after `:` in SCP shorthand is a
valid TCP port number (1-65535) and raise a ValueError with a
suggested ssh:// URL that preserves the port correctly.
@danielmeppiel danielmeppiel requested a review from Copilot April 20, 2026 14:42
@danielmeppiel
Copy link
Copy Markdown
Collaborator

Review panel verdict: Approve -- with two non-blocking suggestions

External contribution (@edenfunf) closing #784. Fix is small (29/+1 lines), surgical, well-tested (15 new test cases), and turns a silent 404 from a remote into an actionable APM-level error. Ran it past the full panel; key findings below.


Python Architect: pass

  • Change is local to _parse_ssh_url; no cross-module ripple. Good.
  • Placement before validate_path_segments is correct -- a port-like first segment is a more specific error and should win.
  • Tiny refactor (had_git_suffix = ...) is justified by the suggestion needing to mirror the user's .git suffix.
  • The re.fullmatch(r"[0-9]+", ...) + range check is slightly belt-and-braces (fullmatch already excludes Unicode digits) but the comment explains the intent. No change requested.

Supply Chain Security Expert: pass (with a note)

  • Strong identity-correctness win. Pre-fix, git@host:7999/project/repo.git parsed to repo_url="7999/project/repo" -- a different identity key than the intended project/repo. That means lockfile entries keyed on the wrong identity and silent failure to dedup against the same dep added via ssh:// form. The fix prevents that ambiguity at parse time. (get_unique_key() / lockfile keying confirmed elsewhere in the codebase.)
  • False positives: a numeric-only first segment in the 1-65535 range. The PR's analysis is sound -- GitHub forbids numeric-only usernames, GitLab and Bitbucket require at least one letter, self-hosted edge cases are vanishingly rare and the error message points at a clean remediation. Accept.
  • Fails closed (raises ValueError) -- correct.

CLI Logging Expert: pass

  • No CommandLogger paths touched. Error is a ValueError that flows through the existing install error handler -- the multi-line \n ssh://... suggestion will render fine.

DevX UX Expert: pass with one polish suggestion

  • Error wording is unusually good for this class of problem: names the offending token, explains why SCP can't carry a port, gives a copy-paste-able ssh:// form, and preserves .git based on user input. This is better UX than git's native error and most package managers.
  • Suggestion (non-blocking): when the user wrote git@host:7999/project/repo.git#main or ...@my-alias, the suggestion currently drops the #ref / @alias because they're stripped before the port check. The tests at lines 832 and 838 confirm the detection still fires -- great -- but the suggested ssh:// URL won't include the user's ref/alias, so they have to re-add it manually. Worth mirroring them into the suggestion.
  • Minor (out of scope): the quoted URL in the message uses the post-strip repo_url (e.g. 'git@host:7999/project/repo') rather than the user's literal input -- a bit confusing when the original had .git or #ref. Quoting the original dependency_str would be more faithful, but again, polish.

APM CEO: approve, request CHANGELOG line

  • Bitbucket Datacenter on non-22 ports is a real enterprise use case; fixing this unblocks adoption inside enterprises that bounced silently before.
  • Not a breaking change -- previously users hit a 404 from git, now they hit an actionable APM error. Strictly better.
  • Ask: add a one-line Fixed entry under [Unreleased] in CHANGELOG.md (Closes #784, #787). Per .github/instructions/changelog.instructions.md every merged PR that changes code needs a line. This is the only thing I'd consider near-blocking.

OSS Growth Hacker: positive signal

  • Story angle worth surfacing in the next release: "APM now plays nice with self-hosted Bitbucket / GitLab / Gitea on non-standard SSH ports." Small but it's the kind of "we listen to enterprise" detail that converts.
  • External contributor merged quickly = social proof for the project's responsiveness. Good.

Recommended path to merge

  1. Required: add CHANGELOG entry under [Unreleased] -> Fixed.
  2. Optional polish (can be follow-up if you want speed): preserve #ref and @alias in the suggested ssh:// URL when present.

LGTM after #1. Thanks @edenfunf for the clean fix and thorough tests.

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

Improves DependencyReference parsing to detect a common misconfiguration where users provide an SCP-style SSH shorthand URL with a port-like first path segment (which SCP syntax cannot represent), and raises an actionable error suggesting the correct ssh:// form.

Changes:

  • Add port-like first-segment detection to _parse_ssh_url for git@host:port/path inputs and raise a helpful ValueError with an ssh:// suggestion.
  • Add unit tests covering valid port boundaries, leading zeros, ref/alias handling, and non-detection for out-of-range values.
Show a summary per file
File Description
src/apm_cli/models/dependency/reference.py Adds SCP-shorthand port-like segment detection and improved error messaging with an ssh:// suggestion.
tests/unit/test_generic_git_urls.py Adds a new test class validating the new detection logic and key edge cases.

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 2

Comment thread src/apm_cli/models/dependency/reference.py Outdated
Comment thread tests/unit/test_generic_git_urls.py Outdated
…entry

Address review feedback:
- Thread reference and alias into the suggested ssh:// URL so users
  don't have to re-add them manually after switching from SCP form.
- Add CHANGELOG entry under [Unreleased] -> Fixed for microsoft#784.
- Add test for combined #ref@alias preservation.
Address Copilot review:
- git@host:7999/ (trailing slash, empty remaining path) now falls into
  the "no repository path follows" branch instead of building a
  suggestion ending with '/'.
- Switch test_suggestion_omits_git_suffix_when_absent from try/except
  to pytest.raises for consistency with the rest of the file.
@edenfunf
Copy link
Copy Markdown
Contributor Author

Thanks @danielmeppiel for the thorough panel review. Both items addressed:

1. CHANGELOG entry (required) — added under [Unreleased]Fixed:

Detect port-like first path segment in SCP shorthand (git@host:7999/path) and raise an actionable error suggesting the ssh:// URL form, instead of silently misparsing the port as part of the repository path (#784)

2. Preserve #ref and @alias in suggestion (polish) — the suggested ssh:// URL now threads reference and alias back into the output. For example:

git@host:7999/project/repo.git#v1.0@my-alias
→ ssh://git@host:7999/project/repo.git#v1.0@my-alias

Added a dedicated test (test_scp_port_with_ref_and_alias_preserves_both) to cover the combined case.

Also addressed Copilot inline findings:

  • git@host:7999/ (trailing slash, empty remaining path) now correctly falls into the "no repository path follows" branch instead of building a suggestion ending with /. Added test_scp_port_trailing_slash_no_path_raises to cover this.
  • Switched test_suggestion_omits_git_suffix_when_absent from try/except + assert False to pytest.raises for consistency with the rest of the test file.

All 4208 unit tests pass.

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.

@edenfunf excellent PR - thank you for this!

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.

[BUG] SCP shorthand with numeric port silently misparsed as repo path

3 participants