Skip to content

Commit

Permalink
subgraph with test and cyclone as tool and test
Browse files Browse the repository at this point in the history
  • Loading branch information
ErniGH committed Jan 10, 2025
1 parent b4936c2 commit efbd44d
Show file tree
Hide file tree
Showing 6 changed files with 253 additions and 0 deletions.
Empty file added conan/tools/sbom/__init__.py
Empty file.
73 changes: 73 additions & 0 deletions conan/tools/sbom/cycloneDX.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@

def cyclonedx_1_4(graph, **kwargs):
import uuid
import time
from datetime import datetime, timezone

has_special_root_node = not (getattr(graph.root.ref, "name", False) and getattr(graph.root.ref, "version", False) and getattr(graph.root.ref, "revision", False))
special_id = str(uuid.uuid4())

components = [node for node in graph.nodes]
if has_special_root_node:
components = components[1:]

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.dependencies]}
dependencies.append(deps)
for c in components:
deps = {"ref": f"pkg:conan/{c.name}@{c.ref.version}?rref={c.ref.revision}"}
depends_on = [f"pkg:conan/{d.dst.name}@{d.dst.ref.version}?rref={d.dst.ref.revision}" for d in c.dependencies]
if depends_on:
deps["dependsOn"] = depends_on
dependencies.append(deps)

def _calculate_licenses(component):
if isinstance(component.conanfile.license, str): # Just one license
return [{"license": {
"id": component.conanfile.license
}}]
return [{"license": {
"id": l
}} for l in c.conanfile.license]

sbom_cyclonedx_1_4 = {
**({"components": [{
"author": "Conan",
"bom-ref": special_id if has_special_root_node else f"pkg:conan/{c.name}@{c.ref.version}?rref={c.ref.revision}",
"description": c.conanfile.description,
**({"externalReferences": [{
"type": "website",
"url": c.conanfile.homepage
}]} if c.conanfile.homepage else {}),
**({"licenses": _calculate_licenses(c)} if c.conanfile.license else {}),
"name": c.name,
"fpurl": f"pkg:conan/{c.name}@{c.ref.version}?rref={c.ref.revision}",
"type": "library",
"version": str(c.ref.version),
} for c in components]} if components else {}),
**({"dependencies": dependencies} if dependencies else {}),
"metadata": {
"component": {
"author": "Conan",
"bom-ref": special_id if has_special_root_node else f"pkg:conan/{c.name}@{c.ref.version}?rref={c.ref.revision}",
"name": graph.root.conanfile.display_name,
"type": "library"
},
"timestamp": f"{datetime.fromtimestamp(time.time(), tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}",
"tools": [{
"externalReferences": [{
"type": "website",
"url": "https://github.com/conan-io/conan"
}],
"name": "Conan-io"
}],
},
"serialNumber": f"urn:uuid:{uuid.uuid4()}",
"bomFormat": "CycloneDX",
"specVersion": "1.4",
"version": 1,
}
return sbom_cyclonedx_1_4
17 changes: 17 additions & 0 deletions conans/client/graph/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,23 @@ def __init__(self, ref, conanfile, context, recipe=None, path=None, test=False):
self.replaced_requires = {} # To track the replaced requires for self.dependencies[old-ref]
self.skipped_build_requires = False

def subgraph(self):
nodes = [self]
opened = [self]
while opened:
new_opened = []
for o in opened:
for n in o.neighbors():
if n not in nodes:
nodes.append(n)
if n not in opened:
new_opened.append(n)
opened = new_opened

graph = DepsGraph()
graph.nodes = nodes
return graph

