diff --git a/conan/api/subapi/install.py b/conan/api/subapi/install.py index cf3a9c4e744..9630105ac65 100644 --- a/conan/api/subapi/install.py +++ b/conan/api/subapi/install.py @@ -88,4 +88,4 @@ def install_consumer(self, deps_graph, generators=None, source_folder=None, outp final_generators.append(gen) conanfile.generators = final_generators app = ConanApp(self.conan_api) - write_generators(conanfile, app, envs_generation=envs_generation) + write_generators(conanfile, app, envs_generation=envs_generation) # TODO add deps_graph without cli node diff --git a/conan/internal/api/install/generators.py b/conan/internal/api/install/generators.py index 3fd02da74ef..5f8d418d51b 100644 --- a/conan/internal/api/install/generators.py +++ b/conan/internal/api/install/generators.py @@ -3,6 +3,7 @@ import traceback import importlib +from conan.api.output import ConanOutput from conan.internal.cache.home_paths import HomePaths from conans.client.subsystems import deduce_subsystem, subsystem_path from conan.internal.errors import conanfile_exception_formatter @@ -71,7 +72,7 @@ def load_cache_generators(path): return result -def write_generators(conanfile, app, envs_generation=None): +def write_generators(conanfile, app, envs_generation=None, deps_graph=None): new_gen_folder = conanfile.generators_folder _receive_conf(conanfile) @@ -135,6 +136,8 @@ def write_generators(conanfile, app, envs_generation=None): env.generate() _generate_aggregated_env(conanfile) + if deps_graph: + _generate_graph_manifests(deps_graph, app) hook_manager.execute("post_generate", conanfile=conanfile) @@ -152,6 +155,24 @@ def _receive_conf(conanfile): conanfile.conf.compose_conf(build_require.conf_info) +def _generate_graph_manifests(sub_graph, app): + from conans.client.loader import load_python_file + sbom_plugin_path = HomePaths(app.cache_folder).sbom_manifest_plugin_path + if os.path.exists(sbom_plugin_path): + mod, _ = load_python_file(sbom_plugin_path) + + if not hasattr(mod, "generate_sbom"): + raise ConanException( + f"SBOM manifest plugin does not have a 'generate_sbom' method") + if not callable(mod.generate_sbom): + raise ConanException( + f"SBOM manifest plugin 'generate_sbom' is not a function") + + ConanOutput().warning(f"generating sbom", warn_tag="experimental") + # TODO think if this is conanfile or conanfile._conan_node + return mod.generate_sbom(sub_graph) + + def _generate_aggregated_env(conanfile): def deactivates(filenames): diff --git a/conan/internal/cache/home_paths.py b/conan/internal/cache/home_paths.py index 30e79002b34..bef60942465 100644 --- a/conan/internal/cache/home_paths.py +++ b/conan/internal/cache/home_paths.py @@ -67,6 +67,10 @@ def auth_source_plugin_path(self): def sign_plugin_path(self): return os.path.join(self._home, _EXTENSIONS_FOLDER, _PLUGINS, "sign", "sign.py") + @property + def sbom_manifest_plugin_path(self): + return os.path.join(self._home, _EXTENSIONS_FOLDER, _PLUGINS, "sbom.py") + @property def remotes_path(self): return os.path.join(self._home, "remotes.json") diff --git a/conans/client/graph/graph.py b/conans/client/graph/graph.py index d79cf16ba1f..00d8f4512a1 100644 --- a/conans/client/graph/graph.py +++ b/conans/client/graph/graph.py @@ -69,6 +69,23 @@ def __init__(self, ref, conanfile, context, recipe=None, path=None, test=False): self.is_conf = False self.replaced_requires = {} # To track the replaced requires for self.dependencies[old-ref] + 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 diff --git a/conans/client/graph/sbom.py b/conans/client/graph/sbom.py new file mode 100644 index 00000000000..afacdac92f1 --- /dev/null +++ b/conans/client/graph/sbom.py @@ -0,0 +1,7 @@ +from conans.client.graph.spdx import spdx_json_generator +from conan.internal.cache.home_paths import HomePaths + +def migrate_sbom_file(cache_folder): + from conans.client.migrations import update_file + sbom_path = HomePaths(cache_folder).sbom_manifest_plugin_path + update_file(sbom_path, spdx_json_generator) diff --git a/conans/client/graph/spdx.py b/conans/client/graph/spdx.py new file mode 100644 index 00000000000..d5adc820575 --- /dev/null +++ b/conans/client/graph/spdx.py @@ -0,0 +1,56 @@ +spdx_json_generator = """ +import time +import json +from datetime import datetime, timezone +from conan import conan_version +from conan.errors import ConanException + +import pathlib + +def generate_sbom(graph, **kwargs): + name = graph.root.name + version = graph.root.ref.version + date = datetime.fromtimestamp(time.time(), tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + packages = [] + for dependency in graph.nodes: + packages.append( + { + "name": dependency.ref.name, + "SPDXID": f"SPDXRef-{dependency.ref}", + "version": str(dependency.ref.version), + "license": dependency.conanfile.license or "NOASSERTION", + }) + files = [] + # https://spdx.github.io/spdx-spec/v2.2.2/package-information/ + data = { + "SPDXVersion": "SPDX-2.2", + "DataLicense": "CC0-1.0", + "SPDXID": "SPDXRef-DOCUMENT", + "DocumentName": f"{name}-{version}", + "DocumentNamespace": f"http://spdx.org/spdxdocs/{name}-{version}-{date}", # the date or hash to make it unique + "Creator": f"Tool: Conan-{conan_version}", + "Created": date, #YYYY-MM-DDThh:mm:ssZ + "Packages": [{ + "PackageName": p["name"], + "SPDXID": p["SPDXID"], + "PackageVersion": p["version"], + "PackageDownloadLocation": "NOASSERTION", + "FilesAnalyzed": False, + "PackageLicenseConcluded": p["license"], + "PackageLicenseDeclared": p["license"], + } for p in packages], + # "Files": [{ + # "FileName": f["path"], # Path to file + # "SPDXID": f["SPDXID"], + # "FileChecksum": f'{f["checksum_algorithm"]}: {f["checksum_algorithm_value"]}', + # "LicenseConcluded": f["licence"], + # "LicenseInfoInFile": f["licence"], + # "FileCopyrightText": "NOASSERTION" + # } for f in files], + } + try: + with open(f"../{name}-{version}.spdx.json", 'w') as f: + json.dump(data, f, indent=4) + except Exception as e: + ConanException("error generating spdx file") +""" diff --git a/conans/client/installer.py b/conans/client/installer.py index bdeb24efe63..aba0c01f46d 100644 --- a/conans/client/installer.py +++ b/conans/client/installer.py @@ -91,7 +91,7 @@ def _copy_sources(conanfile, source_folder, build_folder): raise ConanException("%s\nError copying sources to build folder" % msg) def _build(self, conanfile, pref): - write_generators(conanfile, self._app) + write_generators(conanfile, self._app, deps_graph=conanfile.subgraph) try: run_build_method(conanfile, self._hook_manager) diff --git a/conans/client/migrations.py b/conans/client/migrations.py index 82997ba4e9e..03487d423ef 100644 --- a/conans/client/migrations.py +++ b/conans/client/migrations.py @@ -52,6 +52,9 @@ def _apply_migrations(self, old_version): # Update profile plugin from conan.internal.api.profile.profile_loader import migrate_profile_plugin migrate_profile_plugin(self.cache_folder) + # Update sbom manifest plugins + from conans.client.graph.sbom import migrate_sbom_file + migrate_sbom_file(self.cache_folder) if old_version and old_version < "2.0.14-": _migrate_pkg_db_lru(self.cache_folder, old_version) diff --git a/conans/model/conan_file.py b/conans/model/conan_file.py index f61cee57d38..b25bf331e11 100644 --- a/conans/model/conan_file.py +++ b/conans/model/conan_file.py @@ -186,6 +186,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 diff --git a/test/integration/graph/test_subgraph_reports.py b/test/integration/graph/test_subgraph_reports.py new file mode 100644 index 00000000000..95b425b3594 --- /dev/null +++ b/test/integration/graph/test_subgraph_reports.py @@ -0,0 +1,44 @@ +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"]) + diff --git a/test/integration/sbom/__init__.py b/test/integration/sbom/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/sbom/test_sbom.py b/test/integration/sbom/test_sbom.py new file mode 100644 index 00000000000..0e7a02e566a --- /dev/null +++ b/test/integration/sbom/test_sbom.py @@ -0,0 +1,24 @@ +from conan.test.assets.genconanfile import GenConanfile +from conan.test.utils.tools import TestClient +import os + + +def test_sbom_generation_create(): + tc = TestClient() + 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") + foo_layout = tc.created_layout() + + assert os.path.exists(os.path.join(foo_layout.build(), "foo-1.0.spdx.json")) + +def test_sbom_generation_install(): + tc = TestClient() + 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") + + tc.run("install --requires=foo/1.0") + assert os.path.exists(os.path.join(tc.current_folder, "foo-1.0.spdx.json")) #TODO FIX this test