Repository: conan-io/conan
Pull request #18649 (2025-07-15 13:17:20)

add `@user/channel` to SBOM
Changelog: Feature: Add user channel to CycloneDX SBOM ``sbom_ref`` field
Docs: omit

close: https://github.com/conan-io/conan/issues/18438


Patch:
diff --git a/conan/internal/loader.py b/conan/internal/loader.py
index 75b10db35..f3634ee9a 100644
--- a/conan/internal/loader.py
+++ b/conan/internal/loader.py
@@ -133,6 +133,9 @@ class ConanFileLoader:
                                      % (channel, conanfile.channel))
             conanfile.channel = channel
 
+        if conanfile.channel and not conanfile.user:
+            raise ConanException(f"{conanfile_path}: Can't specify channel '{conanfile.channel}' without user")
+
         if hasattr(conanfile, "set_name"):
             with conanfile_exception_formatter("conanfile.py", "set_name"):
                 conanfile.set_name()
@@ -167,8 +170,6 @@ class ConanFileLoader:
                                     remotes, update, check_update,
                                     tested_python_requires=tested_python_requires)
 
-        if conanfile.channel and not conanfile.user:
-            raise ConanException(f"{conanfile_path}: Can't specify channel without user")
         ref = RecipeReference(conanfile.name, conanfile.version, conanfile.user, conanfile.channel)
         if str(ref):
             conanfile.display_name = "%s (%s)" % (os.path.basename(conanfile_path), str(ref))
diff --git a/conan/tools/sbom/cyclonedx.py b/conan/tools/sbom/cyclonedx.py
index 97d18fe71..521cdf442 100644
--- a/conan/tools/sbom/cyclonedx.py
+++ b/conan/tools/sbom/cyclonedx.py
@@ -40,14 +40,13 @@ def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, **kwar
     dependencies = []
     if has_special_root_node:
         deps = {"ref": special_id,
-                "dependsOn": [f"pkg:conan/{d.dst.name}@{d.dst.ref.version}?rref={d.dst.ref.revision}"
-                              for d in graph.root.edges]}
+                "dependsOn": [_calculate_bomref(d.dst) for d in graph.root.edges]}
         dependencies.append(deps)
     for c in nodes:
-        deps = {"ref": f"pkg:conan/{c.name}@{c.ref.version}?rref={c.ref.revision}"}
+        deps = {"ref": _calculate_bomref(c)}
         dep = [d for d in c.edges if (d.dst.context == "host" or add_build) and (not d.dst.test or add_tests)]
 
-        depends_on = [f"pkg:conan/{d.dst.name}@{d.dst.ref.version}?rref={d.dst.ref.revision}" for d in dep]
+        depends_on = [_calculate_bomref(d.dst) for d in dep]
         if depends_on:
             deps["dependsOn"] = depends_on
         dependencies.append(deps)
