Skip to content

Commit 959e1ce

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

5 files changed

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

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: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
from urllib.parse import urljoin
2121

2222
from pulpcore.plugin.util import get_domain
23-
2423
from pulpcore.plugin.tasking import dispatch
2524

2625
from pulp_rust.app.models import (
@@ -29,7 +28,12 @@
2928
RustPackageYank,
3029
_strip_sparse_prefix,
3130
)
32-
from pulp_rust.app.tasks import ayank_package, aunyank_package
31+
from pulp_rust.app.tasks import (
32+
ayank_package,
33+
aunyank_package,
34+
apublish_package,
35+
parse_cargo_publish_body,
36+
)
3337
from pulp_rust.app.serializers import (
3438
IndexRootSerializer,
3539
RustContentSerializer,
@@ -253,6 +257,94 @@ def retrieve(self, request, repo):
253257
return HttpResponse(json.dumps(data), content_type="application/json")
254258

255259

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

0 commit comments

Comments
 (0)