You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/09/10 14:01:55 UTC

(tooling-trusted-releases) 01/01: Make SBOM file discovery more lenient

This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git

commit 907b3e49ecc11a31f1ed180b43bf81f70893bd13
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Thu Sep 10 15:01:25 2026 +0100

    Make SBOM file discovery more lenient
---
 atr/analysis.py                             | 35 ++++++++++++++++---
 atr/tasks/checks/paths.py                   |  7 ++--
 tests/unit/test_classify.py                 | 18 +++++++---
 tests/unit/test_documentation_tree_check.py | 14 ++++++++
 tests/unit/test_sbom_candidates.py          | 53 +++++++++++++++++++++++++++++
 5 files changed, 116 insertions(+), 11 deletions(-)

diff --git a/atr/analysis.py b/atr/analysis.py
index f4aba48a..61b824e5 100755
--- a/atr/analysis.py
+++ b/atr/analysis.py
@@ -59,9 +59,25 @@ ARTIFACT_SUFFIXES: Final[list[str]] = [
     "zip",
 ]
 
-CYCLONEDX_JSON_SUFFIXES: Final[tuple[str, ...]] = (".cdx.json", "-cyclonedx.json")
+CYCLONEDX_JSON_FILENAMES: Final[tuple[str, ...]] = ("sbom.json", "bom.json")
+
+CYCLONEDX_JSON_SUFFIXES: Final[tuple[str, ...]] = (
+    ".cdx.json",
+    "-cyclonedx.json",
+    ".cyclonedx.json",
+    "-sbom.json",
+    "-bom.json",
+)
+
+CYCLONEDX_XML_FILENAMES: Final[tuple[str, ...]] = ("sbom.xml", "bom.xml")
 