@@ -55,7 +54,7 @@ def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, **kwar
     sbom_cyclonedx_1_4 = {
         **({"components": [{
             "author": node.conanfile.author or "Unknown",
-            "bom-ref": f"pkg:conan/{node.name}@{node.ref.version}?rref={node.ref.revision}",
+            "bom-ref": _calculate_bomref(node),
             "description": node.conanfile.description,
             **({"externalReferences": [{
                 "type": "website",
@@ -71,7 +70,7 @@ def cyclonedx_1_4(conanfile, name=None, add_build=False, add_tests=False, **kwar
         "metadata": {
             "component": {
                 "author": conanfile.author or "Unknown",
-                "bom-ref": special_id if has_special_root_node else f"pkg:conan/{conanfile.name}@{conanfile.ref.version}?rref={conanfile.ref.revision}",
+                "bom-ref": special_id if has_special_root_node else _calculate_bomref(conanfile),
                 "name": name if name else name_default,
                 "type": "application" if conanfile.package_type == "application" else "library",
             },
@@ -131,14 +130,14 @@ def cyclonedx_1_6(conanfile, name=None, add_build=False, add_tests=False, **kwar
     dependencies = []
     if has_special_root_node:
         deps = {"ref": special_id,
-                "dependsOn": [f"pkg:conan/{d.dst.name}@{d.dst.ref.version}?rref={d.dst.ref.revision}"
+                "dependsOn": [_calculate_bomref(d.dst)
                               for d in graph.root.edges]}
         dependencies.append(deps)
     for c in nodes:
-        deps = {"ref": f"pkg:conan/{c.name}@{c.ref.version}?rref={c.ref.revision}"}
+        deps = {"ref": _calculate_bomref(c)}
         dep = [d for d in c.edges if (d.dst.context == "host" or add_build) and (not d.dst.test or add_tests)]
 
-        depends_on = [f"pkg:conan/{d.dst.name}@{d.dst.ref.version}?rref={d.dst.ref.revision}" for d in dep]
+        depends_on = [_calculate_bomref(d.dst) for d in dep]
         if depends_on:
             deps["dependsOn"] = depends_on
         dependencies.append(deps)
@@ -146,7 +145,7 @@ def cyclonedx_1_6(conanfile, name=None, add_build=False, add_tests=False, **kwar
     sbom_cyclonedx_1_6 = {
         **({"components": [{
             **({"authors": [{"name": node.conanfile.author}]} if node.conanfile.author else {}),
-            "bom-ref": f"pkg:conan/{node.name}@{node.ref.version}?rref={node.ref.revision}",
+            "bom-ref": _calculate_bomref(node),
             "description": node.conanfile.description,
             **({"externalReferences": [{
                 "type": "website",
@@ -162,7 +161,7 @@ def cyclonedx_1_6(conanfile, name=None, add_build=False, add_tests=False, **kwar
         "metadata": {
             "component": {
                 **({"authors": [{"name": conanfile.author}]} if conanfile.author else {}),
-                "bom-ref": special_id if has_special_root_node else f"pkg:conan/{conanfile.name}@{conanfile.ref.version}?rref={conanfile.ref.revision}",
+                "bom-ref": special_id if has_special_root_node else _calculate_bomref(conanfile),
                 "name": name if name else name_default,
                 "type": "application" if conanfile.package_type == "application" else "library"
             },
@@ -198,4 +197,10 @@ def _calculate_licenses(component):
         for l in licenses
     ]
 
+def _calculate_bomref(component):
+    user = f"&user={component.ref.user}" if component.ref.user else ""
+    channel = f"&channel={component.ref.channel}" if component.ref.channel else ""
+    return f"pkg:conan/{component.name}@{component.ref.version}?rref={component.ref.revision}{user}{channel}"
+
+
 


Test patch:
diff --git a/test/functional/sbom/test_cyclonedx.py b/test/functional/sbom/test_cyclonedx.py
index a6ca198da..c661ca3ae 100644
--- a/test/functional/sbom/test_cyclonedx.py
+++ b/test/functional/sbom/test_cyclonedx.py
@@ -360,3 +360,28 @@ class TestCyclonedx:
             assert not content_json["components"][0].get("author")
             assert content_json["components"][0]["authors"][0]["name"] == 'conan-dev'
             assert content_json["components"][0]["type"] == 'application'
+
+    @pytest.mark.parametrize("user, channel, user_dep, channel_dep", [("user", None, "user_dep", None), ("user", "channel", "user_dep", "channel_dep")])
+    def test_sbom_user_path(self, hook_setup_post_package_tl, user, channel, user_dep, channel_dep):
+        tc = hook_setup_post_package_tl
+        channel_ref = f"/{channel_dep}" if channel_dep else ""
+        tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"),
+                 "conanfile.py": GenConanfile("main", "1.0").with_requires(f"dep/1.0@{user_dep}{channel_ref}")})
+        command = "create dep"
+        if user: command += f" --user={user_dep}"
+        if channel: command += f" --channel={channel_dep}"
+
+        tc.run(command)
+
+        command = "create ."
+        if user: command += f" --user={user}"
+        if channel: command += f" --channel={channel}"
+        tc.run(command)
+
+        create_layout = tc.created_layout()
+        cyclone_path = os.path.join(create_layout.metadata(), "sbom.cdx.json")
+        content = tc.load(cyclone_path)
+        content_json = json.loads(content)
+
+        assert content_json["components"][0]["bom-ref"].split("&user=")[1] == f"{user}&channel={channel}" if channel else user
+        assert content_json["dependencies"][0]["dependsOn"][0].split("&user=")[1] == f"{user_dep}&channel={channel_dep}" if channel_dep else user_dep
diff --git a/test/integration/command/export/export_test.py b/test/integration/command/export/export_test.py
index 8bd40f9e9..01b976c85 100644
--- a/test/integration/command/export/export_test.py
+++ b/test/integration/command/export/export_test.py
@@ -33,6 +33,22 @@ class TestExportSettings:
         client.save({"conanfile.py": GenConanfile("lib", "1.0")})
         client.run("export . --user=lasote")
         assert "lib/1.0@lasote: Exporting package recipe" in client.out
+        client.run("export . --channel=channel", assert_error=True)
+        assert "Can't specify channel 'channel' without user" in client.out
+
+        client.save({"conanfile.py": GenConanfile("lib", "1.0").with_class_attribute('channel = "channel"')})
+        client.run("export .", assert_error=True)
+        assert "Can't specify channel 'channel' without user" in client.out
+
+        client.run("export . --user=user")
+        assert "Exported: lib/1.0@user/channel" in client.out
+
+        client.save({"conanfile.py": GenConanfile("lib", "1.0").with_class_attribute('user = "user"')})
+        client.run("export .")
+        assert "lib/1.0@user: Exported" in client.out
+        client.run("export . --channel=channel")
+        assert "lib/1.0@user/channel: Exported" in client.out
+
 
     def test_export_read_only(self):
         client = TestClient(light=True)
diff --git a/test/integration/command/install/install_test.py b/test/integration/command/install/install_test.py
index 81e1c51d2..e0e252764 100644
--- a/test/integration/command/install/install_test.py
+++ b/test/integration/command/install/install_test.py
@@ -32,7 +32,7 @@ def test_install_reference_error(client):
     assert "ERROR: Can't use --name, --version, --user or --channel arguments with --requires" in client.out
     client.save({"conanfile.py": GenConanfile("pkg", "1.0")})
     client.run("install . --channel=testing", assert_error=True)
-    assert "Can't specify channel without user" in client.out
+    assert "Can't specify channel 'testing' without user" in client.out
 
 
 def test_install_args_error():