Skip to content

Commit 21210cd

Browse files
committed
Add cargo publish support
Assisted-By: claude-opus-4.6
1 parent ca245e4 commit 21210cd

8 files changed

Lines changed: 502 additions & 7 deletions

File tree

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ A Pulp plugin to support hosting your own Rust/Cargo package registry.
1818

1919
The following features are not yet implemented but are planned for future releases:
2020

21-
- **Publishing** (`cargo publish`) -- crates cannot yet be uploaded via the Cargo CLI
2221
- **Authentication & authorization** -- the registry is currently open to all clients
2322
- **Syncing** -- mirroring an entire upstream registry is not yet supported; use pull-through caching instead
2423

pulp_rust/app/tasks/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from .publishing import parse_cargo_publish_body, apublish_package # noqa
12
from .synchronizing import synchronize # noqa
23
from .streaming import add_cached_content_to_repository # noqa
34
from .yanking import ayank_package, aunyank_package # noqa

pulp_rust/app/tasks/publishing.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import hashlib
2+
import struct
3+
4+
from pulpcore.plugin.models import Artifact, ContentArtifact
5+
from pulpcore.plugin.tasking import aadd_and_remove
6+
7+
from pulp_rust.app.models import RustContent, RustDependency, RustRepository
8+
from pulp_rust.app.utils import extract_cargo_toml, extract_dependencies
9+
10+
11+
def parse_cargo_publish_body(body):
12+
"""
13+
Parse the binary request body from ``cargo publish``.
14+
15+
Format (per https://doc.rust-lang.org/cargo/reference/registry-web-api.html#publish):
16+
4 bytes: JSON metadata length (little-endian u32)
17+
N bytes: JSON metadata (UTF-8)
18+
4 bytes: .crate file length (little-endian u32)
19+
M bytes: .crate file (binary)
20+
21+
Returns:
22+
(metadata_dict, crate_bytes)
23+
"""
24+
import json
25+
26+
offset = 0
27+
28+
json_len = struct.unpack_from("<I", body, offset)[0]
29+
offset += 4
30+
31+
json_bytes = body[offset : offset + json_len]
32+
offset += json_len
33+
metadata = json.loads(json_bytes)
34+
35+
crate_len = struct.unpack_from("<I", body, offset)[0]
36+
offset += 4
37+
38+
crate_bytes = body[offset : offset + crate_len]
39+
offset += crate_len
40+
41+
return metadata, crate_bytes
42+
43+
44+
async def apublish_package(repository_pk, metadata, crate_path):
45+
"""
46+
Publish a crate to a repository.
47+
48+
Creates the Artifact, RustContent, ContentArtifact, and RustDependency records,
49+
then adds the content to a new repository version.
50+
51+
Args:
52+
repository_pk: Primary key of the target repository.
53+
metadata: Parsed JSON metadata from the cargo publish request.
54+
crate_path: Filesystem path to the .crate tarball.
55+
"""
56+
repository = await RustRepository.objects.aget(pk=repository_pk)
57+
58+
name = metadata["name"]
59+
vers = metadata["vers"]
60+
61+
# Create the artifact from the .crate file
62+
with open(crate_path, "rb") as f:
63+
cksum = hashlib.sha256(f.read()).hexdigest()
64+
65+
artifact = Artifact.init_and_validate(crate_path, expected_digests={"sha256": cksum})
66+
await artifact.asave()
67+
68+
# Extract metadata from the Cargo.toml inside the .crate tarball.
69+
# Used as a fallback for fields not present in the publish JSON.
70+
cargo_toml = extract_cargo_toml(artifact.file.path, name, vers)
71+
72+
# Build dependency list from the publish metadata.
73+
# The publish JSON uses "version_req" (not "req") and "explicit_name_in_toml" (not "package")
74+
# per the Cargo registry web API spec.
75+
deps = []
76+
for dep in metadata.get("deps", []):
77+
deps.append(
78+
{
79+
"name": dep["name"],
80+
"req": dep.get("version_req", dep.get("req", "*")),
81+
"features": dep.get("features", []),
82+
"optional": dep.get("optional", False),
83+
"default_features": dep.get("default_features", True),
84+
"target": dep.get("target"),
85+
"kind": dep.get("kind", "normal"),
86+
"registry": dep.get("registry"),
87+
"package": dep.get("explicit_name_in_toml") or dep.get("package"),
88+
}
89+
)
90+
91+
# Fallback to Cargo.toml-parsed deps if publish metadata had none
92+
if not deps:
93+
deps = extract_dependencies(cargo_toml)
94+
95+
# Create the content record
96+
content = RustContent(
97+
name=name,
98+
vers=vers,
99+
cksum=cksum,
100+
features=metadata.get("features", cargo_toml.get("features", {})),
101+
features2=metadata.get("features2"),
102+
links=metadata.get("links", cargo_toml.get("package", {}).get("links")),
103+
rust_version=metadata.get(
104+
"rust_version", cargo_toml.get("package", {}).get("rust-version")
105+
),
106+
_pulp_domain_id=repository.pulp_domain_id,
107+
)
108+
await content.asave()
109+
110+
# Create dependencies
111+
if deps:
112+
await RustDependency.objects.abulk_create(
113+
[RustDependency(content=content, **dep) for dep in deps]
114+
)
115+
116+
# Create the content artifact (links the .crate file to the content)
117+
relative_path = f"{name}/{name}-{vers}.crate"
118+
await ContentArtifact.objects.acreate(
119+
artifact=artifact, content=content, relative_path=relative_path
120+
)
121+
122+
# Add the content to a new repository version
123+
await aadd_and_remove(
124+
repository_pk=repository.pk,
125+
add_content_units=[content.pk],
126+
remove_content_units=[],
127+
)

