From b25c9f8dfbb99819634ff85d3edd8177500643b8 Mon Sep 17 00:00:00 2001 From: Daniel Alley Date: Thu, 9 Apr 2026 13:08:39 -0400 Subject: [PATCH 1/3] Add an AGENTS.md guide Assisted-By: claude-opus-4.6 --- AGENTS.md | 1 + CLAUDE.md | 68 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 120000 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b09f674 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,68 @@ +# CLAUDE.md + +The role of this file is to describe common mistakes and confusion points that agents might encounter as they work in this project. +If you ever encounter something in the project that surprises you, please alert the developer working with you and indicate that this is the case in the CLAUDE.md file to help prevent future agents from having the same issue. + +## Interacting with the developer environment + +Use the `pulp-cli` to interact with the Pulp API. Fallback on `httpie/curl` when the CLI doesn't support the endpoint/options needed. See the [user guides](docs/user/guides/) for workflow examples. + +Use the `oci-env` cli to interact with the developer's Pulp instance. It has commands for managing state, running tests, and executing commands against a running Pulp. + +```bash +oci-env --help +oci-env compose ps # check status of the Pulp dev container +oci-env compose up/down/restart # start/stop/restart the Pulp dev container +oci-env poll --attempts 10 --wait 10 # wait till Pulp container finishes booting up +oci-env pstart/pstop/prestart # start/stop/restart the services inside the Pulp container +oci-env generate-client --help # create the client bindings needed for the functional tests! +oci-env test --help # run the functional/unit tests +oci-env pulpcore-manager # run any pulpcore or Django commands +``` + +## Running/Writing tests + +Prefer writing functional tests for new changes/bugfixes and only fallback on unit tests when the change is not easily testable through the API. + +pulpcore & pulp_rust functional tests require both client bindings to be installed. The bindings must be regenerated for any changes to the API spec. + +**Always** use the `oci-env` to run the functional and unit tests. + +## Modifying template_config.yml + +Use the `plugin-template` tool after any changes made to `template_config.yml`. + +```bash +# typically located in the parent directory of pulpcore/plugin +../plugin_template/plugin-template --github +``` + +## Fixing failed backports + +When patchback fails to cherry-pick a PR into an older branch, you need to manually apply the equivalent change. Key things to know: + +- When creating a PR include `[]` in the PR title (e.g. `[0.1] Fix crate upload handling`). +- Use `git cherry-pick -x`. + +## Contributing + +When preparing to commit and create a PR you **must** follow our [PR checklist](https://pulpproject.org/pulpcore/docs/dev/guides/pull-request-walkthrough/) Important to note is the AI attribution requirement in our commit messages. Also, note that our changelog entries are markdown. + +## Project status + +This project is in **Tech Preview**. APIs, behaviors, and data models are subject to breaking changes without prior notice. See the README for current limitations. + +## Cargo protocol & domain knowledge + +For understanding the Cargo registry protocol, refer to the upstream documentation: + +- [Cargo Book](https://doc.rust-lang.org/stable/cargo/) +- [Registry Web API](https://doc.rust-lang.org/cargo/reference/registry-web-api.html) +- [Registry Index](https://doc.rust-lang.org/cargo/reference/registry-index.html) + +## Common pitfalls + +- **Cargo.toml is authoritative during publish**: When a crate is published, dependencies are extracted from the `Cargo.toml` inside the `.crate` tarball, NOT from the JSON metadata submitted alongside it. This is an intentional security measure (see rust-lang/cargo#14492). +- **RustDependency is NOT a Content type**: Unlike `RustContent` and `RustPackageYank`, `RustDependency` is a regular Django model with an FK to `RustContent`. Do not treat it as a Pulpcore Content subclass. +- **Django app label is `rust`, not `pulp_rust`**: When running Django management commands (e.g. `makemigrations`), use the app label `rust`. The Python package is `pulp_rust` but the Django app label is set to `rust` in `PulpRustPluginAppConfig`. + From e5dac3b6f65eb4e216ce04989c06678f3d5fc9f5 Mon Sep 17 00:00:00 2001 From: Daniel Alley Date: Tue, 14 Apr 2026 01:42:18 -0400 Subject: [PATCH 2/3] Nail down a few additional differences between pulp_rust and crates.io Validate semver formatting and treat the comparison correctly Warn users about keeping private registries on the same domain as public indexes. Add some additional tests Assisted-By: claude-opus-4.6 --- docs/user/guides/private-registry.md | 31 ++- pulp_rust/app/migrations/0001_initial.py | 5 +- pulp_rust/app/models.py | 39 ++- pulp_rust/app/serializers.py | 19 +- pulp_rust/app/tasks/publishing.py | 71 ++++-- pulp_rust/app/tasks/yanking.py | 11 +- pulp_rust/app/utils.py | 63 +++++ pulp_rust/app/views.py | 64 ++++- .../tests/functional/api/test_cargo_api.py | 42 ++++ .../tests/functional/api/test_publish.py | 233 ++++++++++++++++++ .../api/test_pull_through_caching.py | 34 +++ pulp_rust/tests/unit/test_utils.py | 133 +++++++++- 12 files changed, 691 insertions(+), 54 deletions(-) diff --git a/docs/user/guides/private-registry.md b/docs/user/guides/private-registry.md index 7243d01..b1c007a 100644 --- a/docs/user/guides/private-registry.md +++ b/docs/user/guides/private-registry.md @@ -131,10 +131,16 @@ private registry. Any crate not present in the registry will fail to resolve. ## Combining with Pull-Through Caching -If you need both private crates and public crates.io dependencies, we recommend keeping them as -**separate registries** rather than mixing them into one. This avoids -[dependency confusion](https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610) attacks, -where a malicious package on a public registry could impersonate a private dependency. +If you need both private crates and public crates.io dependencies, use separate distributions and +separate repositories -- one for publishing and one for pull-through caching. Pulp enforces that +a distribution cannot have both `allow_uploads` and a `remote` set at the same time. + +!!! warning + The two distributions should also use **separate repositories**. Mixing public and private + content in a single repository creates a risk of + [dependency confusion](https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610) + attacks, where a crate from the public registry conflicts with a private crate of the same + name and version. ```bash # Set up a separate pull-through cache for crates.io @@ -167,11 +173,18 @@ serde = "1.0" # resolved from crate my-internal-lib = { version = "1.0", registry = "my-crates" } # resolved from private registry ``` -!!! warning - Avoid adding a public remote (such as crates.io) to a private registry's distribution. Mixing - public and private packages in a single registry index creates a risk of dependency confusion - attacks, where an attacker publishes a crate on the public registry with the same name as one - of your private crates. +## Crate Name Handling + +Crate names in the Cargo spec are case-insensitive, and hyphens and underscores are treated as +equivalent (e.g. `my-crate` and `my_crate` refer to the same package). Pulp enforces this: +publishing `my-crate` when `my_crate` already exists in the same repository is rejected as a +duplicate. Yank and unyank operations use the same matching. + +!!! tip "Separate Registries" + Keep private registries and public pull-through caches as separate distributions (and + preferably separate repositories). This makes it easy to audit which registries have + upstream access and reduces the risk of accidental misconfiguration. For additional + isolation or access control, they could be kept on entirely separate domains. ## Further Reading diff --git a/pulp_rust/app/migrations/0001_initial.py b/pulp_rust/app/migrations/0001_initial.py index 162231e..be69154 100644 --- a/pulp_rust/app/migrations/0001_initial.py +++ b/pulp_rust/app/migrations/0001_initial.py @@ -19,6 +19,7 @@ class Migration(migrations.Migration): fields=[ ('content_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='core.content')), ('name', models.CharField(db_index=True, max_length=255)), + ('canonical_name', models.CharField(db_index=True, max_length=255)), ('vers', models.CharField(db_index=True, max_length=64)), ('cksum', models.CharField(db_index=True, max_length=64)), ('features', models.JSONField(blank=True, default=dict)), @@ -30,7 +31,7 @@ class Migration(migrations.Migration): ], options={ 'default_related_name': '%(app_label)s_%(model_name)s', - 'unique_together': {('name', 'vers', '_pulp_domain')}, + 'unique_together': {('name', 'vers', 'cksum', '_pulp_domain')}, }, bases=('core.content',), ), @@ -38,7 +39,7 @@ class Migration(migrations.Migration): name='RustDistribution', fields=[ ('distribution_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='core.distribution')), - ('allow_uploads', models.BooleanField(default=True)), + ('allow_uploads', models.BooleanField(default=False)), ], options={ 'default_related_name': '%(app_label)s_%(model_name)s', diff --git a/pulp_rust/app/models.py b/pulp_rust/app/models.py index bc393a7..dbeca0d 100755 --- a/pulp_rust/app/models.py +++ b/pulp_rust/app/models.py @@ -5,7 +5,11 @@ from django.db import models from django_lifecycle import hook, AFTER_CREATE -from pulp_rust.app.utils import extract_cargo_toml, extract_dependencies +from pulp_rust.app.utils import ( + canonicalize_crate_name, + extract_cargo_toml, + extract_dependencies, +) from pulpcore.plugin.models import ( Content, @@ -55,8 +59,25 @@ class RustContent(Content): Cargo registry index specification. Each instance corresponds to one line in a package's index file. + The `name` field preserves the original crate name as it appears in the + package's `Cargo.toml` (e.g. `cfg-if`, `Serde-JSON`). This matches + crates.io behavior and ensures that download paths and index entries use + the author's intended name form. + + The `canonical_name` field stores the canonical form (lowercased, hyphens + replaced with underscores) for use in lookups where the Cargo spec's + case-insensitive, hyphen/underscore-equivalent matching is needed - for + example, duplicate detection during publish and yank operations. + + Content uniqueness is enforced on `(name, vers, cksum, _pulp_domain)`. + Including `cksum` allows different crates with the same name and version + (e.g. a private crate and a public crate) to coexist as separate content + objects within a domain, while `repo_key_fields` prevents both from + appearing in the same repository version. + Fields: - name: The package name (crate name) + name: The package name as it appears in Cargo.toml + canonical_name: Canonical form (lowercased, hyphens -> underscores) vers: The semantic version string (SemVer 2.0.0) cksum: SHA256 checksum of the .crate file (tarball) features: JSON object mapping feature names to their dependencies @@ -69,9 +90,15 @@ class RustContent(Content): TYPE = "rust" repo_key_fields = ("name", "vers") - # Package name - alphanumeric characters, hyphens, and underscores allowed + # Package name as it appears in the crate's Cargo.toml. name = models.CharField(max_length=255, blank=False, null=False, db_index=True) + # Canonical form of the name: lowercased with hyphens replaced by underscores. + # Used for lookups where the Cargo spec's case-insensitive, + # hyphen/underscore-equivalent matching is needed (e.g. duplicate detection, + # yank operations). + canonical_name = models.CharField(max_length=255, blank=False, null=False, db_index=True) + # Semantic version string following SemVer 2.0.0 specification vers = models.CharField(max_length=64, blank=False, null=False, db_index=True) @@ -115,6 +142,7 @@ def init_from_artifact_and_relative_path(artifact, relative_path): content = RustContent( name=crate_name, + canonical_name=canonicalize_crate_name(crate_name), vers=version, cksum=artifact.sha256, features=cargo_toml.get("features", {}), @@ -136,7 +164,7 @@ def _create_dependencies_from_parsed_data(self): class Meta: default_related_name = "%(app_label)s_%(model_name)s" - unique_together = (("name", "vers", "_pulp_domain"),) + unique_together = (("name", "vers", "cksum", "_pulp_domain"),) class RustDependency(models.Model): @@ -277,6 +305,7 @@ class RustPackageYank(Content): name = models.CharField(max_length=255, db_index=True) vers = models.CharField(max_length=64, db_index=True) + _pulp_domain = models.ForeignKey("core.Domain", default=get_domain_pk, on_delete=models.PROTECT) class Meta: @@ -308,7 +337,7 @@ class RustDistribution(Distribution): TYPE = "rust" - allow_uploads = models.BooleanField(default=True) + allow_uploads = models.BooleanField(default=False) class Meta: default_related_name = "%(app_label)s_%(model_name)s" diff --git a/pulp_rust/app/serializers.py b/pulp_rust/app/serializers.py index bf5757c..68f5778 100755 --- a/pulp_rust/app/serializers.py +++ b/pulp_rust/app/serializers.py @@ -7,6 +7,7 @@ from pulpcore.plugin import serializers as core_serializers from . import models +from .utils import canonicalize_crate_name log = logging.getLogger(__name__) @@ -153,6 +154,7 @@ class RustContentSerializer(core_serializers.SingleArtifactContentSerializer): def create(self, validated_data): """Create RustContent and related dependencies.""" dependencies_data = validated_data.pop("dependencies", []) + validated_data["canonical_name"] = canonicalize_crate_name(validated_data["name"]) content = super().create(validated_data) # Create dependency records @@ -243,7 +245,7 @@ class Meta: """ allow_uploads = serializers.BooleanField( - default=True, help_text=_("Allow packages to be uploaded to this index.") + default=False, help_text=_("Allow packages to be uploaded to this index.") ) remote = core_serializers.DetailRelatedField( required=False, @@ -253,6 +255,21 @@ class Meta: allow_null=True, ) + def validate(self, data): + data = super().validate(data) + remote = data.get("remote", self.instance.remote if self.instance else None) + allow_uploads = data.get( + "allow_uploads", self.instance.allow_uploads if self.instance else False + ) + if remote and allow_uploads: + raise serializers.ValidationError( + _( + "A distribution cannot have both a remote and allow_uploads enabled. " + "Use separate distributions for pull-through caching and publishing." + ) + ) + return data + class Meta: fields = core_serializers.DistributionSerializer.Meta.fields + ("allow_uploads", "remote") model = models.RustDistribution diff --git a/pulp_rust/app/tasks/publishing.py b/pulp_rust/app/tasks/publishing.py index 9f08247..9e52bce 100644 --- a/pulp_rust/app/tasks/publishing.py +++ b/pulp_rust/app/tasks/publishing.py @@ -1,11 +1,18 @@ import hashlib import struct +from django.db import IntegrityError + 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 +from pulp_rust.app.utils import ( + canonicalize_crate_name, + extract_cargo_toml, + extract_dependencies, + strip_semver_build_metadata, +) def parse_cargo_publish_body(body): @@ -55,15 +62,19 @@ async def apublish_package(repository_pk, metadata, crate_path): """ repository = await RustRepository.objects.aget(pk=repository_pk) - # Create the artifact from the .crate file + # Create the artifact from the .crate file, or reuse an existing one + # with the same checksum (Artifact has a unique constraint on digests). 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() + try: + await artifact.asave() + except IntegrityError: + artifact = await Artifact.objects.aget(sha256=cksum) # Extract authoritative metadata from the Cargo.toml inside the .crate tarball. - # The publish JSON metadata is NOT authoritative — a rogue client can send metadata + # 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 @@ -72,34 +83,52 @@ async def apublish_package(repository_pk, metadata, crate_path): package = cargo_toml.get("package", {}) name = package["name"] - vers = package["version"] + canonical_name = canonicalize_crate_name(name) + # Strip build metadata - SemVer 2.0.0 treats versions differing only in + # build metadata as identical, and the index must not contain duplicates. + vers = strip_semver_build_metadata(package["version"]) # Build dependency list from the Cargo.toml (authoritative source) deps = extract_dependencies(cargo_toml) - # Create the content record - content = RustContent( + # Reuse existing content if it already exists in the domain with the same + # checksum (e.g. from a pull-through cache or another repository's publish). + # Content in Pulp is globally shared - the same object can belong to + # multiple repositories. Including cksum in the lookup allows different + # crates with the same name+version (e.g. a private crate shadowing a + # public one) to coexist as separate content objects within a domain. + content = await RustContent.objects.filter( 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] + ).afirst() + + if content is None: + content = RustContent( + name=name, + canonical_name=canonical_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() + + 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) + # Create the content artifact if it doesn't already exist relative_path = f"{name}/{name}-{vers}.crate" - await ContentArtifact.objects.acreate( - artifact=artifact, content=content, relative_path=relative_path + await ContentArtifact.objects.aget_or_create( + content=content, + relative_path=relative_path, + defaults={"artifact": artifact}, ) # Add the content to a new repository version diff --git a/pulp_rust/app/tasks/yanking.py b/pulp_rust/app/tasks/yanking.py index 9237a53..0ed70e3 100644 --- a/pulp_rust/app/tasks/yanking.py +++ b/pulp_rust/app/tasks/yanking.py @@ -1,6 +1,7 @@ from pulpcore.plugin.tasking import aadd_and_remove from pulp_rust.app.models import RustContent, RustPackageYank, RustRepository +from pulp_rust.app.utils import canonicalize_crate_name async def ayank_package(repository_pk, name, vers): @@ -9,11 +10,14 @@ async def ayank_package(repository_pk, name, vers): Creates a new repository version with the yank marker added. """ + name = canonicalize_crate_name(name) repository = await RustRepository.objects.aget(pk=repository_pk) latest = await repository.alatest_version() # Verify the package version exists in this repository - exists = await RustContent.objects.filter(pk__in=latest.content, name=name, vers=vers).aexists() + exists = await RustContent.objects.filter( + pk__in=latest.content, canonical_name=name, vers=vers + ).aexists() if not exists: raise ValueError(f"Package {name}=={vers} not found in repository") @@ -25,7 +29,9 @@ async def ayank_package(repository_pk, name, vers): return # Already yanked, no-op yank_marker, _ = await RustPackageYank.objects.aget_or_create( - name=name, vers=vers, _pulp_domain_id=repository.pulp_domain_id + name=name, + vers=vers, + _pulp_domain_id=repository.pulp_domain_id, ) await aadd_and_remove( @@ -41,6 +47,7 @@ async def aunyank_package(repository_pk, name, vers): Creates a new repository version with the yank marker removed. """ + name = canonicalize_crate_name(name) repository = await RustRepository.objects.aget(pk=repository_pk) latest = await repository.alatest_version() diff --git a/pulp_rust/app/utils.py b/pulp_rust/app/utils.py index 061e0d0..5f9fa27 100644 --- a/pulp_rust/app/utils.py +++ b/pulp_rust/app/utils.py @@ -1,3 +1,4 @@ +import re import tarfile try: @@ -88,3 +89,65 @@ def extract_dependencies(cargo_toml): deps.append(parse_dep(name, spec, kind="build", target=target)) return deps + + +CRATE_NAME_MAX_LENGTH = 64 +CRATE_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*$") +SEMVER_RE = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(-[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?" + r"(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$" +) + + +def validate_crate_name(name): + """Validate a crate name. + + Enforces the following rules: + - Must start with an ASCII letter and contain only ASCII alphanumeric + characters, hyphens, or underscores (Cargo spec, via ``cargo new``). + - Must not exceed 64 characters (crates.io policy, not in the Cargo spec). + + Returns None if valid, or an error message string if invalid. + """ + if not name: + return "crate name must not be empty" + if len(name) > CRATE_NAME_MAX_LENGTH: + return f"crate name exceeds maximum length of {CRATE_NAME_MAX_LENGTH} characters" + if not CRATE_NAME_RE.match(name): + return ( + "crate name must start with an ASCII letter and contain only " + "ASCII alphanumeric characters, hyphens, or underscores" + ) + return None + + +def validate_crate_version(version): + """Validate a crate version per SemVer 2.0.0 (required by Cargo spec). + + Returns None if valid, or an error message string if invalid. + """ + if not version: + return "crate version must not be empty" + if not SEMVER_RE.match(version): + return f"invalid semver: `{version}` " "(expected MAJOR.MINOR.PATCH[-prerelease][+build])" + return None + + +def strip_semver_build_metadata(version): + """Strip build metadata from a SemVer version string. + + Per SemVer 2.0.0, versions that differ only in build metadata have equal + precedence. The Cargo registry spec requires that indexes treat such + versions as identical (e.g. ``1.0.0`` and ``1.0.0+build1`` must collide). + """ + return version.split("+", 1)[0] + + +def canonicalize_crate_name(name): + """Canonicalize a crate name for uniqueness comparison. + + Crate names are case-insensitive and hyphens and underscores are treated + as equivalent (Cargo spec). + """ + return name.lower().replace("-", "_") diff --git a/pulp_rust/app/views.py b/pulp_rust/app/views.py index 103bbaa..3bf458e 100644 --- a/pulp_rust/app/views.py +++ b/pulp_rust/app/views.py @@ -32,6 +32,12 @@ _strip_sparse_prefix, ) from pulp_rust.app.auth import require_cargo_token +from pulp_rust.app.utils import ( + validate_crate_name, + validate_crate_version, + canonicalize_crate_name, + strip_semver_build_metadata, +) from pulp_rust.app.tasks import ( ayank_package, aunyank_package, @@ -164,11 +170,13 @@ def retrieve(self, request, path, **kwargs): repo_ver, content = self.get_rvc() # Extract crate name from the path (last component) - crate_name = path.rsplit("/", 1)[-1].lower() + crate_name = path.rsplit("/", 1)[-1] + canonical = canonicalize_crate_name(crate_name) - # For pull-through caches (distributions with a remote), always proxy - # the index from upstream so that newly published upstream versions are - # discovered. The actual .crate files are fetched on-demand. + # For pull-through caches (distributions with a remote), proxy the + # index from upstream so that newly published upstream versions are + # discovered. If the upstream is unreachable, fall through to serve + # from locally cached content. if self.distribution.remote: remote = self.distribution.remote.cast() index_url = _strip_sparse_prefix(remote.url).rstrip("/") @@ -179,15 +187,29 @@ def retrieve(self, request, path, **kwargs): except urllib.error.HTTPError as e: if e.code == 404: return HttpResponseNotFound(f"Crate '{crate_name}' not found") - raise + log.warning( + "Upstream index request failed (HTTP %d) for %s, " + "falling back to cached content", + e.code, + upstream_url, + ) + except (urllib.error.URLError, TimeoutError) as e: + log.warning( + "Upstream index request failed for %s: %s, " "falling back to cached content", + upstream_url, + e, + ) - # For private registries (no remote), serve from local content only + # Serve from local index. For private registries this is the only source; for pull-through + # caches this is the fallback when upstream is unavailable. + # Use canonical_name for the lookup so that requests for any name form + # (e.g. cfg-if, cfg_if, Cfg-If) find the right content. if content is not None: - crate_versions = content.filter(name=crate_name).order_by("vers") + crate_versions = content.filter(canonical_name=canonical).order_by("vers") if crate_versions.exists(): yanked_versions = set( RustPackageYank.objects.filter( - pk__in=repo_ver.content, name=crate_name + pk__in=repo_ver.content, name=canonical ).values_list("vers", flat=True) ) return self._build_index_response(crate_versions, yanked_versions) @@ -338,9 +360,24 @@ def put(self, request, **kwargs): 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 + error = validate_crate_name(name) + if error: + return self._error_response(error) + + error = validate_crate_version(vers) + if error: + return self._error_response(error) + + # Check for duplicates using canonical name form to prevent confusable + # packages (e.g. "my-crate" vs "my_crate" or "MyCrate" vs "mycrate"). + # Strip build metadata because SemVer 2.0.0 treats versions differing only + # in build metadata as identical (e.g. "1.0.0" and "1.0.0+build1" collide). + canonical = canonicalize_crate_name(name) + vers_base = strip_semver_build_metadata(vers) repo_version = distro.repository.latest_version() - if RustContent.objects.filter(pk__in=repo_version.content, name=name, vers=vers).exists(): + if RustContent.objects.filter( + pk__in=repo_version.content, canonical_name=canonical, vers=vers_base + ).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 @@ -437,9 +474,10 @@ def delete(self, request, name, version, rest, **kwargs): if not distro.repository: raise Http404("No repository associated with this distribution") + canonical = canonicalize_crate_name(name) repo_version = distro.repository.latest_version() if not RustContent.objects.filter( - pk__in=repo_version.content, name=name, vers=version + pk__in=repo_version.content, canonical_name=canonical, vers=version ).exists(): return HttpResponse( json.dumps( @@ -455,7 +493,7 @@ def delete(self, request, name, version, rest, **kwargs): immediate=True, kwargs={ "repository_pk": str(distro.repository.pk), - "name": name, + "name": canonical, "vers": version, }, ) @@ -483,7 +521,7 @@ def put(self, request, name, version, rest, **kwargs): immediate=True, kwargs={ "repository_pk": str(distro.repository.pk), - "name": name, + "name": canonicalize_crate_name(name), "vers": version, }, ) diff --git a/pulp_rust/tests/functional/api/test_cargo_api.py b/pulp_rust/tests/functional/api/test_cargo_api.py index 0f4becf..35c1b50 100644 --- a/pulp_rust/tests/functional/api/test_cargo_api.py +++ b/pulp_rust/tests/functional/api/test_cargo_api.py @@ -1,10 +1,12 @@ """Functional tests for the Cargo registry API endpoints.""" import json +import uuid from urllib.parse import urljoin import pytest from aiohttp.client_exceptions import ClientResponseError +from pulpcore.client.pulp_rust.exceptions import ApiException from pulp_rust.tests.functional.utils import CRATES_IO_URL, download_file @@ -252,3 +254,43 @@ def test_add_cached_content_empty_repo( repository = rust_repo_api_client.read(repository.pulp_href) assert repository.latest_version_href is not None + + +def test_distribution_rejects_remote_with_uploads( + rust_remote_factory, + rust_repo_factory, + rust_distro_api_client, +): + """Creating a distribution with both a remote and allow_uploads should fail.""" + remote = rust_remote_factory(url=CRATES_IO_URL) + repo = rust_repo_factory() + + with pytest.raises(ApiException) as exc: + rust_distro_api_client.create( + { + "name": str(uuid.uuid4()), + "base_path": str(uuid.uuid4()), + "repository": repo.pulp_href, + "remote": remote.pulp_href, + "allow_uploads": True, + } + ) + assert exc.value.status == 400 + + +def test_distribution_update_rejects_remote_with_uploads( + rust_repo_factory, + rust_distribution_factory, + rust_remote_factory, + rust_distro_api_client, +): + """Updating a distribution to set both remote and allow_uploads should fail.""" + repo = rust_repo_factory() + distro = rust_distribution_factory(repository=repo.pulp_href) + remote = rust_remote_factory(url=CRATES_IO_URL) + + with pytest.raises(ApiException) as exc: + rust_distro_api_client.partial_update( + distro.pulp_href, {"remote": remote.pulp_href, "allow_uploads": True} + ) + assert exc.value.status == 400 diff --git a/pulp_rust/tests/functional/api/test_publish.py b/pulp_rust/tests/functional/api/test_publish.py index f7a4fb1..8060b84 100644 --- a/pulp_rust/tests/functional/api/test_publish.py +++ b/pulp_rust/tests/functional/api/test_publish.py @@ -10,11 +10,15 @@ so keep these fidelity checks passing. """ +from urllib.parse import urljoin + from pulp_rust.tests.functional.utils import ( + CRATES_IO_URL, assert_index_entry_matches_upstream, build_publish_metadata, cargo_publish, download_crate_from_upstream, + download_file, get_index_entry, ) @@ -172,3 +176,232 @@ def test_cargo_publish_uploads_disabled( assert response.status_code == 403 errors = response.json()["errors"] assert any("does not allow uploads" in e["detail"] for e in errors) + + +def test_cargo_publish_invalid_name_rejected( + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """Publishing with an invalid crate name should be rejected.""" + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) + base = cargo_registry_url(distribution.base_path) + + # Starts with a digit + metadata = {"name": "123invalid", "vers": "0.1.0", "deps": [], "features": {}} + response = cargo_publish(base, metadata, b"fake") + assert response.status_code == 400 + assert "crate name" in response.json()["errors"][0]["detail"] + + +def test_cargo_publish_name_too_long_rejected( + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """Publishing with a crate name exceeding 64 characters should be rejected.""" + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) + base = cargo_registry_url(distribution.base_path) + + metadata = {"name": "a" * 65, "vers": "0.1.0", "deps": [], "features": {}} + response = cargo_publish(base, metadata, b"fake") + assert response.status_code == 400 + assert "maximum length" in response.json()["errors"][0]["detail"] + + +def test_cargo_publish_invalid_version_rejected( + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """Publishing with an invalid version should be rejected.""" + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) + base = cargo_registry_url(distribution.base_path) + + metadata = {"name": "validname", "vers": "not-a-version", "deps": [], "features": {}} + response = cargo_publish(base, metadata, b"fake") + assert response.status_code == 400 + assert "semver" in response.json()["errors"][0]["detail"] + + +def test_cargo_publish_canonical_name_conflict_rejected( + delete_orphans_pre, + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """Publishing with a canonically-equivalent name (case or hyphen/underscore) should fail.""" + crate_name = "serde" + crate_version = "1.0.210" + + crate_path, _ = download_crate_from_upstream(crate_name, crate_version) + with open(crate_path, "rb") as f: + crate_bytes = f.read() + + metadata = build_publish_metadata(crate_path, crate_name, crate_version) + + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) + base = cargo_registry_url(distribution.base_path) + + # First publish should succeed + response = cargo_publish(base, metadata, crate_bytes) + assert response.status_code == 200, response.text + + # Uppercase variant should be rejected as a duplicate + metadata_upper = dict(metadata, name="Serde") + response = cargo_publish(base, metadata_upper, b"fake") + assert response.status_code == 400 + assert "already uploaded" in response.json()["errors"][0]["detail"] + + +def test_cargo_publish_cross_repo_reuses_content( + delete_orphans_pre, + rust_repo_factory, + rust_repo_api_client, + rust_distribution_factory, + rust_content_api_client, + cargo_registry_url, +): + """Publishing the same crate to two repos should reuse the global content object.""" + crate_name = "serde" + crate_version = "1.0.210" + + crate_path, _ = download_crate_from_upstream(crate_name, crate_version) + with open(crate_path, "rb") as f: + crate_bytes = f.read() + + metadata = build_publish_metadata(crate_path, crate_name, crate_version) + + # Publish to first repository + repo_a = rust_repo_factory() + distro_a = rust_distribution_factory(repository=repo_a.pulp_href, allow_uploads=True) + base_a = cargo_registry_url(distro_a.base_path) + + response = cargo_publish(base_a, metadata, crate_bytes) + assert response.status_code == 200, response.text + + # Publish to second repository — should succeed + repo_b = rust_repo_factory() + distro_b = rust_distribution_factory(repository=repo_b.pulp_href, allow_uploads=True) + base_b = cargo_registry_url(distro_b.base_path) + + response = cargo_publish(base_b, metadata, crate_bytes) + assert response.status_code == 200, response.text + + # Verify the same content object is present in both repos (same pulp_href) + repo_a = rust_repo_api_client.read(repo_a.pulp_href) + repo_b = rust_repo_api_client.read(repo_b.pulp_href) + + content_in_a = rust_content_api_client.list( + repository_version=repo_a.latest_version_href, name="serde", vers="1.0.210" + ).results + content_in_b = rust_content_api_client.list( + repository_version=repo_b.latest_version_href, name="serde", vers="1.0.210" + ).results + + assert len(content_in_a) == 1 + assert len(content_in_b) == 1 + assert content_in_a[0].pulp_href == content_in_b[0].pulp_href + + +def test_cargo_publish_build_metadata_collision_rejected( + delete_orphans_pre, + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, +): + """Publishing versions that differ only in build metadata should be rejected. + + Per SemVer 2.0.0, 1.0.210 and 1.0.210+build1 have equal precedence and + must be treated as the same version by the registry. + """ + crate_name = "serde" + crate_version = "1.0.210" + + crate_path, _ = download_crate_from_upstream(crate_name, crate_version) + with open(crate_path, "rb") as f: + crate_bytes = f.read() + + metadata = build_publish_metadata(crate_path, crate_name, crate_version) + + repository = rust_repo_factory() + distribution = rust_distribution_factory(repository=repository.pulp_href, allow_uploads=True) + base = cargo_registry_url(distribution.base_path) + + # First publish should succeed + response = cargo_publish(base, metadata, crate_bytes) + assert response.status_code == 200, response.text + + # Same version with build metadata appended should be rejected + metadata_with_build = dict(metadata, vers="1.0.210+build1") + response = cargo_publish(base, metadata_with_build, b"fake") + assert response.status_code == 400 + assert "already uploaded" in response.json()["errors"][0]["detail"] + + +def test_cargo_publish_cross_repo_reuses_pull_through_content( + delete_orphans_pre, + rust_remote_factory, + rust_repo_factory, + rust_repo_api_client, + rust_distribution_factory, + rust_content_api_client, + cargo_registry_url, +): + """Publishing a crate that was already cached via pull-through should reuse + the same global RustContent object. + + Content in Pulp is shared within a domain. When a crate is first cached + via pull-through and then published to a private registry, the publish + task should find the existing content rather than creating a duplicate. + """ + crate_name = "serde" + crate_version = "1.0.210" + + # --- Pull-through: cache the crate from crates.io --- + remote = rust_remote_factory(url=CRATES_IO_URL, policy="on_demand") + pt_repo = rust_repo_factory(remote=remote.pulp_href) + pt_distro = rust_distribution_factory(remote=remote.pulp_href, repository=pt_repo.pulp_href) + pt_base = cargo_registry_url(pt_distro.base_path) + + download_file(urljoin(pt_base, f"api/v1/crates/{crate_name}/{crate_version}/download")) + + # Verify content was cached + pt_repo = rust_repo_api_client.read(pt_repo.pulp_href) + pt_content = rust_content_api_client.list( + repository_version=pt_repo.latest_version_href, + name=crate_name, + vers=crate_version, + ) + assert pt_content.count == 1, "Content was not cached by pull-through" + + # --- Publish: push the same crate to a private registry --- + crate_path, _ = download_crate_from_upstream(crate_name, crate_version) + with open(crate_path, "rb") as f: + crate_bytes = f.read() + + metadata = build_publish_metadata(crate_path, crate_name, crate_version) + + pub_repo = rust_repo_factory() + pub_distro = rust_distribution_factory(repository=pub_repo.pulp_href, allow_uploads=True) + pub_base = cargo_registry_url(pub_distro.base_path) + + response = cargo_publish(pub_base, metadata, crate_bytes) + assert response.status_code == 200, response.text + + # --- Verify: same content object in both repos --- + pub_repo = rust_repo_api_client.read(pub_repo.pulp_href) + pub_content = rust_content_api_client.list( + repository_version=pub_repo.latest_version_href, + name=crate_name, + vers=crate_version, + ) + assert pub_content.count == 1, "Content not found in private registry" + + assert ( + pt_content.results[0].pulp_href == pub_content.results[0].pulp_href + ), "Pull-through and publish created separate content objects — expected reuse" diff --git a/pulp_rust/tests/functional/api/test_pull_through_caching.py b/pulp_rust/tests/functional/api/test_pull_through_caching.py index bc87739..4025f82 100644 --- a/pulp_rust/tests/functional/api/test_pull_through_caching.py +++ b/pulp_rust/tests/functional/api/test_pull_through_caching.py @@ -335,3 +335,37 @@ def test_index_fidelity_on_demand_cached( # Now the index is served from local data — compare against upstream pulp_entry = get_index_entry(base, "se/rd/serde", "1.0.210") assert_index_entry_matches_upstream(pulp_entry, upstream_index_entry) + + +def test_pull_through_index_falls_back_to_cache_when_upstream_unavailable( + delete_orphans_pre, + rust_remote_factory, + rust_remote_api_client, + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, + monitor_task, +): + """on_demand: if the upstream is unreachable, the index should fall back to cached content.""" + remote = rust_remote_factory(url=CRATES_IO_URL, policy="on_demand") + repository = rust_repo_factory(remote=remote.pulp_href) + distribution = rust_distribution_factory( + remote=remote.pulp_href, repository=repository.pulp_href + ) + + base = cargo_registry_url(distribution.base_path) + + # Cache itoa via pull-through + download_file(urljoin(base, "api/v1/crates/itoa/1.0.0/download")) + + # Point the remote at an unreachable URL + monitor_task( + rust_remote_api_client.partial_update( + remote.pulp_href, {"url": "sparse+https://localhost:1/"} + ).task + ) + + # The index should fall back to locally cached content + entry = get_index_entry(base, "it/oa/itoa", "1.0.0") + assert entry["name"] == "itoa" + assert entry["vers"] == "1.0.0" diff --git a/pulp_rust/tests/unit/test_utils.py b/pulp_rust/tests/unit/test_utils.py index 76249c6..3be7cf7 100644 --- a/pulp_rust/tests/unit/test_utils.py +++ b/pulp_rust/tests/unit/test_utils.py @@ -9,7 +9,15 @@ django.setup() -from pulp_rust.app.utils import extract_cargo_toml, extract_dependencies, parse_dep # noqa: E402 +from pulp_rust.app.utils import ( # noqa: E402 + extract_cargo_toml, + extract_dependencies, + parse_dep, + validate_crate_name, + validate_crate_version, + canonicalize_crate_name, + strip_semver_build_metadata, +) def _make_crate_tarball(crate_name, version, cargo_toml_bytes): @@ -246,3 +254,126 @@ def test_with_links(self): path = _make_crate_tarball("zlib-sys", "0.1.0", toml_content) result = extract_cargo_toml(path, "zlib-sys", "0.1.0") assert result["package"]["links"] == "z" + + +class TestValidateCrateName: + def test_valid_simple(self): + assert validate_crate_name("serde") is None + + def test_valid_with_hyphen(self): + assert validate_crate_name("serde-json") is None + + def test_valid_with_underscore(self): + assert validate_crate_name("serde_json") is None + + def test_valid_single_char(self): + assert validate_crate_name("a") is None + + def test_valid_max_length(self): + assert validate_crate_name("a" * 64) is None + + def test_valid_mixed_case(self): + assert validate_crate_name("MyLib") is None + + def test_invalid_empty(self): + assert validate_crate_name("") is not None + + def test_invalid_starts_with_digit(self): + assert validate_crate_name("123abc") is not None + + def test_invalid_starts_with_hyphen(self): + assert validate_crate_name("-foo") is not None + + def test_invalid_starts_with_underscore(self): + assert validate_crate_name("_foo") is not None + + def test_invalid_special_chars(self): + assert validate_crate_name("foo@bar") is not None + + def test_invalid_spaces(self): + assert validate_crate_name("foo bar") is not None + + def test_invalid_too_long(self): + assert validate_crate_name("a" * 65) is not None + + def test_invalid_dot(self): + assert validate_crate_name("foo.bar") is not None + + +class TestValidateCrateVersion: + def test_valid_basic(self): + assert validate_crate_version("1.0.0") is None + + def test_valid_zeros(self): + assert validate_crate_version("0.0.0") is None + + def test_valid_large_numbers(self): + assert validate_crate_version("100.200.300") is None + + def test_valid_prerelease(self): + assert validate_crate_version("1.0.0-alpha.1") is None + + def test_valid_prerelease_with_hyphen(self): + assert validate_crate_version("1.0.0-beta-2") is None + + def test_valid_build_metadata(self): + assert validate_crate_version("1.0.0+build.123") is None + + def test_valid_prerelease_and_build(self): + assert validate_crate_version("1.0.0-alpha+build") is None + + def test_invalid_empty(self): + assert validate_crate_version("") is not None + + def test_invalid_not_semver(self): + assert validate_crate_version("abc") is not None + + def test_invalid_two_parts(self): + assert validate_crate_version("1.0") is not None + + def test_invalid_four_parts(self): + assert validate_crate_version("1.0.0.0") is not None + + def test_invalid_leading_v(self): + assert validate_crate_version("v1.0.0") is not None + + def test_invalid_leading_zero(self): + assert validate_crate_version("01.0.0") is not None + + +class TestCanonicalizeCrateName: + def test_lowercase(self): + assert canonicalize_crate_name("Serde") == "serde" + + def test_hyphen_to_underscore(self): + assert canonicalize_crate_name("serde-json") == "serde_json" + + def test_already_canonical(self): + assert canonicalize_crate_name("serde_json") == "serde_json" + + def test_mixed(self): + assert canonicalize_crate_name("My-Cool_Crate") == "my_cool_crate" + + def test_all_uppercase(self): + assert canonicalize_crate_name("SERDE") == "serde" + + +class TestStripSemverBuildMetadata: + def test_no_metadata(self): + assert strip_semver_build_metadata("1.0.0") == "1.0.0" + + def test_simple_metadata(self): + assert strip_semver_build_metadata("1.0.0+build1") == "1.0.0" + + def test_complex_metadata(self): + assert strip_semver_build_metadata("1.0.0+build.123.abc") == "1.0.0" + + def test_prerelease_no_metadata(self): + assert strip_semver_build_metadata("1.0.0-alpha.1") == "1.0.0-alpha.1" + + def test_prerelease_with_metadata(self): + assert strip_semver_build_metadata("1.0.0-alpha+build") == "1.0.0-alpha" + + def test_metadata_with_plus_in_metadata(self): + # Only the first '+' is the delimiter + assert strip_semver_build_metadata("1.0.0+a+b") == "1.0.0" From f50de9cacd3bba26ebf78f1bb9f991ad8827b652 Mon Sep 17 00:00:00 2001 From: Daniel Alley Date: Wed, 15 Apr 2026 10:13:50 -0400 Subject: [PATCH 3/3] Fix running on S3 / Azure --- pulp_rust/app/models.py | 3 ++- pulp_rust/app/tasks/publishing.py | 3 ++- pulp_rust/app/utils.py | 14 ++++++++++---- pulp_rust/tests/functional/api/test_upload.py | 3 ++- pulp_rust/tests/functional/utils.py | 3 ++- pulp_rust/tests/unit/test_utils.py | 18 ++++++++++++------ 6 files changed, 30 insertions(+), 14 deletions(-) diff --git a/pulp_rust/app/models.py b/pulp_rust/app/models.py index dbeca0d..4b6352d 100755 --- a/pulp_rust/app/models.py +++ b/pulp_rust/app/models.py @@ -138,7 +138,8 @@ def init_from_artifact_and_relative_path(artifact, relative_path): Cargo.toml inside the .crate tarball. """ crate_name, version = _parse_crate_relative_path(relative_path) - cargo_toml = extract_cargo_toml(artifact.file.path, crate_name, version) + with artifact.file.open("rb") as f: + cargo_toml = extract_cargo_toml(f, crate_name, version) content = RustContent( name=crate_name, diff --git a/pulp_rust/app/tasks/publishing.py b/pulp_rust/app/tasks/publishing.py index 9e52bce..1204a9b 100644 --- a/pulp_rust/app/tasks/publishing.py +++ b/pulp_rust/app/tasks/publishing.py @@ -79,7 +79,8 @@ async def apublish_package(repository_pk, metadata, crate_path): # 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"]) + with artifact.file.open("rb") as f: + cargo_toml = extract_cargo_toml(f, metadata["name"], metadata["vers"]) package = cargo_toml.get("package", {}) name = package["name"] diff --git a/pulp_rust/app/utils.py b/pulp_rust/app/utils.py index 5f9fa27..dcd6921 100644 --- a/pulp_rust/app/utils.py +++ b/pulp_rust/app/utils.py @@ -7,13 +7,19 @@ import tomli as tomllib -def extract_cargo_toml(crate_path, crate_name, version): - """Extract and parse Cargo.toml from a .crate tarball.""" +def extract_cargo_toml(fileobj, crate_name, version): + """Extract and parse Cargo.toml from a .crate tarball. + + Args: + fileobj: A file-like object containing the .crate tarball data. + crate_name: The crate name (used to locate Cargo.toml inside the tarball). + version: The crate version (used to locate Cargo.toml inside the tarball). + """ expected_path = f"{crate_name}-{version}/Cargo.toml" - with tarfile.open(crate_path, "r:gz") as tar: + with tarfile.open(fileobj=fileobj, mode="r:gz") as tar: cargo_toml_file = tar.extractfile(expected_path) if cargo_toml_file is None: - raise FileNotFoundError(f"No Cargo.toml found in {crate_path} at {expected_path}") + raise FileNotFoundError(f"No Cargo.toml found at {expected_path}") return tomllib.load(cargo_toml_file) diff --git a/pulp_rust/tests/functional/api/test_upload.py b/pulp_rust/tests/functional/api/test_upload.py index 2c5b3ed..8524003 100644 --- a/pulp_rust/tests/functional/api/test_upload.py +++ b/pulp_rust/tests/functional/api/test_upload.py @@ -29,7 +29,8 @@ def test_upload_and_index_fidelity( crate_path, cksum = download_crate_from_upstream(crate_name, crate_version) # 2. Parse metadata from the .crate file - cargo_toml = extract_cargo_toml(crate_path, crate_name, crate_version) + with open(crate_path, "rb") as f: + cargo_toml = extract_cargo_toml(f, crate_name, crate_version) deps = extract_dependencies(cargo_toml) features = cargo_toml.get("features", {}) links = cargo_toml.get("package", {}).get("links") diff --git a/pulp_rust/tests/functional/utils.py b/pulp_rust/tests/functional/utils.py index 73ef61f..7fb8fc9 100644 --- a/pulp_rust/tests/functional/utils.py +++ b/pulp_rust/tests/functional/utils.py @@ -129,7 +129,8 @@ def build_publish_metadata(crate_path, crate_name, crate_version): Cargo uses "version_req" (not "req") and "explicit_name_in_toml" (not "package") per the Cargo registry web API spec. """ - cargo_toml = extract_cargo_toml(crate_path, crate_name, crate_version) + with open(crate_path, "rb") as f: + cargo_toml = extract_cargo_toml(f, crate_name, crate_version) deps = extract_dependencies(cargo_toml) return { diff --git a/pulp_rust/tests/unit/test_utils.py b/pulp_rust/tests/unit/test_utils.py index 3be7cf7..0589961 100644 --- a/pulp_rust/tests/unit/test_utils.py +++ b/pulp_rust/tests/unit/test_utils.py @@ -207,7 +207,8 @@ class TestExtractCargoToml: def test_basic_extraction(self): toml_content = b'[package]\nname = "foo"\nversion = "1.0.0"\n' path = _make_crate_tarball("foo", "1.0.0", toml_content) - result = extract_cargo_toml(path, "foo", "1.0.0") + with open(path, "rb") as f: + result = extract_cargo_toml(f, "foo", "1.0.0") assert result["package"]["name"] == "foo" assert result["package"]["version"] == "1.0.0" @@ -216,7 +217,8 @@ def test_with_dependencies(self): b'[package]\nname = "bar"\nversion = "0.1.0"\n' b'\n[dependencies]\nserde = "1.0"\n' ) path = _make_crate_tarball("bar", "0.1.0", toml_content) - result = extract_cargo_toml(path, "bar", "0.1.0") + with open(path, "rb") as f: + result = extract_cargo_toml(f, "bar", "0.1.0") assert "serde" in result["dependencies"] def test_with_features(self): @@ -225,7 +227,8 @@ def test_with_features(self): b'\n[features]\ndefault = ["std"]\nstd = []\n' ) path = _make_crate_tarball("baz", "2.0.0", toml_content) - result = extract_cargo_toml(path, "baz", "2.0.0") + with open(path, "rb") as f: + result = extract_cargo_toml(f, "baz", "2.0.0") assert result["features"] == {"default": ["std"], "std": []} def test_missing_cargo_toml_raises(self): @@ -241,18 +244,21 @@ def test_missing_cargo_toml_raises(self): tmp.flush() with pytest.raises(KeyError): - extract_cargo_toml(tmp.name, "foo", "1.0.0") + with open(tmp.name, "rb") as f: + extract_cargo_toml(f, "foo", "1.0.0") def test_with_rust_version(self): toml_content = b'[package]\nname = "qux"\nversion = "1.0.0"\nrust-version = "1.56.0"\n' path = _make_crate_tarball("qux", "1.0.0", toml_content) - result = extract_cargo_toml(path, "qux", "1.0.0") + with open(path, "rb") as f: + result = extract_cargo_toml(f, "qux", "1.0.0") assert result["package"]["rust-version"] == "1.56.0" def test_with_links(self): toml_content = b'[package]\nname = "zlib-sys"\nversion = "0.1.0"\nlinks = "z"\n' path = _make_crate_tarball("zlib-sys", "0.1.0", toml_content) - result = extract_cargo_toml(path, "zlib-sys", "0.1.0") + with open(path, "rb") as f: + result = extract_cargo_toml(f, "zlib-sys", "0.1.0") assert result["package"]["links"] == "z"