From a206d7acdbff9ec8788f6154c4f81cabbb2df215 Mon Sep 17 00:00:00 2001 From: baptiste Date: Mon, 28 Sep 2026 15:15:50 +0200 Subject: [PATCH 1/2] fix(deps): resolve crates.io dependencies to the crates.io index --- pulp_rust/app/utils.py | 7 +++++-- pulp_rust/tests/unit/test_utils.py | 20 ++++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/pulp_rust/app/utils.py b/pulp_rust/app/utils.py index 70a4fc5..07c32d7 100644 --- a/pulp_rust/app/utils.py +++ b/pulp_rust/app/utils.py @@ -37,6 +37,9 @@ def _normalize_req(version_str): return f"^{version_str}" +CRATES_IO_INDEX = "https://github.com/rust-lang/crates.io-index" + + def parse_dep(name, spec, kind="normal", target=None): """Convert a single Cargo.toml dependency entry to index format.""" if isinstance(spec, str): @@ -49,7 +52,7 @@ def parse_dep(name, spec, kind="normal", target=None): "default_features": True, "target": target, "kind": kind, - "registry": None, + "registry": CRATES_IO_INDEX, "package": None, } @@ -62,7 +65,7 @@ def parse_dep(name, spec, kind="normal", target=None): "default_features": spec.get("default-features", True), "target": target, "kind": kind, - "registry": spec.get("registry"), + "registry": spec.get("registry-index") or CRATES_IO_INDEX, "package": None, } # If the dep was renamed, "name" in the index is the alias (the key), diff --git a/pulp_rust/tests/unit/test_utils.py b/pulp_rust/tests/unit/test_utils.py index 086fbee..f59d017 100644 --- a/pulp_rust/tests/unit/test_utils.py +++ b/pulp_rust/tests/unit/test_utils.py @@ -10,6 +10,7 @@ django.setup() from pulp_rust.app.utils import ( # noqa: E402 + CRATES_IO_INDEX, canonicalize_crate_name, extract_cargo_toml, extract_dependencies, @@ -44,7 +45,7 @@ def test_simple_string_spec(self): "default_features": True, "target": None, "kind": "normal", - "registry": None, + "registry": CRATES_IO_INDEX, "package": None, } @@ -63,7 +64,7 @@ def test_table_spec_all_fields(self): "features": ["derive", "std"], "optional": True, "default-features": False, - "registry": "https://my-registry.example.com/", + "registry-index": "https://my-registry.example.com/", "package": "serde_real", } result = parse_dep("my_serde", spec) @@ -75,6 +76,21 @@ def test_table_spec_all_fields(self): assert result["registry"] == "https://my-registry.example.com/" assert result["package"] == "serde_real" + def test_string_spec_registry_is_crates_io(self): + assert parse_dep("serde", "1.0")["registry"] == CRATES_IO_INDEX + + def test_table_spec_without_registry_defaults_to_crates_io(self): + result = parse_dep("serde", {"version": "1.0"}) + assert result["registry"] == CRATES_IO_INDEX + + def test_table_spec_registry_index_preserved(self): + result = parse_dep("foo", {"version": "1.0", "registry-index": "https://r.example/"}) + assert result["registry"] == "https://r.example/" + + def test_table_spec_registry_name_is_ignored(self): + result = parse_dep("foo", {"version": "1.0", "registry": "my-named-registry"}) + assert result["registry"] == CRATES_IO_INDEX + def test_table_spec_no_version_defaults_to_star(self): result = parse_dep("foo", {"optional": True}) assert result["req"] == "*" From a29bb19f31a9180485319f5560003e755fb362bb Mon Sep 17 00:00:00 2001 From: pkriko Date: Fri, 23 Oct 2026 10:56:44 +0200 Subject: [PATCH 2/2] Address review on crates.io dependency resolution Reject a dependency that names a registry without a registry-index URL instead of treating it as a crates.io dependency. `cargo package` always rewrites a named registry to its index URL, so only a handcrafted crate hits this, and Pulp cannot resolve the name without the client's config. The publish view now parses the Cargo.toml before dispatching the task, so an unsupported dependency or an unreadable crate file is reported as a 400 Cargo error rather than a failed task. The publish fidelity tests compare against the crates.io index entry, whose dependencies omit `registry`. Normalize that entry to the crates.io index URL, as an alternate registry must serve it. Assisted By: Claude Opus 5.5 --- pulp_rust/app/utils.py | 12 ++++++ pulp_rust/app/views.py | 12 ++++++ .../tests/functional/api/test_publish.py | 38 ++++++++++++++++++- pulp_rust/tests/functional/utils.py | 15 +++++++- pulp_rust/tests/unit/test_utils.py | 15 ++++++-- 5 files changed, 86 insertions(+), 6 deletions(-) diff --git a/pulp_rust/app/utils.py b/pulp_rust/app/utils.py index 07c32d7..749391b 100644 --- a/pulp_rust/app/utils.py +++ b/pulp_rust/app/utils.py @@ -40,6 +40,10 @@ def _normalize_req(version_str): CRATES_IO_INDEX = "https://github.com/rust-lang/crates.io-index" +class UnsupportedDependencyError(ValueError): + """A Cargo.toml dependency that cannot be translated to an index entry.""" + + def parse_dep(name, spec, kind="normal", target=None): """Convert a single Cargo.toml dependency entry to index format.""" if isinstance(spec, str): @@ -57,6 +61,14 @@ def parse_dep(name, spec, kind="normal", target=None): } # Table form: dep = { version = "1.0", optional = true, ... } + if "registry" in spec and "registry-index" not in spec: + # `cargo package` rewrites a named registry to `registry-index`. A bare name can only + # be resolved with the client's configuration, so treating it as crates.io could + # point at the wrong crate. + raise UnsupportedDependencyError( + f"dependency `{name}` names registry `{spec['registry']}` without a " + "`registry-index` URL" + ) dep = { "name": name, "req": _normalize_req(spec.get("version", "*")), diff --git a/pulp_rust/app/views.py b/pulp_rust/app/views.py index 482039c..2cfdc34 100644 --- a/pulp_rust/app/views.py +++ b/pulp_rust/app/views.py @@ -1,7 +1,9 @@ +import io import json import logging import os import struct +import tarfile import tempfile import urllib.error import urllib.request @@ -43,7 +45,10 @@ parse_cargo_publish_body, ) from pulp_rust.app.utils import ( + UnsupportedDependencyError, canonicalize_crate_name, + extract_cargo_toml, + extract_dependencies, strip_semver_build_metadata, validate_crate_name, validate_crate_version, @@ -414,6 +419,13 @@ def put(self, request, **kwargs): if error := token_scope_error(request.auth, action, distro): return error + try: + extract_dependencies(extract_cargo_toml(io.BytesIO(crate_bytes), name, vers)) + except UnsupportedDependencyError as e: + return cargo_error(str(e)) + except (tarfile.TarError, EOFError, KeyError, FileNotFoundError, ValueError): + return cargo_error("invalid crate file") + # Write the .crate bytes to a temp file — raw bytes can't be passed # through dispatch() because task kwargs are stored as JSON. tmp = tempfile.NamedTemporaryFile(suffix=".crate", delete=False) diff --git a/pulp_rust/tests/functional/api/test_publish.py b/pulp_rust/tests/functional/api/test_publish.py index ac3c0c4..4f6a681 100644 --- a/pulp_rust/tests/functional/api/test_publish.py +++ b/pulp_rust/tests/functional/api/test_publish.py @@ -10,12 +10,15 @@ so keep these fidelity checks passing. """ +import io +import tarfile from urllib.parse import urljoin import pytest from pulp_rust.tests.functional.utils import ( CRATES_IO_URL, + as_alternate_registry_entry, assert_index_entry_matches_upstream, build_publish_metadata, cargo_publish, @@ -61,7 +64,9 @@ def test_cargo_publish_and_index_fidelity( # Fetch the index from Pulp and compare against crates.io pulp_entry = get_index_entry(base, "se/rd/serde", "1.0.210") - assert_index_entry_matches_upstream(pulp_entry, upstream_index_entry) + assert_index_entry_matches_upstream( + pulp_entry, as_alternate_registry_entry(upstream_index_entry) + ) def test_cargo_publish_duplicate_rejected( @@ -142,7 +147,9 @@ def test_cargo_publish_ignores_tampered_json_metadata( # The index entry should match crates.io (i.e. the Cargo.toml), not the tampered JSON pulp_entry = get_index_entry(base, "se/rd/serde", "1.0.210") - assert_index_entry_matches_upstream(pulp_entry, upstream_index_entry) + assert_index_entry_matches_upstream( + pulp_entry, as_alternate_registry_entry(upstream_index_entry) + ) def test_cargo_publish_octet_stream_content_type( @@ -198,6 +205,33 @@ def test_cargo_publish_uploads_disabled( assert any("does not allow uploads" in e["detail"] for e in errors) +def test_cargo_publish_named_registry_dependency_rejected( + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, + admin_auth_headers, +): + """A dependency naming a registry without its index URL cannot be resolved by Pulp.""" + cargo_toml = ( + b'[package]\nname = "named-dep"\nversion = "0.1.0"\n\n' + b'[dependencies]\nfoo = { version = "1.0", registry = "my-named-registry" }\n' + ) + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + info = tarfile.TarInfo(name="named-dep-0.1.0/Cargo.toml") + info.size = len(cargo_toml) + tar.addfile(info, io.BytesIO(cargo_toml)) + + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) + base = cargo_registry_url(distribution.base_path) + + metadata = {"name": "named-dep", "vers": "0.1.0", "deps": [], "features": {}} + response = cargo_publish(base, metadata, buf.getvalue(), headers=admin_auth_headers) + assert response.status_code == 400 + assert "my-named-registry" in response.json()["errors"][0]["detail"] + + def test_cargo_publish_invalid_name_rejected( rust_repo_factory, rust_distribution_factory, diff --git a/pulp_rust/tests/functional/utils.py b/pulp_rust/tests/functional/utils.py index 90aa583..b35b386 100644 --- a/pulp_rust/tests/functional/utils.py +++ b/pulp_rust/tests/functional/utils.py @@ -8,7 +8,7 @@ import aiohttp import requests -from pulp_rust.app.utils import extract_cargo_toml, extract_dependencies +from pulp_rust.app.utils import CRATES_IO_INDEX, extract_cargo_toml, extract_dependencies CRATES_IO_URL = "sparse+https://index.crates.io/" CRATES_IO_DOWNLOAD_URL = "https://static.crates.io/crates/" @@ -221,6 +221,19 @@ def download_crate_from_upstream(name, version): # --- Assertion helpers --- +def as_alternate_registry_entry(upstream_entry): + """Return a crates.io index entry as an alternate registry must serve it. + + crates.io omits `registry` on dependencies from itself. Any other registry has to name the + crates.io index explicitly, or Cargo resolves them against that registry instead. + """ + deps = [ + {**dep, "registry": dep.get("registry") or CRATES_IO_INDEX} + for dep in upstream_entry["deps"] + ] + return {**upstream_entry, "deps": deps} + + def assert_index_entry_matches_upstream(pulp_entry, upstream_entry): """Assert that a Pulp-served index entry matches the upstream for all stored fields.""" for field in INDEX_FIELDS_TO_COMPARE: diff --git a/pulp_rust/tests/unit/test_utils.py b/pulp_rust/tests/unit/test_utils.py index f59d017..3467c33 100644 --- a/pulp_rust/tests/unit/test_utils.py +++ b/pulp_rust/tests/unit/test_utils.py @@ -11,6 +11,7 @@ from pulp_rust.app.utils import ( # noqa: E402 CRATES_IO_INDEX, + UnsupportedDependencyError, canonicalize_crate_name, extract_cargo_toml, extract_dependencies, @@ -87,9 +88,17 @@ def test_table_spec_registry_index_preserved(self): result = parse_dep("foo", {"version": "1.0", "registry-index": "https://r.example/"}) assert result["registry"] == "https://r.example/" - def test_table_spec_registry_name_is_ignored(self): - result = parse_dep("foo", {"version": "1.0", "registry": "my-named-registry"}) - assert result["registry"] == CRATES_IO_INDEX + def test_table_spec_registry_name_is_rejected(self): + with pytest.raises(UnsupportedDependencyError, match="my-named-registry"): + parse_dep("foo", {"version": "1.0", "registry": "my-named-registry"}) + + def test_table_spec_registry_name_with_index_is_accepted(self): + spec = { + "version": "1.0", + "registry": "my-named-registry", + "registry-index": "https://r.example/", + } + assert parse_dep("foo", spec)["registry"] == "https://r.example/" def test_table_spec_no_version_defaults_to_star(self): result = parse_dep("foo", {"optional": True})