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
19 changes: 17 additions & 2 deletions pulp_rust/app/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ def _normalize_req(version_str):
return f"^{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):
Expand All @@ -49,11 +56,19 @@ def parse_dep(name, spec, kind="normal", target=None):
"default_features": True,
"target": target,
"kind": kind,
"registry": None,
"registry": CRATES_IO_INDEX,
"package": 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", "*")),
Expand All @@ -62,7 +77,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),
Expand Down
12 changes: 12 additions & 0 deletions pulp_rust/app/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import io
import json
import logging
import os
import struct
import tarfile
import tempfile
import urllib.error
import urllib.request
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 36 additions & 2 deletions pulp_rust/tests/functional/api/test_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 14 additions & 1 deletion pulp_rust/tests/functional/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down Expand Up @@ -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:
Expand Down
29 changes: 27 additions & 2 deletions pulp_rust/tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
django.setup()

from pulp_rust.app.utils import ( # noqa: E402
CRATES_IO_INDEX,
UnsupportedDependencyError,
canonicalize_crate_name,
extract_cargo_toml,
extract_dependencies,
Expand Down Expand Up @@ -44,7 +46,7 @@ def test_simple_string_spec(self):
"default_features": True,
"target": None,
"kind": "normal",
"registry": None,
"registry": CRATES_IO_INDEX,
"package": None,
}

Expand All @@ -63,7 +65,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)
Expand All @@ -75,6 +77,29 @@ 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_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})
assert result["req"] == "*"
Expand Down
Loading