Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions pulp_rust/app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
from logging import getLogger

from django.db import models
from django_lifecycle import hook, AFTER_CREATE

from pulp_rust.app.utils import extract_cargo_toml, extract_dependencies

from pulpcore.plugin.models import (
Content,
Expand Down Expand Up @@ -106,15 +109,32 @@ def init_from_artifact_and_relative_path(artifact, relative_path):
Create an unsaved RustContent from a downloaded .crate artifact.

Called by pulpcore's content handler during pull-through caching.
Only populates name, version, and checksum -- dependency and feature
metadata is served from the upstream sparse index via the proxy.
Extracts full metadata (dependencies, features, etc.) from the
Cargo.toml inside the .crate tarball.
"""
crate_name, version = _parse_crate_relative_path(relative_path)
return RustContent(
cargo_toml = extract_cargo_toml(artifact.file.path, crate_name, version)

content = RustContent(
name=crate_name,
vers=version,
cksum=artifact.sha256,
features=cargo_toml.get("features", {}),
links=cargo_toml.get("package", {}).get("links"),
rust_version=cargo_toml.get("package", {}).get("rust-version"),
)
# Store parsed dep data for the AFTER_CREATE hook to consume
content._parsed_deps = extract_dependencies(cargo_toml)
return content

@hook(AFTER_CREATE)
def _create_dependencies_from_parsed_data(self):
"""Create RustDependency records from data parsed during pull-through."""
parsed_deps = getattr(self, "_parsed_deps", None)
if parsed_deps:
RustDependency.objects.bulk_create(
[RustDependency(content=self, **dep) for dep in parsed_deps]
)

class Meta:
default_related_name = "%(app_label)s_%(model_name)s"
Expand Down
90 changes: 90 additions & 0 deletions pulp_rust/app/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import tarfile

try:
import tomllib
except ModuleNotFoundError:
import tomli as tomllib


def extract_cargo_toml(crate_path, crate_name, version):
"""Extract and parse Cargo.toml from a .crate tarball."""
expected_path = f"{crate_name}-{version}/Cargo.toml"
with tarfile.open(crate_path, "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}")
return tomllib.load(cargo_toml_file)


def _normalize_req(version_str):
"""Normalize a Cargo version requirement to its explicit form.

In Cargo.toml, a bare version like "1.0" is shorthand for "^1.0".
The index format uses the explicit form with the comparator prefix.
"""
if not version_str or version_str == "*":
return version_str
# Already has a comparator prefix
if version_str[0] in ("^", "~", "=", ">", "<"):
return version_str
return f"^{version_str}"


def parse_dep(name, spec, kind="normal", target=None):
"""Convert a single Cargo.toml dependency entry to index format."""
if isinstance(spec, str):
# Simple form: dep = "1.0"
return {
"name": name,
"req": _normalize_req(spec),
"features": [],
"optional": False,
"default_features": True,
"target": target,
"kind": kind,
"registry": None,
"package": None,
}

# Table form: dep = { version = "1.0", optional = true, ... }
dep = {
"name": name,
"req": _normalize_req(spec.get("version", "*")),
"features": spec.get("features", []),
"optional": spec.get("optional", False),
"default_features": spec.get("default-features", True),
"target": target,
"kind": kind,
"registry": spec.get("registry"),
"package": None,
}
# If the dep was renamed, "name" in the index is the alias (the key),
# and "package" is the real crate name
if "package" in spec:
dep["package"] = spec["package"]
return dep


def extract_dependencies(cargo_toml):
"""Extract all dependencies from a parsed Cargo.toml into index format."""
deps = []

for name, spec in cargo_toml.get("dependencies", {}).items():
deps.append(parse_dep(name, spec, kind="normal"))

for name, spec in cargo_toml.get("dev-dependencies", {}).items():
deps.append(parse_dep(name, spec, kind="dev"))

for name, spec in cargo_toml.get("build-dependencies", {}).items():
deps.append(parse_dep(name, spec, kind="build"))

# Platform-specific dependencies: [target.'cfg(...)'.dependencies]
for target, target_deps in cargo_toml.get("target", {}).items():
for name, spec in target_deps.get("dependencies", {}).items():
deps.append(parse_dep(name, spec, kind="normal", target=target))
for name, spec in target_deps.get("dev-dependencies", {}).items():
deps.append(parse_dep(name, spec, kind="dev", target=target))
for name, spec in target_deps.get("build-dependencies", {}).items():
deps.append(parse_dep(name, spec, kind="build", target=target))

return deps
28 changes: 15 additions & 13 deletions pulp_rust/app/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,19 +178,21 @@ def _build_index_response(crate_versions):
for crate_version in crate_versions:
deps = []
for dep in crate_version.dependencies.all():
deps.append(
{
"name": dep.name,
"req": dep.req,
"features": dep.features,
"optional": dep.optional,
"default_features": dep.default_features,
"target": dep.target,
"kind": dep.kind,
"registry": dep.registry,
"package": dep.package,
}
)
dep_obj = {
"name": dep.name,
"req": dep.req,
"features": dep.features,
"optional": dep.optional,
"default_features": dep.default_features,
"target": dep.target,
"kind": dep.kind,
}
# crates.io omits these keys when not set
if dep.registry is not None:
dep_obj["registry"] = dep.registry
if dep.package is not None:
dep_obj["package"] = dep.package
deps.append(dep_obj)

version_obj = {
"name": crate_version.name,
Expand Down
4 changes: 1 addition & 3 deletions pulp_rust/tests/functional/api/test_cargo_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@
import pytest
from aiohttp.client_exceptions import ClientResponseError

from pulp_rust.tests.functional.utils import download_file

CRATES_IO_URL = "sparse+https://index.crates.io/"
from pulp_rust.tests.functional.utils import CRATES_IO_URL, download_file


def test_config_json(
Expand Down
4 changes: 2 additions & 2 deletions pulp_rust/tests/functional/api/test_download_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import hashlib
from urllib.parse import urljoin

from pulp_rust.tests.functional.utils import download_file
from pulp_rust.tests.functional.utils import CRATES_IO_URL, download_file


def test_download_content(
Expand All @@ -27,7 +27,7 @@ def test_download_content(
3. Verify that the content was automatically added to the repository.
4. Remove the remote and verify the content is still served from cache.
"""
remote = rust_remote_factory(url="sparse+https://index.crates.io/")
remote = rust_remote_factory(url=CRATES_IO_URL)
repository = rust_repo_factory(remote=remote.pulp_href)
distribution = rust_distribution_factory(
remote=remote.pulp_href, repository=repository.pulp_href
Expand Down
Loading