Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- When a registry proxy serves an archive that APM rejects (an unsafe member such as a symlink, or a body that is not a zip), `apm install` now reports that rejection instead of a later fallback URL's HTTP 404. Archive containment checks are unchanged and symlinks are still refused. (#3375)

## [0.35.0] - 2026-10-22

### Added
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/enterprise/registry-proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,8 @@ and `apm cache clean`.
| HTTP 404 on every ref of an existing GitLab project | Default branch is `master`, APM defaults to `main` | Pin the ref: `<repo>#master` in `apm.yml` |
| Upstream URL in `X-Artifactory-Origin-Remote-Path` has a duplicated group name | The remote's "Repository Path Prefix" is prepending a segment that's also in the request | Clear the prefix field on the remote |

When the proxy serves an archive that APM rejects, for example because a member is a symlink or the body is not a zip, the error reports that archive's URL and reason after `Archive error:`, even if later fallback URLs return HTTP 404.

For fully disconnected CI (no proxy reach at all), build a bundle on a
connected host with `apm pack` and restore offline. See
[Pack and distribute](../../producer/pack-a-bundle/).
Expand Down
8 changes: 7 additions & 1 deletion src/apm_cli/deps/download_strategies.py
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,9 @@ def download_artifactory_archive(
max_archive_bytes = int(os.environ.get("ARTIFACTORY_MAX_ARCHIVE_MB", "500")) * 1024 * 1024

last_error = None
# A 200 archive that fails validation is a more useful cause than a
# later 404 from a fallback URL, so report it unless a later URL succeeds.
archive_error = None
for url in archive_urls:
_debug(f"Trying Artifactory archive: {url}")
resp = None
Expand Down Expand Up @@ -467,9 +470,11 @@ def download_artifactory_archive(
_debug(last_error)
except ArchiveError as e:
last_error = f"Unsafe zip archive from {url}: {e}"
archive_error = archive_error or last_error
_debug(last_error)
except zipfile.BadZipFile:
last_error = f"Invalid zip archive from {url}"
archive_error = archive_error or last_error
_debug(last_error)
except requests.RequestException as e:
last_error = str(e)
Expand All @@ -478,9 +483,10 @@ def download_artifactory_archive(
if resp is not None:
_close_response(resp, "artifactory archive")

detail = f"Archive error: {archive_error}" if archive_error else f"Last error: {last_error}"
raise RuntimeError(
f"Failed to download package {owner}/{repo}#{ref} from Artifactory "
f"({host}/{prefix}). Last error: {last_error}"
f"({host}/{prefix}). {detail}"
)

def download_file_from_artifactory(
Expand Down
72 changes: 72 additions & 0 deletions tests/unit/test_artifactory_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -1107,6 +1107,78 @@ def test_raises_on_empty_archive(self):
target,
)

def _make_symlink_zip_bytes(self):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("repo-main/", "")
zf.writestr("repo-main/apm.yml", b"name: test\nversion: 1.0.0\n")
link = zipfile.ZipInfo("repo-main/link")
link.create_system = 3
link.external_attr = 0o120777 << 16
zf.writestr(link, "apm.yml")
return buf.getvalue()

@pytest.mark.parametrize(
("body", "expected"),
[
pytest.param(
"symlink",
"Unsafe zip archive from https://art.example.com/artifactory/github/owner/"
"repo/archive/refs/heads/main.zip: Refusing to extract symlink: repo-main/link",
id="unsafe-member",
),
pytest.param(
b"<!DOCTYPE html><html></html>",
"Invalid zip archive from https://art.example.com/artifactory/github/owner/"
"repo/archive/refs/heads/main.zip",
id="invalid-zip",
),
],
)
def test_archive_rejection_not_masked_by_later_404(self, body, expected):
"""A rejected 200 archive stays the reported cause when later URLs 404 (#3110)."""
content = self._make_symlink_zip_bytes() if body == "symlink" else body
responses = [_mock_stream_response(200, content)] + [
_mock_stream_response(404) for _ in range(4)
]

target = self.temp_dir / "pkg"
with patch.object(self.downloader, "_resilient_get", side_effect=responses):
with pytest.raises(RuntimeError) as exc_info:
self.downloader._download_artifactory_archive(
"art.example.com",
"artifactory/github",
"owner",
"repo",
"main",
target,
)

message = str(exc_info.value)
assert message.endswith(expected)
assert "HTTP 404" not in message

def test_rejected_archive_then_valid_archive_succeeds(self):
"""A later valid archive still wins after an earlier archive is rejected."""
responses = [
_mock_stream_response(200, self._make_symlink_zip_bytes()),
_mock_stream_response(404),
_mock_stream_response(200, self._make_zip_bytes()),
]

target = self.temp_dir / "pkg"
with patch.object(self.downloader, "_resilient_get", side_effect=responses):
self.downloader._download_artifactory_archive(
"art.example.com",
"artifactory/github",
"owner",
"repo",
"main",
target,
)

assert (target / "README.md").exists()

def test_nested_directories_extracted(self):
"""Nested directories within the archive are properly extracted."""
files = {
Expand Down
Loading