-CYCLONEDX_XML_SUFFIXES: Final[tuple[str, ...]] = (".cdx.xml", "-cyclonedx.xml")
+CYCLONEDX_XML_SUFFIXES: Final[tuple[str, ...]] = (
+    ".cdx.xml",
+    "-cyclonedx.xml",
+    ".cyclonedx.xml",
+    "-sbom.xml",
+    "-bom.xml",
+)
 
 DISALLOWED_FILENAMES: Final[frozenset[str]] = frozenset(
     {
@@ -170,6 +186,7 @@ VARIANT_PATTERNS: Final[list[str]] = [
 _CANDIDATE_TAG: Final = r"(?: Candidate | candidate | RC | Rc | rc ) [.-]? [0-9]+"
 _CANDIDATE_PARTIAL: Final = re.compile(rf"(?x) - {_CANDIDATE_TAG}")
 _CANDIDATE_WHOLE: Final = re.compile(rf"(?x) ^ {_CANDIDATE_TAG} $")
+_SOURCE_CLASSIFIER: Final = re.compile(r"[_-](?:source-release|sources|source|src)(?=[_-][0-9]|$)")
 
 
 @dataclasses.dataclass
@@ -368,11 +385,11 @@ def is_cyclonedx(name: str) -> bool:
 
 
 def is_cyclonedx_json(name: str) -> bool:
-    return name.endswith(CYCLONEDX_JSON_SUFFIXES)
+    return name.endswith(CYCLONEDX_JSON_SUFFIXES) or (pathlib.Path(name).name in CYCLONEDX_JSON_FILENAMES)
 
 
 def is_cyclonedx_xml(name: str) -> bool:
-    return name.endswith(CYCLONEDX_XML_SUFFIXES)
+    return name.endswith(CYCLONEDX_XML_SUFFIXES) or (pathlib.Path(name).name in CYCLONEDX_XML_FILENAMES)
 
 
 def is_sbom_metadata(name: str) -> bool:
@@ -503,6 +520,11 @@ def sbom_candidates(name: str, suffixes: tuple[str, ...]) -> list[str]:
         for stem in stems:
             base = stem if (str(directory) == ".") else f"{directory}/{stem}"
             candidates.extend(base + suffix for suffix in suffixes)
+    if stems and _SOURCE_CLASSIFIER.search(stems[0]):
+        if any(suffix.endswith(".json") for suffix in suffixes):
+            candidates.extend(str(path.with_name(filename)) for filename in CYCLONEDX_JSON_FILENAMES)
+        if any(suffix.endswith(".xml") for suffix in suffixes):
+            candidates.extend(str(path.with_name(filename)) for filename in CYCLONEDX_XML_FILENAMES)
     return candidates
 
 
@@ -553,7 +575,10 @@ def _classifier_removed(name: str) -> str:
     # must still be attached for this to work, as variant_pattern wants a trailing separator and
     # the extension's dot provides it
     pattern = r"[_-](?:" + "|".join(VARIANT_PATTERNS) + r")(?=" + extension_pattern() + r")"
-    return re.sub(pattern, "", name, count=1)
+    declassified = re.sub(pattern, "", name, count=1)
+    if (declassified != name) or ((stem := artifact_stem(name)) is None):
+        return declassified
+    return _SOURCE_CLASSIFIER.sub("", stem, count=1) + name[len(stem) :]
 
 
 if __name__ == "__main__":
diff --git a/atr/tasks/checks/paths.py b/atr/tasks/checks/paths.py
index 04d9d645..5e86258b 100644
--- a/atr/tasks/checks/paths.py
+++ b/atr/tasks/checks/paths.py
@@ -43,7 +43,7 @@ _DOC_TREE_MAX_FILES: Final = 512
 # Release policy fields which this check relies on - used for result caching
 INPUT_POLICY_KEYS: Final[list[str]] = ["binary_artifact_paths", "source_artifact_paths", "download_path_suffix"]
 INPUT_EXTRA_ARGS: Final[list[str]] = ["is_podling", "all_files"]
-CHECK_VERSION: Final[str] = "9"
+CHECK_VERSION: Final[str] = "10"
 
 
 async def check(args: checks.FunctionArguments) -> results.Results | None:
@@ -355,6 +355,9 @@ async def _check_path_process_single(  # noqa: C901
                 ext_metadata = suffix
                 is_standalone_metadata = True
                 break
+    if (not ext_metadata) and analysis.is_cyclonedx(path.name):
+        ext_metadata = path.suffix
+        is_standalone_metadata = True
     if (not ext_artifact) and (not ext_metadata) and analysis.is_sbom_metadata(path.name):
         ext_metadata = path.suffix
 
@@ -459,7 +462,7 @@ def _is_bundled_doc(relative_path: safe.RelPath) -> bool:
     search = re.search(analysis.extension_pattern(), path_str)
     if search and (search.group("artifact") or search.group("metadata")):
         return False
-    return not any(path_str.endswith(suffix) for suffix in analysis.STANDALONE_METADATA_SUFFIXES)
+    return not analysis.is_cyclonedx(path_str)
 
 
 async def _record(
diff --git a/tests/unit/test_classify.py b/tests/unit/test_classify.py
index 94f3a591..ac652f90 100644
--- a/tests/unit/test_classify.py
+++ b/tests/unit/test_classify.py
@@ -18,6 +18,8 @@
 
 import json
 
+import pytest
+
 import atr.classify as classify
 import atr.models.safe as safe
 
@@ -179,15 +181,15 @@ async def test_sbom_classified_as_sbom():
 
 
 async def test_sbom_content_marker_classifies_unsuffixed_json_as_sbom(tmp_path):
-    path = safe.RelPath("bom.json")
-    (tmp_path / "bom.json").write_text(json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6"}))
+    path = safe.RelPath("inventory.json")
+    (tmp_path / "inventory.json").write_text(json.dumps({"bomFormat": "CycloneDX", "specVersion": "1.6"}))
     result = await classify.classify(path, base_path=safe.StatePath(tmp_path))
     assert result == classify.FileType.SBOM
 
 
 async def test_sbom_content_marker_classifies_unsuffixed_xml_as_sbom(tmp_path):
-    path = safe.RelPath("bom.xml")
-    (tmp_path / "bom.xml").write_text('<?xml version="1.0"?><bom xmlns="http://cyclonedx.org/schema/bom/1.6" />')
+    path = safe.RelPath("inventory.xml")
+    (tmp_path / "inventory.xml").write_text('<?xml version="1.0"?><bom xmlns="http://cyclonedx.org/schema/bom/1.6" />')
     result = await classify.classify(path, base_path=safe.StatePath(tmp_path))
     assert result == classify.FileType.SBOM
 
@@ -199,6 +201,14 @@ async def test_sbom_content_marker_ignores_unrelated_json(tmp_path):
     assert result != classify.FileType.SBOM
 
 
+@pytest.mark.parametrize("name", ["sbom.json", "bom.xml", "sbom.json.asc", "sbom.json.sha512", "example-sbom.json"])
+async def test_sbom_names_classify_consistently(name: str) -> None:
+    path = safe.RelPath(f"sub/{name}")
+    expected = classify.FileType.METADATA if name.endswith((".asc", ".sha512")) else classify.FileType.SBOM
+    assert classify.classify_path(path.as_path()) == expected
+    assert await classify.classify(path) == expected
+
+
 async def test_sbom_signature_classified_as_metadata():
     path = safe.RelPath("apache-widget-1.0.tar.gz.cdx.json.asc")
     assert (await classify.classify(path)) == classify.FileType.METADATA
diff --git a/tests/unit/test_documentation_tree_check.py b/tests/unit/test_documentation_tree_check.py
index 3179443c..37e36ddd 100644
--- a/tests/unit/test_documentation_tree_check.py
+++ b/tests/unit/test_documentation_tree_check.py
@@ -15,6 +15,8 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import unittest.mock as mock
+
 import pytest
 
 import atr.models.safe as safe
@@ -56,8 +58,20 @@ def test_is_bundled_doc_excludes_artifacts_metadata_and_top_level():
     assert paths._is_bundled_doc(safe.RelPath("binaries/de/app-1.0.tar.gz")) is False
     assert paths._is_bundled_doc(safe.RelPath("binaries/de/app-1.0.tar.gz.asc")) is False
     assert paths._is_bundled_doc(safe.RelPath("sub/sbom.cdx.json")) is False
+    assert paths._is_bundled_doc(safe.RelPath("sub/sbom.json")) is False
     assert paths._is_bundled_doc(safe.RelPath("index.html")) is False
 
 
+@pytest.mark.parametrize("name", ["sbom.json", "sbom.json.asc", "sbom.json.sha512", "camel-k-2.11.0-sbom.json"])
+async def test_sbom_paths_accept_source_companions(monkeypatch, tmp_path, name: str) -> None:
+    recorder = _recorder(tmp_path)
+    monkeypatch.setattr(paths.user, "is_admin_async", mock.AsyncMock(return_value=False))
+    relative_paths = {"camel-k-sources-2.11.0.tar.gz", "sbom.json", name}
+    await paths._check_path_process_single(
+        "test", safe.StatePath(tmp_path), safe.RelPath(name), recorder, recorder, recorder, relative_paths, False
+    )
+    assert [status for status, _, _ in recorder.messages] == ["note"]
+
+
 def _recorder(tmp_path) -> RecorderStub:
     return RecorderStub(safe.StatePath(tmp_path), "atr.tasks.checks.paths.check_errors", "5")
diff --git a/tests/unit/test_sbom_candidates.py b/tests/unit/test_sbom_candidates.py
index 05c9570c..7ccff404 100644
--- a/tests/unit/test_sbom_candidates.py
+++ b/tests/unit/test_sbom_candidates.py
@@ -15,6 +15,8 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import pytest
+
 import atr.analysis as analysis
 
 
@@ -35,13 +37,22 @@ def test_sbom_candidates() -> None:
     assert analysis.sbom_candidates("apache-example-1.0.jar", analysis.CYCLONEDX_JSON_SUFFIXES) == [
         "apache-example-1.0.jar.cdx.json",
         "apache-example-1.0.jar-cyclonedx.json",
+        "apache-example-1.0.jar.cyclonedx.json",
+        "apache-example-1.0.jar-sbom.json",
+        "apache-example-1.0.jar-bom.json",
         "apache-example-1.0.cdx.json",
         "apache-example-1.0-cyclonedx.json",
+        "apache-example-1.0.cyclonedx.json",
+        "apache-example-1.0-sbom.json",
+        "apache-example-1.0-bom.json",
     ]
     # A name with no artifact extension has only itself to offer
     assert analysis.sbom_candidates("KEYS", analysis.CYCLONEDX_JSON_SUFFIXES) == [
         "KEYS.cdx.json",
         "KEYS-cyclonedx.json",
+        "KEYS.cyclonedx.json",
+        "KEYS-sbom.json",
+        "KEYS-bom.json",
     ]
     # The XML suffixes reach the stem too, which is what the cataloguer and announce pair on
     assert "apache-example-1.0-cyclonedx.xml" in analysis.sbom_candidates(
@@ -49,6 +60,30 @@ def test_sbom_candidates() -> None:
     )
 
 
+def test_sbom_candidates_generic_priority_and_directory() -> None:
+    candidates = analysis.sbom_candidates("source/camel-k-sources-2.11.0.tar.gz", analysis.SBOM_SUFFIXES)
+    assert candidates[-4:] == ["source/sbom.json", "source/bom.json", "source/sbom.xml", "source/bom.xml"]
+    assert "sbom.json" not in candidates
+    assert candidates.index("source/camel-k-2.11.0-sbom.json") < candidates.index("camel-k-2.11.0-sbom.json")
+    assert candidates.index("source/camel-k-2.11.0-sbom.json") < candidates.index("source/camel-k-2.11.0-bom.xml")
+    json_candidates = analysis.sbom_candidates("camel-k-sources-2.11.0.tar.gz", analysis.CYCLONEDX_JSON_SUFFIXES)
+    assert json_candidates[-2:] == ["sbom.json", "bom.json"]
+    assert not any(path.endswith(".xml") for path in json_candidates)
+
+
+@pytest.mark.parametrize(
+    "name",
+    [
+        "apache-source-tool-1.0.tar.gz",
+        "camel-k-client-2.11.0-linux-amd64.tar.gz",
+        "apache-example-1.0.tar.gz",
+        "apache-example-1.0-sources.tar.gz.asc",
+    ],
+)
+def test_sbom_candidates_generic_requires_source_classifier(name: str) -> None:
+    assert "sbom.json" not in analysis.sbom_candidates(name, analysis.SBOM_SUFFIXES)
+
+
 def test_sbom_candidates_maven_classifier() -> None:
     # The CycloneDX Maven plugin names its output off the version alone, dropping the artifact's
     # own classifier, so a classified artifact still has to reach the declassified SBOM name
@@ -98,10 +133,28 @@ def test_sbom_pairs_artifact() -> None:
     )
 
 
+@pytest.mark.parametrize(
+    ("artifact", "sbom"),
+    [
+        ("apache-ant-antunit-1.5.0-bin.tar.gz", "apache-ant-antunit-1.5.0-bin.tar.gz.cyclonedx.json"),
+        ("apache-camel-4.22.0-src.zip", "apache-camel-4.22.0-sbom.json"),
+        ("apache-camel-4.22.0.pom", "apache-camel-4.22.0-sbom.json"),
+        ("apache-ldap-api-2.1.7-src.zip", "apache-ldap-api-2.1.7-bom.xml"),
+        ("camel-kamelets-sources-4.8.5.tar.gz", "camel-kamelets-4.8.5-sbom.json"),
+        ("camel-k-sources-2.11.0.tar.gz", "sbom.json"),
+        ("apache-example-1.0-source-release.zip", "bom.xml"),
+        ("apache-example-src-1.0.0-beta-1.zip", "apache-example-1.0.0-beta-1-cyclonedx.json"),
+    ],
+)
+def test_sbom_pairs_artifact_conventions(artifact: str, sbom: str) -> None:
+    assert analysis.sbom_pairs_artifact(sbom, artifact, analysis.SBOM_SUFFIXES)
+
+
 def test_classifier_removed() -> None:
     # A classifier abutting the extension comes off, along with its leading separator
     assert analysis._classifier_removed("apache-example-1.0-source-release.zip") == "apache-example-1.0.zip"
     assert analysis._classifier_removed("apache-example-1.0-bin.tar.gz") == "apache-example-1.0.tar.gz"
+    assert analysis._classifier_removed("camel-kamelets-sources-4.8.5.tar.gz") == "camel-kamelets-4.8.5.tar.gz"
     # A name with no classifier is returned untouched, so callers can tell nothing was stripped
     assert analysis._classifier_removed("apache-example-1.0.jar") == "apache-example-1.0.jar"
     # A variant word elsewhere in the name is not a classifier, so it stays put


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org