diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 759ff840..4889e21c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,21 +1,13 @@ -Release 0.15.0 (unreleased) +Release 0.14.1 (unreleased) ============================ -* Implement C-043: add ``pip-audit`` OSV gate to the release workflow; publishing is blocked if - any known vulnerability is found in dfetch's runtime dependencies -* Add CRA Compliance Track B: OSCAL 1.2.2 Component Definition mapping all CRA Annex I Part I - essential requirements (ECR-a–m) through prEN 40000-1-4 Security Objectives to dfetch controls; - covers Part II via prEN 40000-1-3; introduces controls C-043 (release-gate CVE check), C-044 - (data minimisation policy), and C-046 (exploit mitigation inventory) -* Upgrade OSCAL artifacts to 1.2.2: both the prEN 40000-1-4 catalog and the Component Definition - now declare ``oscal-version: 1.2.2``; the catalog gains ``parties``, ``roles``, and - ``responsible-parties`` in its metadata; the Component Definition adds a ``purpose`` field on - the component, a ``supplier`` party, ``document-ids`` for stable cross-referencing, - ``responsible-roles``, and ``evidence`` links on every implemented-requirement pointing to the - concrete code or CI workflow file that implements the control — turning the compliance mapping - into a machine-verifiable security-as-code artifact; the back-matter is enriched with references - to the OpenSSF Scorecard, SLSA Source Provenance, Sigstore attestation, in-toto test results, - CodeQL, and dependency-review workflows +* Implement C-043: add ``pip-audit`` OSV gate to the release workflow +* Add CRA Compliance: OSCAL 1.2.2 Component Definition +* Fix ``dfetch import`` mangling the namespace of a generic VCS URL whose path contains ``.git`` (#1268) +* Fix ``Version`` comparison raising ``AttributeError`` when compared against a non-``Version`` object (#1268) +* Fix ``dfetch add`` matching remote whose base URL is only string (not path) prefix of the project URL (#1268) +* Fix git ref resolution spuriously matching the first reference for an empty revision (#1268) +* Strip the trailing newline from the git origin URL returned by ``get_remote_url`` (#1268) Release 0.14.0 (released 2026-06-14) ==================================== diff --git a/dfetch/manifest/manifest.py b/dfetch/manifest/manifest.py index e98bde02..953cb76e 100644 --- a/dfetch/manifest/manifest.py +++ b/dfetch/manifest/manifest.py @@ -592,7 +592,7 @@ def find_remote_for_url(self, remote_url: str) -> Remote | None: target = remote_url.rstrip("/") for remote in self.remotes: remote_base = remote.url.rstrip("/") - if target.startswith(remote_base): + if target == remote_base or target.startswith(remote_base + "/"): return remote return None diff --git a/dfetch/manifest/version.py b/dfetch/manifest/version.py index 6d7299fb..d5358c2b 100644 --- a/dfetch/manifest/version.py +++ b/dfetch/manifest/version.py @@ -16,7 +16,7 @@ class Version(NamedTuple): def __eq__(self, other: Any) -> bool: """Check if two versions can be considered as equal.""" - if not other: + if not isinstance(other, Version): return False if self.tag or other.tag: @@ -24,6 +24,12 @@ def __eq__(self, other: Any) -> bool: return bool(self.branch == other.branch and self.revision == other.revision) + def __hash__(self) -> int: + """Hash only fields that determine equality.""" + if self.tag: + return hash(self.tag) + return hash((self.branch, self.revision)) + @property def field(self) -> tuple[str, str]: """Return ``(kind, value)`` for the active field: tag, revision, or branch.""" diff --git a/dfetch/util/purl.py b/dfetch/util/purl.py index f5829a7f..ff573ae2 100644 --- a/dfetch/util/purl.py +++ b/dfetch/util/purl.py @@ -101,7 +101,7 @@ def _vcs_namespace_and_name(remote_url: str) -> tuple[str, str, str]: remote_url = f"ssh://{parsed.path.replace(':', '/')}" else: namespace, name = _namespace_and_name_from_domain_and_path( - remote_url, path.replace(".git", "") + remote_url, path.removesuffix(".git") ) return namespace, name, remote_url diff --git a/dfetch/vcs/git.py b/dfetch/vcs/git.py index 1cd3bd18..4dce4b5b 100644 --- a/dfetch/vcs/git.py +++ b/dfetch/vcs/git.py @@ -277,6 +277,8 @@ def _find_branch_tip_or_tag_from_sha( ) -> tuple[str, str]: """Check all branch tips and tags and see if the sha is one of them.""" branch, tag = "", "" + if not rev: + return (branch, tag) for reference, sha in info.items(): if sha.startswith(rev): # Also allow for shorter SHA's if reference.startswith("refs/tags/"): @@ -680,7 +682,7 @@ def get_remote_url() -> str: """Get the url of the remote origin.""" try: result = run_on_cmdline(logger, ["git", "remote", "get-url", "origin"]) - decoded_result = str(result.stdout.decode()) + decoded_result = str(result.stdout.decode()).strip() except SubprocessCommandError: decoded_result = "" diff --git a/tests/test_git_vcs.py b/tests/test_git_vcs.py index 4b6c1ca1..03f97dcf 100644 --- a/tests/test_git_vcs.py +++ b/tests/test_git_vcs.py @@ -318,6 +318,22 @@ def test_ls_remote(): assert info == expected +def test_get_remote_url_strips_trailing_newline(): + """git remote get-url appends a newline that must not leak into the URL.""" + with patch("dfetch.vcs.git.run_on_cmdline") as run_on_cmdline_mock: + run_on_cmdline_mock.return_value.stdout = b"https://github.com/org/repo.git\n" + assert GitLocalRepo.get_remote_url() == "https://github.com/org/repo.git" + + +def test_find_branch_tip_or_tag_from_sha_empty_rev_matches_nothing(): + """An empty revision must not spuriously match the first reference.""" + info = { + "refs/heads/master": "33d11e10699bae03ba2a58a280e92494f4fa0d82", + "refs/tags/v1.0": "0e3b216c7ab365b67765e94aeb45085c4db029e0", + } + assert GitRemote._find_branch_tip_or_tag_from_sha(info, "") == ("", "") + + @pytest.mark.parametrize( "name, env_ssh, git_config_ssh, expected", [ diff --git a/tests/test_manifest.py b/tests/test_manifest.py index ef6e3317..2efbbba5 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -6,7 +6,7 @@ import os from typing import cast -from unittest.mock import mock_open, patch +from unittest.mock import Mock, mock_open, patch import pytest @@ -686,3 +686,35 @@ def test_builder_blank_line_between_remotes() -> None: .build() ) assert "\n\n - name: gitlab" in manifest._doc.as_yaml() + + +# --------------------------------------------------------------------------- +# Manifest.find_remote_for_url – path-boundary checks +# --------------------------------------------------------------------------- + + +def _make_remote(name: str, url: str) -> Mock: + r = Mock(spec=Remote) + r.name = name + r.url = url + return r + + +def test_determine_remote_requires_path_boundary(): + """An org-scoped remote must not match a different org sharing its prefix.""" + m = Mock() + m.remotes = [_make_remote("myorg", "https://github.com/myorg")] + result = Manifest.find_remote_for_url( + m, "https://github.com/myorg-private/repo.git" + ) + assert result is None + + +def test_determine_remote_matches_exact_and_subpath(): + """The boundary check still matches the remote itself and any URL beneath it.""" + m = Mock() + m.remotes = [_make_remote("myorg", "https://github.com/myorg")] + assert Manifest.find_remote_for_url(m, "https://github.com/myorg") is not None + assert ( + Manifest.find_remote_for_url(m, "https://github.com/myorg/repo.git") is not None + ) diff --git a/tests/test_project_version.py b/tests/test_project_version.py index 0c6a0338..0533a541 100644 --- a/tests/test_project_version.py +++ b/tests/test_project_version.py @@ -202,3 +202,25 @@ def test_version_comparison(name, version_1, version_2, expected_equality): actual_equality = version_1 == version_2 assert actual_equality == expected_equality + + +@pytest.mark.parametrize( + "other", + [ + ("", "main", "123"), # a plain tuple + "main", # a string + 42, # an int + ], +) +def test_version_comparison_with_non_version(other): + """Comparing a Version with a non-Version must not crash.""" + assert (Version(branch="main", revision="123") == other) is False + + +def test_version_remains_hashable(): + """Defining __eq__ must not break hashing/set membership.""" + v1 = Version(tag="1.0") + v2 = Version(tag="1.0") + assert v1 == v2 + assert hash(v1) == hash(v2) + assert v2 in {v1} diff --git a/tests/test_purl.py b/tests/test_purl.py index 80699f5b..78b76cf2 100644 --- a/tests/test_purl.py +++ b/tests/test_purl.py @@ -115,6 +115,11 @@ "git://git.git.savannah.gnu.org/automake.git", "pkg:generic/automake?vcs_url=git://git.git.savannah.gnu.org/automake.git", ), + # A ".git" substring inside the path must not be stripped (only the suffix) + ( + "https://gitlab.com/group/foo.github/project.git", + "pkg:generic/group/foo.github/project?vcs_url=https://gitlab.com/group/foo.github/project.git", + ), # Trailing slash – issue #1137 ("https://github.com/cpputest/cpputest/", "pkg:github/cpputest/cpputest"), ("https://github.com/dfetch-org/dfetch/", "pkg:github/dfetch-org/dfetch"),