diff --git a/docs/index.md b/docs/index.md index 2053d5c..54c18dd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,6 +13,7 @@ See the [REST API documentation](site:pulp_rust/restapi/) for detailed endpoint - [Use Pulp as a pull-through cache](site:pulp_rust/docs/user/guides/pull-through-cache/) for crates.io or any Cargo sparse registry - [Host a private Cargo registry](site:pulp_rust/docs/user/guides/private-registry/) for internal crates +- Publish crates with `cargo publish` and manage them with `cargo yank` - Implements the [Cargo sparse registry protocol](https://doc.rust-lang.org/cargo/reference/registry-index.html#sparse-index) for compatibility with standard Cargo tooling - Download crates on-demand to reduce disk usage - Every operation creates a restorable snapshot with Versioned Repositories diff --git a/docs/user/guides/private-registry.md b/docs/user/guides/private-registry.md index 9e836ac..7243d01 100644 --- a/docs/user/guides/private-registry.md +++ b/docs/user/guides/private-registry.md @@ -4,10 +4,6 @@ This guide walks you through setting up Pulp as a private Cargo registry for hos crates. This is useful for organizations that need to distribute proprietary or internal-only Rust packages. -!!! note - Package publishing support (`cargo publish`) is not yet available but is planned for an - upcoming release. In the meantime, content can be uploaded through the Pulp REST API. - ## Create a Repository ```bash @@ -16,13 +12,15 @@ pulp rust repository create --name my-crates ## Create a Distribution -A distribution makes the repository's content available to Cargo over HTTP. +A distribution makes the repository's content available to Cargo over HTTP. Set `--allow-uploads` +to enable publishing crates via `cargo publish`. ```bash pulp rust distribution create \ --name my-crates \ --base-path my-crates \ - --repository my-crates + --repository my-crates \ + --allow-uploads ``` Your private registry is now served at `http:///pulp/cargo/my-crates/`. @@ -36,6 +34,58 @@ Add the private registry to your Cargo configuration. Create or edit `~/.cargo/c index = "sparse+http:///pulp/cargo/my-crates/" ``` +## Authentication + +State-changing operations (publishing, yanking, and unyanking) require an authorization token. +Configure the token for your registry in `~/.cargo/credentials.toml`: + +```toml +[registries.my-crates] +token = "i_understand_that_pulp_rust_does_not_support_proper_auth_yet" +``` + +Alternatively, you can pass the token on the command line: + +```bash +cargo publish --registry my-crates --token "i_understand_that_pulp_rust_does_not_support_proper_auth_yet" +``` + +!!! warning + This is a temporary stub token. Proper token-based authentication is planned for a future + release. The stub token exists to ensure that the authentication workflow is exercised and that + state-changing operations are not completely open. + +Read-only operations (downloading crates, browsing the index) do not require a token. + +## Publish a Crate + +Once the registry is configured and a distribution with `--allow-uploads` exists, you can publish +crates using standard Cargo tooling: + +```bash +cargo publish --registry my-crates +``` + +This uploads the crate to Pulp, which creates the artifact, content metadata, and a new repository +version. The crate is immediately available for download through the distribution. + +Publishing the same crate version twice is rejected — crate versions are immutable, consistent +with crates.io behavior. + +## Yank and Unyank + +Yanking marks a crate version as unavailable for new dependency resolution, while still allowing +existing projects that already depend on it to continue downloading it. This matches the +[crates.io yank semantics](https://doc.rust-lang.org/cargo/reference/publishing.html#cargo-yank). + +```bash +# Yank a version +cargo yank --registry my-crates --version 1.0.0 my-crate + +# Unyank a version +cargo yank --registry my-crates --version 1.0.0 --undo my-crate +``` + ### Using the Private Registry as a Dependency Source To depend on crates from your private registry, specify the registry in your `Cargo.toml`: diff --git a/pulp_rust/app/auth.py b/pulp_rust/app/auth.py new file mode 100644 index 0000000..22aefea --- /dev/null +++ b/pulp_rust/app/auth.py @@ -0,0 +1,37 @@ +"""Stub authentication for Cargo API endpoints. + +This is a temporary placeholder — it validates the Authorization header against +a hardcoded token so that state-changing endpoints (publish, yank, unyank) are +not completely open. It will be replaced by proper token-based auth later. +""" + +import functools +import json + +from django.http import HttpResponse + +STUB_TOKEN = "i_understand_that_pulp_rust_does_not_support_proper_auth_yet" + + +def require_cargo_token(view_method): + """Decorator that validates the Cargo Authorization header against the stub token. + + Returns a 403 with a Cargo-style JSON error if the token is missing or incorrect. + """ + + @functools.wraps(view_method) + def wrapper(self, request, *args, **kwargs): + token = request.META.get("HTTP_AUTHORIZATION") + if token == STUB_TOKEN: + return view_method(self, request, *args, **kwargs) + if not token: + detail = "this endpoint requires an authorization token" + else: + detail = "invalid authorization token" + return HttpResponse( + json.dumps({"errors": [{"detail": detail}]}), + content_type="application/json", + status=403, + ) + + return wrapper diff --git a/pulp_rust/app/models.py b/pulp_rust/app/models.py index a4edcfc..bc393a7 100755 --- a/pulp_rust/app/models.py +++ b/pulp_rust/app/models.py @@ -17,6 +17,10 @@ logger = getLogger(__name__) +# Cache for the "dl" template from each registry's config.json. +# Keyed by index URL; effectively never changes for a given registry. +_dl_template_cache = {} + def _strip_sparse_prefix(url): """Strip the sparse+ prefix from a Cargo registry URL.""" @@ -194,9 +198,8 @@ class RustDependency(models.Model): default="normal", ) - # @TODO: I suspect this isn't needed - # URL of alternative registry if dependency comes from a non-default registry - # Null means the dependency is from the same registry as the parent package + # URL of alternative registry if dependency comes from a non-default registry. + # Null means the dependency is from the same registry as the parent package. registry = models.CharField(max_length=512, blank=True, null=True) # Original crate name if the dependency was renamed @@ -235,11 +238,12 @@ def get_remote_artifact_url(self, relative_path=None, request=None): crate_name, version = _parse_crate_relative_path(relative_path) index_url = _strip_sparse_prefix(self.url).rstrip("/") - # TODO: Cache the config.json response to avoid fetching it on every request. - config_url = f"{index_url}/config.json" - response = urllib.request.urlopen(config_url) - config = json.loads(response.read()) - dl_template = config["dl"] + if index_url not in _dl_template_cache: + config_url = f"{index_url}/config.json" + response = urllib.request.urlopen(config_url, timeout=30) + config = json.loads(response.read()) + _dl_template_cache[index_url] = config["dl"] + dl_template = _dl_template_cache[index_url] if "{crate}" in dl_template or "{version}" in dl_template: return dl_template.replace("{crate}", crate_name).replace("{version}", version) diff --git a/pulp_rust/app/urls.py b/pulp_rust/app/urls.py index b0caa2f..3d3e45a 100644 --- a/pulp_rust/app/urls.py +++ b/pulp_rust/app/urls.py @@ -5,6 +5,7 @@ IndexRoot, CargoIndexApiViewSet, CargoDownloadApiView, + CargoMeApiView, CargoPublishApiView, ) @@ -15,6 +16,11 @@ urlpatterns = [ + path( + CRATES_IO_URL + "me", + CargoMeApiView.as_view(), + name="cargo-me-api", + ), path( CRATES_IO_URL + "api/v1/crates/new", CargoPublishApiView.as_view(), diff --git a/pulp_rust/app/views.py b/pulp_rust/app/views.py index a8d9fdf..f997f2e 100644 --- a/pulp_rust/app/views.py +++ b/pulp_rust/app/views.py @@ -1,5 +1,7 @@ import json import logging +import os +import struct import tempfile import urllib.request import urllib.error @@ -29,6 +31,7 @@ RustPackageYank, _strip_sparse_prefix, ) +from pulp_rust.app.auth import require_cargo_token from pulp_rust.app.tasks import ( ayank_package, aunyank_package, @@ -76,7 +79,7 @@ def get_distribution(repo): try: return distro_qs.get(base_path=repo, pulp_domain=get_domain()) except ObjectDoesNotExist: - raise Http404(f"No RustDistribution found for base_path {repo}") # TODO: broken + raise Http404(f"No RustDistribution found for base_path {repo}") @staticmethod def get_repository_version(distribution): @@ -180,7 +183,7 @@ def retrieve(self, request, path, **kwargs): index_url = _strip_sparse_prefix(remote.url).rstrip("/") upstream_url = f"{index_url}/{path}" try: - response = urllib.request.urlopen(upstream_url) + response = urllib.request.urlopen(upstream_url, timeout=30) return HttpResponse(response.read(), content_type="text/plain") except urllib.error.HTTPError as e: if e.code == 404: @@ -258,6 +261,23 @@ def retrieve(self, request, repo): return HttpResponse(json.dumps(data), content_type="application/json") +class CargoMeApiView(APIView): + """ + Auth verification endpoint for ``cargo login``. + + Cargo calls GET /me after login to verify the token is valid. + See: https://doc.rust-lang.org/cargo/reference/registry-web-api.html + """ + + authentication_classes = [] + permission_classes = [] + renderer_classes = [JSONRenderer] + + @require_cargo_token + def get(self, request, **kwargs): + return HttpResponse(json.dumps({"ok": True}), content_type="application/json") + + class CargoPublishApiView(APIView): """ View for Cargo's crate publish endpoint (PUT /api/v1/crates/new). @@ -268,9 +288,8 @@ class CargoPublishApiView(APIView): 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 uses a stub token via @require_cargo_token decorator. + # TODO: Replace with proper per-user token auth and RBAC integration. authentication_classes = [] permission_classes = [] renderer_classes = [JSONRenderer] @@ -288,6 +307,7 @@ def _error_response(detail, status=400): status=status, ) + @require_cargo_token def put(self, request, **kwargs): """ Handle ``cargo publish`` requests. @@ -308,7 +328,7 @@ def put(self, request, **kwargs): try: metadata, crate_bytes = parse_cargo_publish_body(request.body) - except Exception: + except (struct.error, json.JSONDecodeError, UnicodeDecodeError): return self._error_response("invalid publish request body") name = metadata.get("name") @@ -327,17 +347,20 @@ def put(self, request, **kwargs): 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) + try: + 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) + finally: + os.unlink(tmp.name) return HttpResponse( json.dumps( @@ -363,7 +386,7 @@ class CargoDownloadApiView(APIView): permission_classes = [] renderer_classes = [PlainTextRenderer, JSONRenderer] - def get_full_path(self, base_path, pulp_domain=None): # TODO: replace with ApiMixin? + def get_full_path(self, base_path, pulp_domain=None): if settings.DOMAIN_ENABLED: domain = pulp_domain or get_domain() return f"{domain.name}/{base_path}" @@ -393,10 +416,11 @@ def get(self, request, name, version, rest, **kwargs): relative_path = f"{name}/{name}-{version}.crate" return self.redirect_to_content_app(distro, relative_path, request) elif rest == "readme": - raise NotImplementedError("Readme endpoint is not yet implemented") + raise Http404("Readme endpoint is not yet implemented") else: raise Http404(f"Unknown action: {rest}") + @require_cargo_token def delete(self, request, name, version, rest, **kwargs): """ Responds to DELETE requests for yanking crate versions. @@ -436,6 +460,7 @@ def delete(self, request, name, version, rest, **kwargs): has_task_completed(task) return HttpResponse(json.dumps({"ok": True}), content_type="application/json") + @require_cargo_token def put(self, request, name, version, rest, **kwargs): """ Responds to PUT requests for unyanking crate versions. diff --git a/pulp_rust/tests/functional/api/test_auth.py b/pulp_rust/tests/functional/api/test_auth.py new file mode 100644 index 0000000..fd79dd3 --- /dev/null +++ b/pulp_rust/tests/functional/api/test_auth.py @@ -0,0 +1,144 @@ +"""Tests for stub authentication on Cargo API endpoints.""" + +from urllib.parse import urljoin + +from pulp_rust.tests.functional.utils import ( + CARGO_AUTH_HEADERS, + cargo_api_request, + download_file, + get_index_entry, + minimal_publish_request, +) + +# --- 403 tests for missing/wrong token --- + + +def test_publish_without_token_returns_403( + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """Publish without an Authorization header should return 403.""" + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) + base = cargo_registry_url(distribution.base_path) + + response = minimal_publish_request(base) + assert response.status_code == 403 + errors = response.json()["errors"] + assert any("authorization token" in e["detail"] for e in errors) + + +def test_publish_with_wrong_token_returns_403( + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """Publish with an incorrect token should return 403.""" + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) + base = cargo_registry_url(distribution.base_path) + + response = minimal_publish_request(base, headers={"Authorization": "wrong-token"}) + assert response.status_code == 403 + errors = response.json()["errors"] + assert any("invalid" in e["detail"] for e in errors) + + +def test_yank_without_token_returns_403( + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """Yank without an Authorization header should return 403.""" + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href) + base = cargo_registry_url(distribution.base_path) + + yank_url = urljoin(base, "api/v1/crates/fake/0.0.1/yank") + response = cargo_api_request("DELETE", yank_url) + assert response.status_code == 403 + errors = response.json()["errors"] + assert any("authorization token" in e["detail"] for e in errors) + + +def test_unyank_without_token_returns_403( + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """Unyank without an Authorization header should return 403.""" + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href) + base = cargo_registry_url(distribution.base_path) + + unyank_url = urljoin(base, "api/v1/crates/fake/0.0.1/unyank") + response = cargo_api_request("PUT", unyank_url) + assert response.status_code == 403 + errors = response.json()["errors"] + assert any("authorization token" in e["detail"] for e in errors) + + +# --- /me endpoint (cargo login verification) --- + + +def test_me_with_valid_token( + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """/me with a valid token should return 200 {"ok": true}.""" + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href) + base = cargo_registry_url(distribution.base_path) + + response = cargo_api_request("GET", urljoin(base, "me"), headers=CARGO_AUTH_HEADERS) + assert response.status_code == 200 + assert response.json()["ok"] is True + + +def test_me_without_token_returns_403( + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """/me without a token should return 403.""" + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href) + base = cargo_registry_url(distribution.base_path) + + response = cargo_api_request("GET", urljoin(base, "me")) + assert response.status_code == 403 + + +def test_me_with_wrong_token_returns_403( + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """/me with an invalid token should return 403.""" + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href) + base = cargo_registry_url(distribution.base_path) + + response = cargo_api_request("GET", urljoin(base, "me"), headers={"Authorization": "wrong"}) + assert response.status_code == 403 + + +# --- Public endpoints remain accessible without token --- + + +def test_download_without_token_succeeds(populated_repo): + """Downloads should work without any authorization token.""" + base_url = populated_repo["base_url"] + download_url = urljoin(base_url, "api/v1/crates/itoa/1.0.0/download") + result = download_file(download_url) + assert result.response_obj.status == 200 + + +def test_index_without_token_succeeds(populated_repo): + """The sparse index should be accessible without any authorization token.""" + base_url = populated_repo["base_url"] + entry = get_index_entry(base_url, "it/oa/itoa", "1.0.0") + assert entry is not None + assert entry["name"] == "itoa" diff --git a/pulp_rust/tests/functional/api/test_publish.py b/pulp_rust/tests/functional/api/test_publish.py index 72cf074..f7a4fb1 100644 --- a/pulp_rust/tests/functional/api/test_publish.py +++ b/pulp_rust/tests/functional/api/test_publish.py @@ -1,81 +1,22 @@ -"""Tests for the Cargo publish API (PUT /api/v1/crates/new).""" - -import json -import struct - -import requests +"""Tests for the Cargo publish API (PUT /api/v1/crates/new). + +NOTE: The test helpers (build_publish_metadata, cargo_publish) reuse +pulp_rust's own extract_cargo_toml / extract_dependencies to build the +publish request — the same code the server uses to process it. The index +fidelity tests below validate that code path by comparing Pulp's index +output against independently-fetched crates.io data. If those app +functions ever produce wrong results, these fidelity tests are what will +catch it. Other test modules (auth, yank) also depend on the same helpers, +so keep these fidelity checks passing. +""" from pulp_rust.tests.functional.utils import ( assert_index_entry_matches_upstream, + build_publish_metadata, + cargo_publish, 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("