Skip to content
Merged
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
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions pulp_rust/app/tasks/__init__.py
Original file line number Diff line number Diff line change
@@ -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
110 changes: 110 additions & 0 deletions pulp_rust/app/tasks/publishing.py
Original file line number Diff line number Diff line change
@@ -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("<I", body, offset)[0]
offset += 4

json_bytes = body[offset : offset + json_len]
offset += json_len
metadata = json.loads(json_bytes)

crate_len = struct.unpack_from("<I", body, offset)[0]
offset += 4

crate_bytes = body[offset : offset + crate_len]
offset += crate_len

return metadata, crate_bytes


async def apublish_package(repository_pk, metadata, crate_path):
"""
Publish a crate to a repository.

Creates the Artifact, RustContent, ContentArtifact, and RustDependency records,
then adds the content to a new repository version.

Args:
repository_pk: Primary key of the target repository.
metadata: Parsed JSON metadata from the cargo publish request.
crate_path: Filesystem path to the .crate tarball.
"""
repository = await RustRepository.objects.aget(pk=repository_pk)

# Create the artifact from the .crate file
with open(crate_path, "rb") as f:
cksum = hashlib.sha256(f.read()).hexdigest()

artifact = Artifact.init_and_validate(crate_path, expected_digests={"sha256": cksum})
await artifact.asave()

# Extract authoritative metadata from the Cargo.toml inside the .crate tarball.
# The publish JSON metadata is NOT authoritative — a rogue client can send metadata
# that doesn't match the actual package. We only use the JSON name/vers to locate the
# Cargo.toml within the tarball, then extract everything from the Cargo.toml itself.
# See: https://github.com/rust-lang/cargo/issues/14492
# https://github.com/rust-lang/crates.io/pull/7238
cargo_toml = extract_cargo_toml(artifact.file.path, metadata["name"], metadata["vers"])
package = cargo_toml.get("package", {})

name = package["name"]
vers = package["version"]

# Build dependency list from the Cargo.toml (authoritative source)
deps = extract_dependencies(cargo_toml)

# Create the content record
content = RustContent(
name=name,
vers=vers,
cksum=cksum,
features=cargo_toml.get("features", {}),
features2=None,
links=package.get("links"),
rust_version=package.get("rust-version"),
_pulp_domain_id=repository.pulp_domain_id,
)
await content.asave()

# Create dependencies
if deps:
await RustDependency.objects.abulk_create(
[RustDependency(content=content, **dep) for dep in deps]
)

# Create the content artifact (links the .crate file to the content)
relative_path = f"{name}/{name}-{vers}.crate"
await ContentArtifact.objects.acreate(
artifact=artifact, content=content, relative_path=relative_path
)

# Add the content to a new repository version
await aadd_and_remove(
repository_pk=repository.pk,
add_content_units=[content.pk],
remove_content_units=[],
)
12 changes: 11 additions & 1 deletion pulp_rust/app/urls.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
from django.conf import settings
from django.urls import path

from pulp_rust.app.views import IndexRoot, CargoIndexApiViewSet, CargoDownloadApiView
from pulp_rust.app.views import (
IndexRoot,
CargoIndexApiViewSet,
CargoDownloadApiView,
CargoPublishApiView,
)

if settings.DOMAIN_ENABLED:
CRATES_IO_URL = "pulp/cargo/<slug:pulp_domain>/<slug:repo>/"
Expand All @@ -10,6 +15,11 @@


urlpatterns = [
path(
CRATES_IO_URL + "api/v1/crates/new",
CargoPublishApiView.as_view(),
name="cargo-publish-api",
),
path(
CRATES_IO_URL + "api/v1/crates/<str:name>/<str:version>/<path:rest>",
CargoDownloadApiView.as_view(),
Expand Down
110 changes: 105 additions & 5 deletions pulp_rust/app/views.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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 (
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down
Loading