diff --git a/pulp_rust/app/models.py b/pulp_rust/app/models.py index 8d6caa4..c456c36 100755 --- a/pulp_rust/app/models.py +++ b/pulp_rust/app/models.py @@ -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, @@ -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" diff --git a/pulp_rust/app/utils.py b/pulp_rust/app/utils.py new file mode 100644 index 0000000..061e0d0 --- /dev/null +++ b/pulp_rust/app/utils.py @@ -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 diff --git a/pulp_rust/app/views.py b/pulp_rust/app/views.py index d6c19f2..8827d3c 100644 --- a/pulp_rust/app/views.py +++ b/pulp_rust/app/views.py @@ -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, diff --git a/pulp_rust/tests/functional/api/test_cargo_api.py b/pulp_rust/tests/functional/api/test_cargo_api.py index d8b1264..0f4becf 100644 --- a/pulp_rust/tests/functional/api/test_cargo_api.py +++ b/pulp_rust/tests/functional/api/test_cargo_api.py @@ -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( diff --git a/pulp_rust/tests/functional/api/test_download_content.py b/pulp_rust/tests/functional/api/test_download_content.py index 3ff4bbc..0c374be 100644 --- a/pulp_rust/tests/functional/api/test_download_content.py +++ b/pulp_rust/tests/functional/api/test_download_content.py @@ -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( @@ -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 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 31dcf35..bc87739 100644 --- a/pulp_rust/tests/functional/api/test_pull_through_caching.py +++ b/pulp_rust/tests/functional/api/test_pull_through_caching.py @@ -1,13 +1,17 @@ """Tests for Cargo pull-through caching via the sparse index proxy.""" import hashlib +import json from urllib.parse import urljoin import pytest -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, + assert_index_entry_matches_upstream, + download_file, + get_index_entry, +) def test_pull_through_sparse_index( @@ -112,43 +116,11 @@ def test_pull_through_on_demand_creates_content( assert content_response.count == 1 -def test_pull_through_on_demand_add_cached_content( - rust_remote_factory, - rust_repo_factory, - rust_distribution_factory, - rust_repo_api_client, - monitor_task, - cargo_registry_url, -): - """on_demand: add_cached_content should add pulled-through content to a new repo version.""" - 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 - ) - - # Download a crate to trigger caching - unit_path = "api/v1/crates/itoa/1.0.0/download" - pulp_unit_url = urljoin(cargo_registry_url(distribution.base_path), unit_path) - download_file(pulp_unit_url) - - # Add cached content to the repository - monitor_task( - rust_repo_api_client.add_cached_content( - repository.pulp_href, {"remote": remote.pulp_href} - ).task - ) - - repository = rust_repo_api_client.read(repository.pulp_href) - assert not repository.latest_version_href.endswith("/versions/0/") - - def test_pull_through_on_demand_serves_from_cache_without_remote( rust_remote_factory, rust_repo_factory, rust_distribution_factory, rust_distro_api_client, - rust_repo_api_client, monitor_task, cargo_registry_url, ): @@ -164,13 +136,6 @@ def test_pull_through_on_demand_serves_from_cache_without_remote( first_download = download_file(pulp_unit_url) first_checksum = hashlib.sha256(first_download.body).hexdigest() - # Add cached content to the repository - monitor_task( - rust_repo_api_client.add_cached_content( - repository.pulp_href, {"remote": remote.pulp_href} - ).task - ) - # Remove the remote from the distribution monitor_task( rust_distro_api_client.partial_update(distribution.pulp_href, {"remote": None}).task @@ -214,7 +179,6 @@ def test_pull_through_multiple_crates_on_demand( rust_distribution_factory, rust_content_api_client, rust_repo_api_client, - monitor_task, cargo_registry_url, ): """on_demand: downloading multiple crates should cache all of them.""" @@ -232,12 +196,142 @@ def test_pull_through_multiple_crates_on_demand( assert rust_content_api_client.list(name="itoa", vers="1.0.0").count == 1 assert rust_content_api_client.list(name="cfg-if", vers="1.0.0").count == 1 - # add_cached_content should pick up both - monitor_task( - rust_repo_api_client.add_cached_content( - repository.pulp_href, {"remote": remote.pulp_href} - ).task - ) - + # Content should have been automatically added to the repository repository = rust_repo_api_client.read(repository.pulp_href) assert not repository.latest_version_href.endswith("/versions/0/") + + +def test_pull_through_on_demand_preserves_metadata( + delete_orphans_pre, + rust_remote_factory, + rust_repo_factory, + rust_distribution_factory, + rust_content_api_client, + cargo_registry_url, +): + """on_demand: cached content should have full metadata (deps, features). + + Ensure that all metadata is saved appropriately when a package is created via + pull-through caching. + """ + 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 + ) + + # serde has dependencies (serde_derive) and features in most versions + crate_name, crate_version = "serde", "1.0.210" + unit_path = f"api/v1/crates/{crate_name}/{crate_version}/download" + pulp_unit_url = urljoin(cargo_registry_url(distribution.base_path), unit_path) + download_file(pulp_unit_url) + + # Verify the content record was created with full metadata + content_response = rust_content_api_client.list(name=crate_name, vers=crate_version) + assert content_response.count == 1 + content = content_response.results[0] + + # Should have dependencies (serde has serde_derive as an optional dep) + assert len(content.dependencies) > 0 + dep_names = [d.name for d in content.dependencies] + assert "serde_derive" in dep_names + + # Should have features + assert len(content.features) > 0 + assert "derive" in content.features + + # Fetch the sparse index from Pulp (now served from local data) + index_url = urljoin(cargo_registry_url(distribution.base_path), f"se/rd/{crate_name}") + index_response = download_file(index_url) + assert index_response.response_obj.status == 200 + + body = index_response.body.decode("utf-8") + lines = body.strip().split("\n") + # Find the line for our version + version_entry = None + for line in lines: + entry = json.loads(line) + if entry["vers"] == crate_version: + version_entry = entry + break + + assert version_entry is not None, f"Version {crate_version} not found in index" + assert len(version_entry["deps"]) > 0, "Index entry has no dependencies" + index_dep_names = [d["name"] for d in version_entry["deps"]] + assert "serde_derive" in index_dep_names + assert len(version_entry["features"]) > 0, "Index entry has no features" + + +# --------------------------------------------------------------------------- +# Index fidelity tests: compare Pulp output against crates.io for each mode +# --------------------------------------------------------------------------- + + +def test_index_fidelity_streamed( + rust_remote_factory, + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, + upstream_index_entry, +): + """streamed: proxied sparse index entry should match crates.io exactly.""" + remote = rust_remote_factory(url=CRATES_IO_URL, policy="streamed") + 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) + pulp_entry = get_index_entry(base, "se/rd/serde", "1.0.210") + assert_index_entry_matches_upstream(pulp_entry, upstream_index_entry) + + +def test_index_fidelity_on_demand_proxied( + rust_remote_factory, + rust_repo_factory, + rust_distribution_factory, + cargo_registry_url, + upstream_index_entry, +): + """on_demand: index with no local content proxies upstream and should match.""" + 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) + + # No local content in this repo, so the index is proxied from 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_index_fidelity_on_demand_cached( + delete_orphans_pre, + rust_remote_factory, + rust_repo_factory, + rust_distribution_factory, + rust_content_api_client, + cargo_registry_url, + upstream_index_entry, +): + """on_demand: after caching, the locally-served index entry should match crates.io.""" + 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) + + # Download the .crate to trigger content creation + download_file(urljoin(base, "api/v1/crates/serde/1.0.210/download")) + + # Verify content was cached + content = rust_content_api_client.list(name="serde", vers="1.0.210") + assert content.count == 1, "Content was not cached after on_demand download" + + # 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) diff --git a/pulp_rust/tests/functional/conftest.py b/pulp_rust/tests/functional/conftest.py index f88f38e..ba9fe4d 100644 --- a/pulp_rust/tests/functional/conftest.py +++ b/pulp_rust/tests/functional/conftest.py @@ -1,4 +1,5 @@ import uuid +from urllib.parse import urljoin import pytest @@ -10,6 +11,12 @@ RepositoriesRustApi, ) +from pulp_rust.tests.functional.utils import ( + CRATES_IO_URL, + download_file, + get_index_entry, +) + @pytest.fixture(scope="session") def rust_client(_api_client_set, bindings_cfg): @@ -86,3 +93,54 @@ def _cargo_registry_url(base_path): return f"{bindings_cfg.host}/pulp/cargo/{base_path}/" return _cargo_registry_url + + +@pytest.fixture +def populated_repo( + rust_remote_factory, + rust_repo_factory, + rust_distribution_factory, + rust_repo_api_client, + rust_distro_api_client, + monitor_task, + cargo_registry_url, +): + """Create a repo with itoa 1.0.0 and 1.0.1 pulled-through via on_demand. + + Content is automatically added to the repository during pull-through + (PULL_THROUGH_SUPPORTED = True). The remote is then detached from the + distribution so the index is served from local content only. + + Returns a dict with 'repository', 'distribution', 'remote', and 'base_url'. + """ + 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 + ) + base_url = cargo_registry_url(distribution.base_path) + + # Pull through two versions — each download automatically creates a new + # repo version with the content added (via PULL_THROUGH_SUPPORTED). + for version in ("1.0.0", "1.0.1"): + unit_path = f"api/v1/crates/itoa/{version}/download" + download_file(urljoin(base_url, unit_path)) + + # Detach remote from the distribution so the index is served from local + # content only (the distribution's remote controls the proxy fallback). + monitor_task( + rust_distro_api_client.partial_update(distribution.pulp_href, {"remote": None}).task + ) + + return { + "repository": rust_repo_api_client.read(repository.pulp_href), + "distribution": rust_distro_api_client.read(distribution.pulp_href), + "remote": remote, + "base_url": base_url, + } + + +@pytest.fixture(scope="module") +def upstream_index_entry(): + """Fetch the canonical index entry for serde 1.0.210 from crates.io (once per module).""" + return get_index_entry("https://index.crates.io/", "se/rd/serde", "1.0.210") diff --git a/pulp_rust/tests/functional/utils.py b/pulp_rust/tests/functional/utils.py index f050549..a7848f5 100644 --- a/pulp_rust/tests/functional/utils.py +++ b/pulp_rust/tests/functional/utils.py @@ -1,7 +1,19 @@ +import json + import aiohttp import asyncio from dataclasses import dataclass +CRATES_IO_URL = "sparse+https://index.crates.io/" + +# Fields from the sparse index that Pulp should faithfully reproduce. +# "yanked" is excluded because it depends on per-repo yank state, not upstream. +# Fields from the sparse index that Pulp should faithfully reproduce. +# "yanked" is excluded because it depends on per-repo yank state, not upstream. +# "v" is excluded because it's a schema version marker that older crates.io entries omit +# (it defaults to 1 when absent). +INDEX_FIELDS_TO_COMPARE = ("name", "vers", "deps", "cksum", "features", "links") + @dataclass class Download: @@ -30,3 +42,62 @@ async def _download_file(url, auth=None, headers=None): async with aiohttp.ClientSession(auth=auth, raise_for_status=True) as session: async with session.get(url, verify_ssl=False, headers=headers) as response: return Download(body=await response.read(), response_obj=response) + + +def parse_index_entry(body, version): + """Find and return the index entry for a specific version from newline-delimited JSON.""" + for line in body.strip().split("\n"): + entry = json.loads(line) + if entry["vers"] == version: + return entry + return None + + +def get_index_entry(url, sparse_path, version): + """Fetch a sparse index (from any base URL) and return the entry for a single version. + + Works for both upstream registries (e.g. https://index.crates.io/) and + Pulp-served registries. + """ + from urllib.parse import urljoin + + full_url = urljoin(url, sparse_path) + downloaded = download_file(full_url) + assert downloaded.response_obj.status == 200 + entry = parse_index_entry(downloaded.body.decode("utf-8"), version) + if entry is None: + raise AssertionError(f"Version {version} not found in index at {full_url}") + return entry + + +def assert_index_entry_matches_upstream(pulp_entry, upstream_entry): + """Assert that a Pulp-served index entry matches the upstream for all stored fields.""" + for field in INDEX_FIELDS_TO_COMPARE: + if field == "deps": + + def sort_key(d): + return ( + d["name"], + d.get("kind", ""), + d.get("req", ""), + d.get("target") or "", + ) + + pulp_deps = sorted(pulp_entry["deps"], key=sort_key) + upstream_deps = sorted(upstream_entry["deps"], key=sort_key) + assert ( + pulp_deps == upstream_deps + ), f"deps mismatch:\n pulp={pulp_deps}\n upstream={upstream_deps}" + else: + assert pulp_entry.get(field) == upstream_entry.get(field), ( + f"{field} mismatch: pulp={pulp_entry.get(field)!r} " + f"upstream={upstream_entry.get(field)!r}" + ) + + # Also compare optional fields that may be present + for field in ("features2", "rust_version"): + if field in upstream_entry: + assert pulp_entry.get(field) == upstream_entry[field], ( + f"{field} mismatch: pulp={pulp_entry.get(field)!r} " + f"upstream={upstream_entry[field]!r}" + ) diff --git a/pulp_rust/tests/unit/test_utils.py b/pulp_rust/tests/unit/test_utils.py new file mode 100644 index 0000000..76249c6 --- /dev/null +++ b/pulp_rust/tests/unit/test_utils.py @@ -0,0 +1,248 @@ +"""Unit tests for pulp_rust.app.utils.""" + +import io +import tarfile +import tempfile + +import django +import pytest + +django.setup() + +from pulp_rust.app.utils import extract_cargo_toml, extract_dependencies, parse_dep # noqa: E402 + + +def _make_crate_tarball(crate_name, version, cargo_toml_bytes): + """Create a .crate (gzipped tarball) in a temp file and return its path.""" + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + info = tarfile.TarInfo(name=f"{crate_name}-{version}/Cargo.toml") + info.size = len(cargo_toml_bytes) + tar.addfile(info, io.BytesIO(cargo_toml_bytes)) + tmp = tempfile.NamedTemporaryFile(suffix=".crate", delete=False) + tmp.write(buf.getvalue()) + tmp.flush() + return tmp.name + + +class TestParseDep: + def test_simple_string_spec(self): + result = parse_dep("serde", "1.0") + assert result == { + "name": "serde", + "req": "^1.0", + "features": [], + "optional": False, + "default_features": True, + "target": None, + "kind": "normal", + "registry": None, + "package": None, + } + + def test_table_spec_minimal(self): + result = parse_dep("serde", {"version": "1.0"}) + assert result["name"] == "serde" + assert result["req"] == "^1.0" + assert result["optional"] is False + assert result["default_features"] is True + assert result["features"] == [] + assert result["package"] is None + + def test_table_spec_all_fields(self): + spec = { + "version": "^1.2", + "features": ["derive", "std"], + "optional": True, + "default-features": False, + "registry": "https://my-registry.example.com/", + "package": "serde_real", + } + result = parse_dep("my_serde", spec) + assert result["name"] == "my_serde" + assert result["req"] == "^1.2" + assert result["features"] == ["derive", "std"] + assert result["optional"] is True + assert result["default_features"] is False + assert result["registry"] == "https://my-registry.example.com/" + assert result["package"] == "serde_real" + + def test_table_spec_no_version_defaults_to_star(self): + result = parse_dep("foo", {"optional": True}) + assert result["req"] == "*" + + def test_kind_propagated(self): + result = parse_dep("cc", "1.0", kind="build") + assert result["kind"] == "build" + + def test_target_propagated(self): + result = parse_dep("winapi", "0.3", target="cfg(windows)") + assert result["target"] == "cfg(windows)" + + def test_dev_kind(self): + result = parse_dep("criterion", "0.4", kind="dev") + assert result["kind"] == "dev" + + def test_renamed_dep(self): + spec = {"version": "1.0", "package": "original_name"} + result = parse_dep("alias", spec) + assert result["name"] == "alias" + assert result["package"] == "original_name" + + def test_bare_version_gets_caret_prefix(self): + assert parse_dep("foo", "1.2.3")["req"] == "^1.2.3" + + def test_tilde_version_preserved(self): + assert parse_dep("foo", "~1.2")["req"] == "~1.2" + + def test_exact_version_preserved(self): + assert parse_dep("foo", "=1.0.0")["req"] == "=1.0.0" + + def test_comparison_version_preserved(self): + assert parse_dep("foo", ">=1.0,<2.0")["req"] == ">=1.0,<2.0" + + def test_wildcard_preserved(self): + assert parse_dep("foo", "*")["req"] == "*" + + def test_table_bare_version_gets_caret(self): + result = parse_dep("foo", {"version": "0.3"}) + assert result["req"] == "^0.3" + + +class TestExtractDependencies: + def test_empty_toml(self): + assert extract_dependencies({}) == [] + + def test_normal_deps(self): + cargo_toml = { + "dependencies": { + "serde": "1.0", + "log": {"version": "0.4", "features": ["std"]}, + } + } + deps = extract_dependencies(cargo_toml) + assert len(deps) == 2 + by_name = {d["name"]: d for d in deps} + assert by_name["serde"]["req"] == "^1.0" + assert by_name["serde"]["kind"] == "normal" + assert by_name["log"]["features"] == ["std"] + + def test_dev_deps(self): + cargo_toml = { + "dev-dependencies": { + "criterion": "0.4", + } + } + deps = extract_dependencies(cargo_toml) + assert len(deps) == 1 + assert deps[0]["kind"] == "dev" + assert deps[0]["name"] == "criterion" + + def test_build_deps(self): + cargo_toml = { + "build-dependencies": { + "cc": "1.0", + } + } + deps = extract_dependencies(cargo_toml) + assert len(deps) == 1 + assert deps[0]["kind"] == "build" + + def test_target_specific_deps(self): + cargo_toml = { + "target": { + "cfg(windows)": { + "dependencies": {"winapi": "0.3"}, + }, + "cfg(unix)": { + "dependencies": {"libc": "0.2"}, + "dev-dependencies": {"nix": "0.26"}, + }, + } + } + deps = extract_dependencies(cargo_toml) + assert len(deps) == 3 + by_name = {d["name"]: d for d in deps} + assert by_name["winapi"]["target"] == "cfg(windows)" + assert by_name["winapi"]["kind"] == "normal" + assert by_name["libc"]["target"] == "cfg(unix)" + assert by_name["nix"]["target"] == "cfg(unix)" + assert by_name["nix"]["kind"] == "dev" + + def test_mixed_dep_types(self): + cargo_toml = { + "dependencies": {"serde": "1.0"}, + "dev-dependencies": {"criterion": "0.4"}, + "build-dependencies": {"cc": "1.0"}, + } + deps = extract_dependencies(cargo_toml) + assert len(deps) == 3 + kinds = {d["name"]: d["kind"] for d in deps} + assert kinds == {"serde": "normal", "criterion": "dev", "cc": "build"} + + def test_target_build_deps(self): + cargo_toml = { + "target": { + "cfg(windows)": { + "build-dependencies": {"vcpkg": "0.2"}, + } + } + } + deps = extract_dependencies(cargo_toml) + assert len(deps) == 1 + assert deps[0]["name"] == "vcpkg" + assert deps[0]["kind"] == "build" + assert deps[0]["target"] == "cfg(windows)" + + +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") + assert result["package"]["name"] == "foo" + assert result["package"]["version"] == "1.0.0" + + def test_with_dependencies(self): + toml_content = ( + 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") + assert "serde" in result["dependencies"] + + def test_with_features(self): + toml_content = ( + b'[package]\nname = "baz"\nversion = "2.0.0"\n' + 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") + assert result["features"] == {"default": ["std"], "std": []} + + def test_missing_cargo_toml_raises(self): + # A .crate without Cargo.toml is invalid and should error + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + data = b"hello" + info = tarfile.TarInfo(name="foo-1.0.0/README.md") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + tmp = tempfile.NamedTemporaryFile(suffix=".crate", delete=False) + tmp.write(buf.getvalue()) + tmp.flush() + + with pytest.raises(KeyError): + extract_cargo_toml(tmp.name, "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") + 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") + assert result["package"]["links"] == "z"