pulp_rust/app/urls.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
from django.conf import settings
22
from django.urls import path
33

4-
from pulp_rust.app.views import IndexRoot, CargoIndexApiViewSet, CargoDownloadApiView
4+
from pulp_rust.app.views import (
5+
IndexRoot,
6+
CargoIndexApiViewSet,
7+
CargoDownloadApiView,
8+
CargoPublishApiView,
9+
)
510

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

1116

1217
urlpatterns = [
18+
path(
19+
CRATES_IO_URL + "api/v1/crates/new",
20+
CargoPublishApiView.as_view(),
21+
name="cargo-publish-api",
22+
),
1323
path(
1424
CRATES_IO_URL + "api/v1/crates/<str:name>/<str:version>/<path:rest>",
1525
CargoDownloadApiView.as_view(),

pulp_rust/app/views.py

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import json
22
import logging
3+
import tempfile
34
import urllib.request
45
import urllib.error
56

7+
from rest_framework.renderers import BaseRenderer, JSONRenderer
68
from rest_framework.views import APIView
79
from rest_framework.viewsets import ViewSet
810
from rest_framework.exceptions import Throttled
9-
from rest_framework.renderers import BaseRenderer
1011
from django.core.exceptions import ObjectDoesNotExist
1112
from django.shortcuts import redirect, get_object_or_404
1213

@@ -20,7 +21,6 @@
2021
from urllib.parse import urljoin
2122

2223
from pulpcore.plugin.util import get_domain
23-
2424
from pulpcore.plugin.tasking import dispatch
2525

2626
from pulp_rust.app.models import (
@@ -29,7 +29,12 @@
2929
RustPackageYank,
3030
_strip_sparse_prefix,
3131
)
32-
from pulp_rust.app.tasks import ayank_package, aunyank_package
32+
from pulp_rust.app.tasks import (
33+
ayank_package,
34+
aunyank_package,
35+
apublish_package,
36+
parse_cargo_publish_body,
37+
)
3338
from pulp_rust.app.serializers import (
3439
IndexRootSerializer,
3540
RustContentSerializer,
@@ -110,7 +115,7 @@ def initial(self, request, *args, **kwargs):
110115
else:
111116
cargo_base = request.build_absolute_uri(f"/pulp/cargo/{repo}/")
112117
self.base_content_url = urljoin(BASE_CONTENT_URL, f"pulp/cargo/{repo}/")
113-
self.base_api_url = cargo_base
118+
self.base_api_url = cargo_base.rstrip("/")
114119
self.base_download_url = f"{cargo_base}api/v1/crates"
115120

116121
@classmethod
@@ -253,6 +258,101 @@ def retrieve(self, request, repo):
253258
return HttpResponse(json.dumps(data), content_type="application/json")
254259

255260

261+
class CargoPublishApiView(APIView):
262+
"""
263+
View for Cargo's crate publish endpoint (PUT /api/v1/crates/new).
264+
265+
Parses the custom binary format from ``cargo publish`` and dispatches a task
266+
to create the artifact, content, and new repository version.
267+
268+
See: https://doc.rust-lang.org/cargo/reference/registry-web-api.html#publish
269+
"""
270+
271+
# TODO: Authentication/authorization is not yet implemented.
272+
# All users with network access can publish. In production, this should
273+
# require a valid token and verify crate ownership.
274+
authentication_classes = []
275+
permission_classes = []
276+
renderer_classes = [JSONRenderer]
277+
278+
def get_distribution(self):
279+
return get_object_or_404(
280+
RustDistribution, base_path=self.kwargs["repo"], pulp_domain=get_domain()
281+
)
282+
283+
@staticmethod
284+
def _error_response(detail, status=400):
285+
return HttpResponse(
286+
json.dumps({"errors": [{"detail": detail}]}),
287+
content_type="application/json",
288+
status=status,
289+
)
290+
291+
def put(self, request, **kwargs):
292+
"""
293+
Handle ``cargo publish`` requests.
294+
295+
Parses the binary body (JSON metadata + .crate tarball), validates the
296+
distribution allows uploads and the crate doesn't already exist in the
297+
repository, then dispatches a publish task.
298+
"""
299+
distro = self.get_distribution()
300+
301+
if not distro.allow_uploads:
302+
return self._error_response("this registry does not allow uploads", status=403)
303+
304+
if not distro.repository:
305+
return self._error_response(
306+
"no repository associated with this distribution", status=404
307+
)
308+
309+
try:
310+
metadata, crate_bytes = parse_cargo_publish_body(request.body)
311+
except Exception:
312+
return self._error_response("invalid publish request body")
313+
314+
name = metadata.get("name")
315+
vers = metadata.get("vers")
316+
if not name or not vers:
317+
return self._error_response("missing required fields: name, vers")
318+
319+
# Check for duplicates before dispatching — crates.io rejects re-publishing
320+
repo_version = distro.repository.latest_version()
321+
if RustContent.objects.filter(pk__in=repo_version.content, name=name, vers=vers).exists():
322+
return self._error_response(f"crate version `{name}@{vers}` is already uploaded")
323+
324+
# Write the .crate bytes to a temp file — raw bytes can't be passed
325+
# through dispatch() because task kwargs are stored as JSON.
326+
tmp = tempfile.NamedTemporaryFile(suffix=".crate", delete=False)
327+
tmp.write(crate_bytes)
328+
tmp.close()
329+
330+
task = dispatch(
331+
apublish_package,
332+
exclusive_resources=[distro.repository],
333+
immediate=True,
334+
kwargs={
335+
"repository_pk": str(distro.repository.pk),
336+
"metadata": metadata,
337+
"crate_path": tmp.name,
338+
},
339+
)
340+
has_task_completed(task)
341+
342+
return HttpResponse(
343+
json.dumps(
344+
{
345+
"warnings": {
346+
"invalid_categories": [],
347+
"invalid_badges": [],
348+
"other": [],
349+
}
350+
}
351+
),
352+
content_type="application/json",
353+
)
354+
355+
256356
class CargoDownloadApiView(APIView):
257357
"""
258358
View for Cargo's crate download, readme, yank, and unyank endpoints.
@@ -261,7 +361,7 @@ class CargoDownloadApiView(APIView):
261361
# Authentication disabled for now
262362
authentication_classes = []
263363
permission_classes = []
264-
renderer_classes = [PlainTextRenderer]
364+
renderer_classes = [PlainTextRenderer, JSONRenderer]
265365

266366
def get_full_path(self, base_path, pulp_domain=None): # TODO: replace with ApiMixin?
267367
if settings.DOMAIN_ENABLED:

0 commit comments

Comments
 (0)