Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions src/apm_cli/marketplace/yml_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@
_OWNER_REPO_PAT = rf"{_SEGMENT_PAT}/{_SEGMENT_PAT}"
_HTTPS_REPOSITORY_PAT = rf"{_SEGMENT_PAT}(?:/{_SEGMENT_PAT})+"
_RELATIVE_SOURCE_PAT = rf"{_SEGMENT_PAT}(?:/{_SEGMENT_PAT})*"
_SOURCE_BASE_SEGMENT_PAT = r"(?:[A-Za-z0-9._-]|%[0-9A-Fa-f]{2})+"
_SOURCE_BASE_PATH_PAT = rf"{_SOURCE_BASE_SEGMENT_PAT}(?:/{_SOURCE_BASE_SEGMENT_PAT})*"

SOURCE_RE = re.compile(
r"^(?:"
Expand All @@ -107,7 +109,7 @@
r")$"
)
LOCAL_SOURCE_RE = re.compile(r"^\./")
SOURCE_BASE_RE = re.compile(rf"^https://{_HOST_PAT}/{_RELATIVE_SOURCE_PAT}$")
SOURCE_BASE_RE = re.compile(rf"^https://{_HOST_PAT}/{_SOURCE_BASE_PATH_PAT}$")
_RELATIVE_SOURCE_RE = re.compile(rf"^{_RELATIVE_SOURCE_PAT}$")
# Matches ``host.tld/owner/repo`` (3 segments, first is FQDN-ish).
_HOST_PREFIXED_SOURCE_RE = re.compile(rf"^({_HOST_PAT})/({_OWNER_REPO_PAT})$")
Expand Down Expand Up @@ -549,8 +551,31 @@ def parse_source_base(raw: Any) -> str | None:
raise MarketplaceYmlError(str(exc)) from exc
if not SOURCE_BASE_RE.match(source_base):
raise MarketplaceYmlError(
"'sourceBase' path segments may only contain letters, digits, dot, underscore, or hyphen"
"'sourceBase' path segments may only contain letters, digits, dot, underscore, hyphen, "
"or percent-encoded bytes"
)

decoded_segments: list[str] = []
for segment in path.split("/"):
try:
decoded = _urlparse.unquote_to_bytes(segment).decode("utf-8")
except UnicodeDecodeError as exc:
raise MarketplaceYmlError(
"'sourceBase' contains invalid UTF-8 percent-encoding"
) from exc
if "/" in decoded or "\\" in decoded:
raise MarketplaceYmlError(
"'sourceBase' percent-encoding must not decode to a path separator"
)
decoded_segments.append(decoded)
try:
validate_path_segments(
"/".join(decoded_segments),
context="sourceBase",
reject_empty=True,
)
except PathTraversalError as exc:
raise MarketplaceYmlError(str(exc)) from exc
return source_base


Expand Down
16 changes: 16 additions & 0 deletions tests/unit/marketplace/test_marketplace_source_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,20 @@ def test_accepts_single_and_nested_relative_sources_when_source_base_is_set(
assert config.packages[1].source == "team/tools/nested-tool"
assert config.packages[1].host is None

def test_accepts_percent_encoded_space_in_ado_source_base(self, tmp_path: Path) -> None:
config = _load_config(
tmp_path,
"https://dev.azure.com/contoso/My%20Projects/_git",
f"""
- name: ado-tool
source: agent-skills
ref: {_SHA}
""",
)

assert config.source_base == "https://dev.azure.com/contoso/My%20Projects/_git"
assert config.packages[0].source == "agent-skills"

def test_absent_source_base_keeps_owner_repo_source_unchanged(self, tmp_path: Path) -> None:
config = _load_config(
tmp_path,
Expand Down Expand Up @@ -125,6 +139,8 @@ def test_absent_source_base_keeps_owner_repo_source_unchanged(self, tmp_path: Pa
("https://gitlab.example.com/group//repo", "empty"),
("https://gitlab.example.com/group//", "empty"),
("https://gitlab.example.com/group/../repo", "traversal"),
("https://dev.azure.com/contoso/%2e%2e/_git", "traversal"),
("https://dev.azure.com/contoso/My%2FProjects/_git", "path separator"),
],
)
def test_rejects_source_base_security_guard_violations(
Expand Down