def __lt__(self, other):
"""
@type other: Node
Expand Down
4 changes: 4 additions & 0 deletions conans/model/conan_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ def output(self):
def context(self):
return self._conan_node.context

@property
def subgraph(self):
return self._conan_node.subgraph()

@property
def dependencies(self):
# Caching it, this object is requested many times
Expand Down
116 changes: 116 additions & 0 deletions test/functional/sbom/test_cycloneDX.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import textwrap

import pytest

from conan.test.assets.genconanfile import GenConanfile
from conan.test.utils.tools import TestClient
from conans.util.files import save
import os

sbom_hook = """
import json
import os
from conan.errors import ConanException
from conan.api.output import ConanOutput
from conan.tools.sbom.cycloneDX import cyclonedx_1_4
def _generate_cyclonedx_1_4_file(conanfile):
try:
sbom_cyclonedx_1_4 = cyclonedx_1_4(conanfile.subgraph)
metadata_folder = conanfile.package_metadata_folder
file_name = "cyclonedx_1_4.json"
with open(os.path.join(metadata_folder, file_name), 'w') as f:
json.dump(sbom_cyclonedx_1_4, f, indent=4)
ConanOutput().success(f"CYCLONEDX CREATED - {conanfile.package_metadata_folder}")
except Exception as e:
ConanException("error generating CYCLONEDX file")
def post_package(conanfile):
_generate_cyclonedx_1_4_file(conanfile)
def post_generate(conanfile):
_generate_cyclonedx_1_4_file(conanfile)
"""

@pytest.fixture()
def hook_setup():
tc = TestClient()
hook_path = os.path.join(tc.paths.hooks_path, "hook_sbom.py")
save(hook_path, sbom_hook)
return tc

def test_sbom_generation_create(hook_setup):
tc = hook_setup
tc.run("new cmake_lib -d name=dep -d version=1.0")
tc.run("export .")
tc.run("new cmake_lib -d name=foo -d version=1.0 -d requires=dep/1.0 -f")
tc.run("export .")
tc.run("new cmake_lib -d name=bar -d version=1.0 -d requires=foo/1.0 -f")
# bar -> foo -> dep
tc.run("create . --build=missing")
bar_layout = tc.created_layout()
assert os.path.exists(os.path.join(bar_layout.build(),"..", "d", "metadata", "cyclonedx_1_4.json"))

def test_sbom_generation_install_requires(hook_setup):
tc = hook_setup
tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"),
"conanfile.py": GenConanfile("foo", "1.0").with_requires("dep/1.0")})
tc.run("export dep")
tc.run("create . --build=missing")

#cli -> foo -> dep
tc.run("install --requires=foo/1.0")
assert os.path.exists(os.path.join(tc.current_folder, "cyclonedx_1_4.json"))

def test_sbom_generation_install_path(hook_setup):
tc = hook_setup
tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"),
"conanfile.py": GenConanfile("foo", "1.0").with_requires("dep/1.0")})
tc.run("create dep")

#foo -> dep
tc.run("install .")
assert os.path.exists(os.path.join(tc.current_folder, "cyclonedx_1_4.json"))

def test_sbom_generation_install_path_consumer(hook_setup):
tc = hook_setup
tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"),
"conanfile.py": GenConanfile().with_requires("dep/1.0")})
tc.run("create dep")

#conanfile.py -> dep
tc.run("install .")
assert os.path.exists(os.path.join(tc.current_folder, "cyclonedx_1_4.json"))

def test_sbom_generation_install_path_txt(hook_setup):
tc = hook_setup
tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"),
"conanfile.txt": textwrap.dedent(
"""
[requires]
dep/1.0
"""
)})
tc.run("create dep")

#foo -> dep
tc.run("install .")
assert os.path.exists(os.path.join(tc.current_folder, "cyclonedx_1_4.json"))

def test_sbom_generation_skipped_dependencies(hook_setup):
tc = hook_setup
tc.save({"dep/conanfile.py": GenConanfile("dep", "1.0"),
"app/conanfile.py": GenConanfile("app", "1.0")
.with_package_type("application")
.with_requires("dep/1.0"),
"conanfile.py": GenConanfile("foo", "1.0").with_tool_requires("app/1.0")})
tc.run("create dep")
tc.run("create app")
tc.run("create .")
create_layout = tc.created_layout()

cyclone_path = os.path.join(create_layout.build(), "..", "d", "metadata", "cyclonedx_1_4.json")
content = tc.load(cyclone_path)
# A skipped dependency also shows up in the sbom
assert "pkg:conan/dep@1.0?rref=6a99f55e933fb6feeb96df134c33af44" in content
43 changes: 43 additions & 0 deletions test/integration/graph/test_subgraph_reports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import json
import os
import textwrap

from conan.test.assets.genconanfile import GenConanfile
from conan.test.utils.tools import TestClient
from conans.util.files import load


def test_subgraph_reports():
c = TestClient()
subgraph_hook = textwrap.dedent("""\
import os, json
from conan.tools.files import save
from conans.model.graph_lock import Lockfile
def post_package(conanfile):
subgraph = conanfile.subgraph
save(conanfile, os.path.join(conanfile.package_folder, "..", "..", f"{conanfile.name}-conangraph.json"),
json.dumps(subgraph.serialize(), indent=2))
save(conanfile, os.path.join(conanfile.package_folder, "..", "..", f"{conanfile.name}-conan.lock"),
Lockfile(subgraph).dumps())
""")

c.save_home({"extensions/hooks/subgraph_hook/hook_subgraph.py": subgraph_hook})
c.save({"dep/conanfile.py": GenConanfile("dep", "0.1"),
"pkg/conanfile.py": GenConanfile("pkg", "0.1").with_requirement("dep/0.1"),
"app/conanfile.py": GenConanfile("app", "0.1").with_requirement("pkg/0.1")})
c.run("export dep")
c.run("export pkg")
# app -> pkg -> dep
c.run("create app --build=missing --format=json")

app_graph = json.loads(load(os.path.join(c.cache.builds_folder, "app-conangraph.json")))
pkg_graph = json.loads(load(os.path.join(c.cache.builds_folder, "pkg-conangraph.json")))
dep_graph = json.loads(load(os.path.join(c.cache.builds_folder, "dep-conangraph.json")))

app_lock = json.loads(load(os.path.join(c.cache.builds_folder, "app-conan.lock")))
pkg_lock = json.loads(load(os.path.join(c.cache.builds_folder, "pkg-conan.lock")))
dep_lock = json.loads(load(os.path.join(c.cache.builds_folder, "dep-conan.lock")))

assert len(app_graph["nodes"]) == len(app_lock["requires"])
assert len(pkg_graph["nodes"]) == len(pkg_lock["requires"])
assert len(dep_graph["nodes"]) == len(dep_lock["requires"])

0 comments on commit efbd44d

Please # to comment.