From 83fb1abf029e7f3a0f5377eff980efccc087a493 Mon Sep 17 00:00:00 2001 From: Daniel Alley Date: Thu, 2 Apr 2026 10:50:40 -0400 Subject: [PATCH] Add cargo publish support Assisted-By: claude-opus-4.6 --- README.md | 1 - pulp_rust/app/tasks/__init__.py | 1 + pulp_rust/app/tasks/publishing.py | 110 ++++++++++ pulp_rust/app/urls.py | 12 +- pulp_rust/app/views.py | 110 +++++++++- .../tests/functional/api/test_publish.py | 205 ++++++++++++++++++ pulp_rust/tests/functional/api/test_upload.py | 84 +++++++ pulp_rust/tests/functional/utils.py | 17 ++ 8 files changed, 533 insertions(+), 7 deletions(-) create mode 100644 pulp_rust/app/tasks/publishing.py create mode 100644 pulp_rust/tests/functional/api/test_publish.py create mode 100644 pulp_rust/tests/functional/api/test_upload.py diff --git a/README.md b/README.md index 60afe67..5c09c69 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,6 @@ A Pulp plugin to support hosting your own Rust/Cargo package registry. The following features are not yet implemented but are planned for future releases: -- **Publishing** (`cargo publish`) -- crates cannot yet be uploaded via the Cargo CLI - **Authentication & authorization** -- the registry is currently open to all clients - **Syncing** -- mirroring an entire upstream registry is not yet supported; use pull-through caching instead diff --git a/pulp_rust/app/tasks/__init__.py b/pulp_rust/app/tasks/__init__.py index 037ed67..49f4862 100755 --- a/pulp_rust/app/tasks/__init__.py +++ b/pulp_rust/app/tasks/__init__.py @@ -1,3 +1,4 @@ +from .publishing import parse_cargo_publish_body, apublish_package # noqa from .synchronizing import synchronize # noqa from .streaming import add_cached_content_to_repository # noqa from .yanking import ayank_package, aunyank_package # noqa diff --git a/pulp_rust/app/tasks/publishing.py b/pulp_rust/app/tasks/publishing.py new file mode 100644 index 0000000..9f08247 --- /dev/null +++ b/pulp_rust/app/tasks/publishing.py @@ -0,0 +1,110 @@ +import hashlib +import struct + +from pulpcore.plugin.models import Artifact, ContentArtifact +from pulpcore.plugin.tasking import aadd_and_remove + +from pulp_rust.app.models import RustContent, RustDependency, RustRepository +from pulp_rust.app.utils import extract_cargo_toml, extract_dependencies + + +def parse_cargo_publish_body(body): + """ + Parse the binary request body from ``cargo publish``. + + Format (per https://doc.rust-lang.org/cargo/reference/registry-web-api.html#publish): + 4 bytes: JSON metadata length (little-endian u32) + N bytes: JSON metadata (UTF-8) + 4 bytes: .crate file length (little-endian u32) + M bytes: .crate file (binary) + + Returns: + (metadata_dict, crate_bytes) + """ + import json + + offset = 0 + + json_len = struct.unpack_from("//", CargoDownloadApiView.as_view(), diff --git a/pulp_rust/app/views.py b/pulp_rust/app/views.py index 9e02bde..a8d9fdf 100644 --- a/pulp_rust/app/views.py +++ b/pulp_rust/app/views.py @@ -1,12 +1,13 @@ import json import logging +import tempfile import urllib.request import urllib.error +from rest_framework.renderers import BaseRenderer, JSONRenderer from rest_framework.views import APIView from rest_framework.viewsets import ViewSet from rest_framework.exceptions import Throttled -from rest_framework.renderers import BaseRenderer from django.core.exceptions import ObjectDoesNotExist from django.shortcuts import redirect, get_object_or_404 @@ -20,7 +21,6 @@ from urllib.parse import urljoin from pulpcore.plugin.util import get_domain - from pulpcore.plugin.tasking import dispatch from pulp_rust.app.models import ( @@ -29,7 +29,12 @@ RustPackageYank, _strip_sparse_prefix, ) -from pulp_rust.app.tasks import ayank_package, aunyank_package +from pulp_rust.app.tasks import ( + ayank_package, + aunyank_package, + apublish_package, + parse_cargo_publish_body, +) from pulp_rust.app.serializers import ( IndexRootSerializer, RustContentSerializer, @@ -110,7 +115,7 @@ def initial(self, request, *args, **kwargs): else: cargo_base = request.build_absolute_uri(f"/pulp/cargo/{repo}/") self.base_content_url = urljoin(BASE_CONTENT_URL, f"pulp/cargo/{repo}/") - self.base_api_url = cargo_base + self.base_api_url = cargo_base.rstrip("/") self.base_download_url = f"{cargo_base}api/v1/crates" @classmethod @@ -253,6 +258,101 @@ def retrieve(self, request, repo): return HttpResponse(json.dumps(data), content_type="application/json") +class CargoPublishApiView(APIView): + """ + View for Cargo's crate publish endpoint (PUT /api/v1/crates/new). + + Parses the custom binary format from ``cargo publish`` and dispatches a task + to create the artifact, content, and new repository version. + + See: https://doc.rust-lang.org/cargo/reference/registry-web-api.html#publish + """ + + # TODO: Authentication/authorization is not yet implemented. + # All users with network access can publish. In production, this should + # require a valid token and verify crate ownership. + authentication_classes = [] + permission_classes = [] + renderer_classes = [JSONRenderer] + + def get_distribution(self): + return get_object_or_404( + RustDistribution, base_path=self.kwargs["repo"], pulp_domain=get_domain() + ) + + @staticmethod + def _error_response(detail, status=400): + return HttpResponse( + json.dumps({"errors": [{"detail": detail}]}), + content_type="application/json", + status=status, + ) + + def put(self, request, **kwargs): + """ + Handle ``cargo publish`` requests. + + Parses the binary body (JSON metadata + .crate tarball), validates the + distribution allows uploads and the crate doesn't already exist in the + repository, then dispatches a publish task. + """ + distro = self.get_distribution() + + if not distro.allow_uploads: + return self._error_response("this registry does not allow uploads", status=403) + + if not distro.repository: + return self._error_response( + "no repository associated with this distribution", status=404 + ) + + try: + metadata, crate_bytes = parse_cargo_publish_body(request.body) + except Exception: + return self._error_response("invalid publish request body") + + name = metadata.get("name") + vers = metadata.get("vers") + if not name or not vers: + return self._error_response("missing required fields: name, vers") + + # Check for duplicates before dispatching — crates.io rejects re-publishing + repo_version = distro.repository.latest_version() + if RustContent.objects.filter(pk__in=repo_version.content, name=name, vers=vers).exists(): + return self._error_response(f"crate version `{name}@{vers}` is already uploaded") + + # 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) + tmp.write(crate_bytes) + tmp.close() + + task = dispatch( + apublish_package, + exclusive_resources=[distro.repository], + immediate=True, + kwargs={ + "repository_pk": str(distro.repository.pk), + "metadata": metadata, + "crate_path": tmp.name, + }, + ) + has_task_completed(task) + + return HttpResponse( + json.dumps( + { + "warnings": { + "invalid_categories": [], + "invalid_badges": [], + "other": [], + } + } + ), + content_type="application/json", + ) + + class CargoDownloadApiView(APIView): """ View for Cargo's crate download, readme, yank, and unyank endpoints. @@ -261,7 +361,7 @@ class CargoDownloadApiView(APIView): # Authentication disabled for now authentication_classes = [] permission_classes = [] - renderer_classes = [PlainTextRenderer] + renderer_classes = [PlainTextRenderer, JSONRenderer] def get_full_path(self, base_path, pulp_domain=None): # TODO: replace with ApiMixin? if settings.DOMAIN_ENABLED: diff --git a/pulp_rust/tests/functional/api/test_publish.py b/pulp_rust/tests/functional/api/test_publish.py new file mode 100644 index 0000000..72cf074 --- /dev/null +++ b/pulp_rust/tests/functional/api/test_publish.py @@ -0,0 +1,205 @@ +"""Tests for the Cargo publish API (PUT /api/v1/crates/new).""" + +import json +import struct + +import requests + +from pulp_rust.tests.functional.utils import ( + assert_index_entry_matches_upstream, + download_crate_from_upstream, + get_index_entry, +) +from pulp_rust.app.utils import extract_cargo_toml, extract_dependencies + + +def build_cargo_publish_body(metadata, crate_bytes): + """Build the binary request body that ``cargo publish`` sends. + + Format (per Cargo registry web API spec): + 4 bytes: JSON metadata length (little-endian u32) + N bytes: JSON metadata (UTF-8) + 4 bytes: .crate file length (little-endian u32) + M bytes: .crate file (binary) + """ + json_bytes = json.dumps(metadata).encode("utf-8") + return ( + struct.pack("